Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 73b0a91c13 docs/layers.md: the rule's wording, replays split, the licence note
- duckybertduck's placement rule in their words (tooling excepted; the
  rest can be built on what's below), plus: a feature often splits, the
  part others build on is platform, the finished feature Horizon.
- Replays: recording and playback (.mcpr) are platform; the Studio
  (camera paths, timeline) is Horizon or a native mod.
- Licence: if this repository goes GPL, the linked Horizon layer must be
  GPL-compatible (no closed native code); WASM mods could stay closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:10:39 +01:00
Horizon agentandClaude Opus 5.5 e547357ae7 docs/layers.md: controllers, splitscreen, Replay Studio, UI pages; the placement rule
Merges origin/main (keeps the rewritten page over #12993's edit of the
first version, which it already covers) and, from #contributors:
- duckybertduck's placement rule opens "Where a new feature goes".
- The compositor is engine (part of how the renderer presents a frame,
  idle on vanilla's one-view path), as Martipops and duckybertduck argued.
- Controllers, splitscreen, Replay Studio, the HUD widget framework and a
  UI page API are platform, on engine hooks named in a new section
  (actions and held state today; move vector, look delta and menu focus
  as an input source; screens). PvP presets and widgets stay Horizon.
- Plan states updated (#12983, #12990, #13015, #12993, #13002, #13058
  merged; #13060, #13094), M6 lists the input-source and page work.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:08:24 +01:00
Ludovic Marie db87c15e7c Merge pull request #13039 from Ragbie/fix/boat-float-height
🤖 Agent: the float sums match Boat: getWaterLevelAbove returns blockY + height in float, floatBoat sets waterLevel to getY(1.0) and subtracts getBbHeight in float before the double 0.101; I merged main and kept main's corpus with this PR's cases added.
2026-11-09 02:56:59 +01:00
Ludovic Marie 83e4f7028b Merge pull request #13025 from DUzzL/fix/12908-tnt-fuse-number
🤖 Agent: verify failed only on main's old catalog_matches_hook_points bug (fixed by #13028); I merged main into the branch and requeued, nothing in the PR changed.
2026-11-09 02:56:55 +01:00
Horizon agent cc9da4ef8f Merge remote-tracking branch 'origin/main' into m-13039
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 02:55:18 +01:00
Horizon agentandClaude Opus 5.5 b310f706dd Merge main into #13025 (main's catalog_matches_hook_points fix, #13028)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:54:11 +01:00
Ludovic Marie b5bbc989ae Merge pull request #13017 from NikitaMGrimm/entity-uuid-draws
🤖 Agent: the UUID comes from the mob's own random before LivingEntity's yaw draw, and the spawn yaw keeps the level's random, as Entity.<init> then Mob does; seed_sync cases 8-13 pin it.
2026-11-09 02:44:07 +01:00
Ludovic Marie 3f489a5e73 Merge pull request #12793 from RLC248/fix/12524-projectile-common-state
🤖 Agent: merged main; entity_nbt.rs keeps both load_projectile_entity_fields and main's OnGround read, so projectiles still restore on_ground.
2026-11-09 02:44:03 +01:00
Ludovic Marie ca6a29bf50 Merge pull request #13035 from TheMrZZ/fix/h078-full-chunk-feature-writes
🤖 Agent: merged main; levels.rs keeps main's BlendingData test and adds this PR's full-chunk feature-write test before it.
2026-11-09 02:43:58 +01:00
Ludovic Marie b6e5125c26 Merge pull request #13023 from TheMrZZ/audit/item-on-block-use-cooldown-start
🤖 Agent: main moved use_hand into game/use_hand.rs, so I ported the fix there (apply_use_cooldown after a successful useOn, as ItemStack.useOn's applyAfterUseComponentSideEffects) and dropped the one-off docs/parity note and probe.
2026-11-09 02:43:55 +01:00
Ludovic Marie 85f2a3d6c1 Merge pull request #13021 from Ragbie/fix/vehicle-fall-clip
🤖 Agent: merged main; command_exec_corpus.py is main's file plus this PR's new cases and groups() entries, no shared case changed.
2026-11-09 02:43:51 +01:00
Ludovic Marie c3d493b309 Merge pull request #13032 from voduff/bobber-block-hits
🤖 Agent: merged main; ProjectileHitter now sits before main's SpawnYaw enum in creatures.rs, both kept whole.
2026-11-09 02:43:47 +01:00
Ludovic Marie 0c080475bc Merge pull request #13031 from TheMrZZ/fix/fence-leash-force
🤖 Agent: merged main (mod declaration in game.rs, rustfmt); fence_leash_force_tests sits under #[cfg(test)] like its neighbours.
2026-11-09 02:43:44 +01:00
Ludovic Marie 89a904c8a5 Merge pull request #13027 from Dangerousbros/perf/lod-keep-tiles
🤖 Agent: I scaled the kept-tile budget to the view (fit_to_view: KEPT_BYTES times tiles / 4700, the 512-chunk view's count, capped at KEPT_BYTES) so a small view no longer holds the full budget, with a test; merged main.
2026-11-09 02:43:40 +01:00
Ludovic Marie 93848bf19b Merge pull request #13041 from vnsmoreira/packs/polytone-vanilla-overrides
🤖 Agent: stacked on #13029 (queued first); the override map is rebuilt from the registry on each pack load, so a pack swap drops overrides the new pack no longer has.
2026-11-09 02:43:35 +01:00
Ludovic Marie 0ffdf24711 Merge pull request #13038 from vnsmoreira/packs/polytone-global-rain
🤖 Agent: g.rain is now (rain + thunder) / 2 in both the particle and post expressions, and the fog-end tests pin a few rain/thunder pairs with independent numbers.
2026-11-09 02:43:31 +01:00
Ludovic Marie 0a41b1b12c Merge pull request #13030 from NikitaMGrimm/hdr-scaled-screenshots
🤖 Agent: a layered HDR frame keeps its own targets through the scaled screenshot; only the non-layered path takes and releases them, so the next frame after the shot needs no rebuild.
2026-11-09 02:43:28 +01:00
Ludovic Marie 3888a0fcc3 Merge pull request #13029 from vnsmoreira/packs/polytone-model-particles
🤖 Agent: a model quad whose sprite is missing from the atlas is skipped, not drawn with a zero rect, so a pack with a typo shows a gap instead of a stretched texture.
2026-11-09 02:43:24 +01:00
Ludovic Marie f7ce74d8ab Merge pull request #12993 from horizon-rust/layers/addresses-home
🤖 Agent: the moved constants keep their old public names (horizon_telemetry::DEFAULT_ENDPOINT, horizon_friends' STUN list, etc.) as re-pointers to horizon-addresses, so callers don't change; main merged in with telemetry taking both horizon-addresses and horizon-platform, and all four workspaces' locks are --locked clean.
2026-11-09 02:37:10 +01:00
Ludovic Marie 5e79ce9da6 Merge pull request #13015 from horizon-rust/layers/file-size
🤖 Agent: check_file_size.py diffs against the merge base with rename detection, so a file over 3000 lines that is renamed and grows still fails, while big files that shrink or stay the same pass; no pinned list of files.
2026-11-09 02:34:06 +01:00
Horizon agent 613205b79c Merge remote-tracking branch 'origin/main' into layers/addresses-home
# Conflicts:
#	Cargo.lock
#	crates/horizon-telemetry-server/Cargo.lock
#	crates/horizon-telemetry/Cargo.toml
#	launcher/Cargo.lock
2026-11-09 02:33:35 +01:00
Ludovic Marie 68f661b686 Merge pull request #13058 from horizon-rust/layers/system
🤖 Agent: the probes move unchanged; System::detect_in now copies a horizon_system::Machine into the same payload fields, and telemetry reaches horizon-system only through horizon-platform's re-export, so no new Horizon → engine exception (still 47).
2026-11-09 02:27:51 +01:00
Horizon agentandClaude Opus 5.5 1cf95d4de1 Keep fake_player_tick_tests test-only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:18:10 +01:00
Vinícius MoreiraandClaude Opus 5.5 b6902327b4 Polytone: custom particles that replace vanilla ones
A pack's custom particle under a vanilla id (`minecraft:rain` from
assets/minecraft/polytone/custom_particles/rain.json) now replaces that
vanilla particle wherever the game spawns it, drawn from the vanilla
type's sprite set, as the wiki describes. Visual Effects+ turns rain
splashes into ripples on the ground this way; they now show as in the
real mod.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:13:33 -03:00
Vinícius MoreiraandClaude Opus 5.5 94740f1b3b Polytone: draw model particles
A custom particle type with a `model` is now drawn as that block model's
faces instead of being hidden: elements, faces, `#texture` references,
parents and element rotations are read the vanilla way, the particle's
size scales the model and its rotation mode turns it as it turns a
sprite quad. `none` keeps the model in the world's axes.

Checked against the real mod with an asymmetric test model: `look_at_xyz`
turns the model's +Z to the camera, `none` keeps +X east and +Z south.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:13:33 -03:00
Vinícius MoreiraandClaude Opus 5.5 182a3c2fe7 Polytone: g.rain is the mean of rain and thunder
A pack's `g.rain` read the rain level alone, so plain rain counted as a
thunderstorm. Measured on the real mod with a sky colormap whose row is
`g.rain`: clear sky reads the colormap's bottom row, rain its middle row
and a thunderstorm its top row, so `g.rain` is (rain + thunder) / 2.

With rain counted as a storm, a pack that picks its sky by `g.rain`
showed its storm sky and fog in plain rain; its rain sky now matches the
real mod's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:13:08 -03:00
Nikita Maximilian Grimm 7c77479175 Merge origin/main into entity-uuid-draws
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	tools/seed_sync_corpus.py
2026-11-09 02:09:33 +01:00
Horizon agent 7256e407de Merge remote-tracking branch 'origin/main' into m-12793b
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
2026-11-09 02:03:17 +01:00
Horizon agent ff58018a06 Merge remote-tracking branch 'origin/main' into m-13032
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-11-09 02:02:51 +01:00
Horizon agent a2e0595255 Merge remote-tracking branch 'origin/main' into m-13031
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-09 02:02:27 +01:00
Ludovic Marie f52d16d46a Merge pull request #12881 from NikitaMGrimm/hdr-settings
🤖 Agent: Black Level 0 now lifts nothing, and Peak steps by 500 above 2000 up to 10 000 nits; same content as reviewed, with main merged in by the author.
2026-11-09 02:02:17 +01:00
Horizon agent 0d9ebac9ce Merge remote-tracking branch 'origin/main' into m-13021
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 02:02:09 +01:00
Ludovic Marie 1c6d163156 Merge pull request #12693 from RLC248/fix/12523-canonical-entity-flags
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:01:06 +01:00
Ludovic Marie a41ac74237 Merge pull request #12818 from RLC248/fix/7504-french-creative-search
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:01:02 +01:00
Ludovic Marie c2535ba21f Merge pull request #12817 from RLC248/fix/7467-honey-slime-item-bounce
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:59 +01:00
Ludovic Marie 8caa631434 Merge pull request #13018 from DUzzL/fix/12985-piston-contact-box-float
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:54 +01:00
Ludovic Marie dde65ec694 Merge pull request #13009 from voduff/summon-yaw-parity
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:50 +01:00
Ludovic Marie a3a423c26f Merge pull request #13006 from vivescene/perf/selector-type-first
🤖 Agent: verify failed only on main's catalog_matches_hook_points bug (fixed by #13028) or the old test_nightly_set one. I merged main into the branch and requeued; nothing in the PR changed.
2026-11-09 02:00:47 +01:00
Horizon agent 851ef6ebbf Merge main into #12693 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:59:00 +01:00
Horizon agent 504f35eeb0 Merge main into #12818 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:57 +01:00
Horizon agent 01e27e93f0 Merge main into #12817 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:55 +01:00
Horizon agent 625844b654 Merge main into #13018 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:52 +01:00
Horizon agent d51ac0e772 Merge main into #13009 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:50 +01:00
Horizon agent 5852c51e76 Merge main into #13006 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:58:46 +01:00
Horizon agent e4f993d5b3 Merge remote-tracking branch 'origin/main' into m-13023
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-09 01:56:59 +01:00
Ludovic Marie 9cd7b899eb Merge pull request #13002 from NikitaMGrimm/layers/portable-check
🤖 Agent: the portable rule reads the same cargo metadata packages as the layer check; a listed crate that stops being an engine crate fails too, so a rename can't silently drop the rule. Merged main in (#12983 landed); 16 tests and the check pass on the result.
2026-11-09 01:56:25 +01:00
Ludovic Marie 1db479a6eb Merge pull request #12956 from Teiwazik/motion/moving-geometry
🤖 Agent: each projectile's run is closed with an id-0 run, so geometry drawn after it never inherits its motion; potions with no id yet keep the camera's.
2026-11-09 01:56:21 +01:00
Ludovic Marie 24321e2da7 Merge pull request #12987 from martipops/ui/shader-shell
🤖 Agent: over the ~500-line guideline (+1731/−1519), but it is one screen moving onto the shell plus the shell parts that screen needs, so it lands as is. The collapsed page list lays out from the drawn font, so the clicks match what is shown.
2026-11-09 01:56:18 +01:00
Ludovic Marie aad77e73e1 Merge pull request #12967 from k3yh/feat/mod-fluid-declarations
🤖 Agent: mod fluids join minecraft:fluid after vanilla's (source, then flowing) and the content identity, so a client without them is refused. Nothing places them yet.
2026-11-09 01:56:14 +01:00
Ludovic Marie 133a9e6cb8 Merge pull request #12999 from k3yh/feat/fluid-kind-table
🤖 Agent: FluidKind becomes a table index with no behaviour change; same_family and is_source compare source rows, and FluidMask stays u8 until mod fluids need more.
2026-11-09 01:56:10 +01:00
Ludovic Marie 9c364b492d Merge pull request #12966 from voduff/armor-glint-inventory-preview
🤖 Agent: the preview glint draws in the model's own depth pass (additive, LEQUAL, no depth write), so it never shows through other parts.
2026-11-09 01:56:05 +01:00
Ludovic Marie db63f2f6fe Merge pull request #12975 from iseaprojects/perf/sprite-mip-chains
🤖 Agent: the mip chains are built a chunk of layers at a time in parallel; the equality test pins the same bytes as the serial build.
2026-11-09 01:56:01 +01:00
Ludovic Marie 59e4e5bc0c Merge pull request #12968 from DUzzL/fix/12896-tnt-explosion-power-number
🤖 Agent: PrimedTnt reads explosion_power with getFloatOr, so any numeric tag counts.
2026-11-09 01:55:58 +01:00
Ludovic Marie 127d79c02c Merge pull request #12970 from Solenopsisbot/perf/pack-sky-scratch
🤖 Agent: the shader pack sky scratch is retained behind a Mutex, which is taken once a frame, so it can't contend.
2026-11-09 01:55:55 +01:00
Ludovic Marie 0346fe5f36 Merge pull request #12992 from vivescene/perf/falling-block-in-place
🤖 Agent: falling blocks now move in place in the list instead of being removed and pushed again, which keeps their tick order.
2026-11-09 01:55:51 +01:00
Ludovic Marie 4af07e93ac Merge pull request #12995 from TheMrZZ/audit/connected-player-statistics-save-all
🤖 Agent: removed tools/probes, the harness and the doc page: they run only against paper-pumpkin-parity, which lives outside this repo. The 16-line PlayerList.saveAll fix and its 3 Game tests stay (they pass with prepared resources).
2026-11-09 01:55:47 +01:00
Ludovic Marie 7aed5be227 Merge pull request #12994 from Ragbie/fix/projectile-on-ground
🤖 Agent: merged main (corpus groups: kept both); a rocket's on_ground = wanted.y < 0 && moved.y != wanted.y is setOnGroundWithMovement's verticalCollisionBelow, and setPos-movers keep the loaded flag.
2026-11-09 01:55:43 +01:00
Ludovic Marie 529d713ba0 Merge pull request #12976 from vivescene/perf/piston-push-area
🤖 Agent: merged main, keeping both piston_push fns (yours and main's block triggers); pushes only read bodies within 2 blocks of a moving block, which is safe because only the pushed body moves during the pushes.
2026-11-09 01:55:39 +01:00
Horizon agentandClaude Opus 5.5 dc8b7dd1c0 rustfmt the payload's tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:54:17 +01:00
Horizon agentandClaude Opus 5.5 603ff7030d Far terrain: keep a share of the kept-tile budget sized to the view
512 MB is about one 512-chunk view. A shorter far distance now keeps the same
share of it as its share of the tiles that view plans, so travelling with a
short far distance doesn't fill the whole budget with old places.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:53:32 +01:00
Horizon agent 3588d63f6b Merge remote-tracking branch 'origin/main' into layers-1/portable-check 2026-11-09 01:52:15 +01:00
Ludovic Marie 257e4f3f88 Merge pull request #12990 from horizon-rust/layers/check-addresses
🤖 Agent: check_addresses.py tokenizes Rust (strings, raw strings, comments masked) and strips #[cfg(test)] items, so test URLs and translation keys like mco.info don't count as hosts; allowed in tools/addresses.json can only shrink against --base.
2026-11-09 01:52:05 +01:00
Horizon agent 673bf94146 Merge main into #12995 2026-11-09 01:51:35 +01:00
Horizon agent 0cc69c80a3 Merge main into #12994 2026-11-09 01:51:33 +01:00
Horizon agent 7dbbd8bc9f Merge main into #12976 2026-11-09 01:51:29 +01:00
Horizon agentandClaude Opus 5.5 8a9f5a1288 horizon-system: the machine's probes leave horizon-telemetry (layer plan M2)
The OS, CPU, memory, drive, power and GPU probes move, unchanged, from
horizon-telemetry/src/system.rs to a new engine crate, horizon-system, with
a `Machine` description. The crash report and the renderer's GPU info read
it directly; the crash report's own copy of `command_output` goes (the
shared one opens no console window on Windows).

The Horizon layer depends on the platform only (docs/layers.md), so
telemetry reaches the probes through a first platform crate,
horizon-platform, that re-exports horizon-system as `system`.
`horizon_telemetry::system` stays as a path for the launcher and the
benchmark; `System::detect_in` copies a `Machine` into the payload.

No new layer exception: render -> telemetry stays until M3 moves the
renderer's telemetry module out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:49:58 +01:00
Ludovic Marie c57792a7c3 Merge pull request #12961 from rdbtCVS/refactor/game-world-update
🤖 Agent: pure move (only paths change), checked line by line.
2026-11-09 01:49:38 +01:00
Ludovic Marie 40fe249d09 Merge pull request #12950 from rdbtCVS/refactor/game-use-hand
🤖 Agent: pure move (only paths change), checked line by line.
2026-11-09 01:49:35 +01:00
Ludovic Marie 246b32c604 Merge pull request #12947 from rdbtCVS/refactor/game-launch-options
🤖 Agent: pure move (only paths change), checked line by line.
2026-11-09 01:49:31 +01:00
Ludovic Marie 0c65c8d47b Merge pull request #12952 from TheApple05/fix/12944-client-redstone-preview
🤖 Agent: the client placement preview stays client-side: TntBlock.prime and PistonBaseBlock.onPlace act only on the server; new harness client_redstone_preview.
2026-11-09 01:49:27 +01:00
Ludovic Marie fc442a81aa Merge pull request #12955 from DUzzL/fix/12895-attribute-base-number
🤖 Agent: attribute base reads any numeric tag (getDoubleOr).
2026-11-09 01:49:23 +01:00
Ludovic Marie df31a2fe7d Merge pull request #12954 from DUzzL/fix/12897-dragon-nbt-number-types
🤖 Agent: dragon Health/DragonPhase/DragonDeathTime read any numeric tag, as getFloatOr/getIntOr do.
2026-11-09 01:49:19 +01:00
Ludovic Marie 2756d39448 Merge pull request #12963 from Solenopsisbot/perf/flat-groups-inline
🤖 Agent: flat renderer groups inline in a SmallVec, no heap for the common few.
2026-11-09 01:49:15 +01:00
Ludovic Marie 33f2d64da8 Merge pull request #12960 from Solenopsisbot/perf/pack-post-attachments
🤖 Agent: SmallVec for pack post attachments; the explicit drop() ends the borrow before the pass.
2026-11-09 01:49:11 +01:00
Ludovic Marie 1d6a65b2b0 Merge pull request #12953 from Solenopsisbot/perf/hud-runs-scratch
🤖 Agent: runs_in gives the same runs as runs() plus the clip (checked), with the blend runs inline.
2026-11-09 01:49:07 +01:00
Ludovic Marie 23e9193dc3 Merge pull request #12949 from Solenopsisbot/perf/sound-tick-scratch
🤖 Agent: reuses the sound engine's per-tick scratch; no change to what plays.
2026-11-09 01:49:04 +01:00
Ludovic Marie 0b65f696e2 Merge pull request #12946 from Solenopsisbot/perf/streaming-scratch-reuse
🤖 Agent: keeps the streaming update's scratch vectors across frames instead of reallocating them each update.
2026-11-09 01:49:00 +01:00
Ludovic Marie 51a0732053 Merge pull request #12912 from Solenopsisbot/perf/share-entity-models
🤖 Agent: merged main after #12899: one load_with_visibility_models_and_defaults takes both the decoded entity-models document and the item defaults, so neither is read twice.
2026-11-09 01:48:57 +01:00
Ludovic Marie b33b610d56 Merge pull request #12884 from Detimes/hooks-chat
🤖 Agent: merged main (native_hooks.rs / native_server.rs: kept both sets of functions); chat hooks come from fragments stamped at build.
2026-11-09 01:48:53 +01:00
Nikita Maximilian Grimm 00ee749a58 Merge remote-tracking branch 'origin/main' into layers/portable-check 2026-11-09 01:48:49 +01:00
Ludovic Marie 08eb26ad9e Merge pull request #12852 from ilguerro/fix/landing-vibrations
🤖 Agent: merged main (vibration_tests.rs: kept both test tails); trampled farmland and broken turtle eggs now emit their game events where vanilla does.
2026-11-09 01:48:49 +01:00
Ludovic Marie 1cd834a3d8 Merge pull request #12885 from Detimes/hooks-fake-players
🤖 Agent: fake-player part only now (the per-tick box cache went to #13051); guests still build the others' current boxes each turn, as main does. Benchmark posted above.
2026-11-09 01:48:45 +01:00
Horizon agentandClaude Opus 5.5 b7b5cda0ad docs/layers.md: linked Horizon crates, portable crates, HDR in M6
From NikitaMGrimm's review on #13012:
- The Horizon layer's in-game parts are crates linked into the official
  game build in CI through platform hooks, never compiled by players. The
  official executable is a thin crate in the Horizon repository; this
  repository keeps a plain one.
- The portable crates (core, world, protocol, items, command, client-core,
  assets) take no desktop library; ports beyond the desktop build on them.
  #12997's text folded in; #13002 adds the check. Step P: gameplay into
  them over time.
- HDR's part of M6 is planned with NikitaMGrimm after HDR step 5.
- Credits name NikitaMGrimm (DuckyBertDuck on Discord).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:45:36 +01:00
Horizon agent 53776cf792 Merge remote-tracking branch 'origin/main' into layers/plan 2026-11-09 01:45:30 +01:00
Horizon agent 2bb1ef83e4 Merge main into #12885 (main's catalog_matches_hook_points fix, #13028) 2026-11-09 01:44:46 +01:00
Ludovic Marie 5abd285cc1 Merge pull request #12983 from horizon-rust/layers/check-deps
🤖 Agent: tools/check_layers.py only reads cargo metadata (--no-deps --offline), so nothing builds and nothing players run changes; with --base it refuses any new exception in tools/layers.json unless the crate itself is new (a split-out crate keeps its parent's).
2026-11-09 01:44:39 +01:00
Horizon agent 62707dae44 Merge remote-tracking branch 'origin/main' into layers/check-deps 2026-11-09 01:43:43 +01:00
Horizon agent f4aeb6aa14 Merge main into #12912 2026-11-09 01:43:39 +01:00
Horizon agent 040629ea7e Merge main into #12884 2026-11-09 01:43:36 +01:00
Horizon agent 6ba328d7ca Merge main into #12852 2026-11-09 01:43:11 +01:00
Horizon agent 812188bcc4 Merge remote-tracking branch 'origin/main' into m-13035
# Conflicts:
#	crates/horizon-client/src/game/levels.rs
2026-11-09 01:40:31 +01:00
Horizon agentandClaude Opus 5.5 f66c07b0c3 Drop the item-cooldown fixture that needs an outside checkout
tools/probes/item_on_block_cooldown ran only against paper-pumpkin-parity, a
framework outside this repo, so nobody here (or CI) can run it. The fix and its
Game tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:40:14 +01:00
Nikita Maximilian Grimm f4cb8d2fba Merge remote-tracking branch 'origin/main' into hdr-scaled-screenshots 2026-11-09 01:38:01 +01:00
Nikita Maximilian Grimm c2b8c12bb7 Merge remote-tracking branch 'origin/main' into hdr-settings 2026-11-09 01:37:58 +01:00
Ludovic Marie c76286a74a Merge pull request #13028 from rdbtCVS/fix/push-voice-helper-section
🤖 Agent: right fix for main's catalog_matches_hook_points failure: push_voice is a helper in its fragment, and the test reads every native_ fn in the hook-points block as a hook; the moved function is byte-identical. My miss on #12869 (I ran the stamp test, not this one).
2026-11-09 01:36:50 +01:00
Evrim CicekandClaude Opus 5.5 f7063d3621 A boat floats at vanilla's height: its water checks sum in float
AbstractBoat.checkInWater and isUnderwater add a block's y and its water
height in float, and floatBoat lands a falling boat at the float
getWaterLevelAbove() - getBbHeight(), with waterLevel = getY(1.0). Horizon
summed in double, so high up a boat floated about 3e-6 off vanilla's height.

Fixes #13022.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:30:08 +03:00
Horizon agent d7bbe2f5e3 Merge remote-tracking branch 'origin/main' into layers/check-deps 2026-11-09 01:25:53 +01:00
voduffandClaude Opus 5.5 6feba56d0e Fishing bobbers run the struck block's onProjectileHit (#12936)
FishingHook.onHitBlock calls Projectile.onHitBlock before it rebounds,
so a bobber powers a target block (target_hit, the criterion), rings a
bell struck on its body (bell_ring), chimes amethyst and tilts a big
dripleaf. Horizon's bobber only posted PROJECTILE_LAND.

The bell and target helpers take a ProjectileHitter (owner, guest,
arrow, type, position) that thrown projectiles and the bobber both
build. New harness test_fishing_hook_blocks_parity.py casts onto
target blocks on vanilla's and Horizon's dedicated servers.

Fixes #12936

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:21:31 +00:00
DUzzL 01ecb800e2 Merge remote-tracking branch 'upstream/main' into fix/12908-tnt-fuse-number
# Conflicts:
#	crates/horizon-client/src/game/commands/data.rs
2026-11-09 01:15:19 +01:00
Florian ErnstandClaude Opus 5.5 78151f0e02 A single lead's pull turns and adds its push as vanilla does
Leashable.computeElasticInteraction turns every attachment point with
Vec3.yRot(float), Mth's float cosine and sine of a float angle, and
checkElasticInteractions adds the whole push in one addDeltaMovement:
v + (force * axis + relative * 0.11). Horizon turned a single lead's
points with exact trigonometry (only the quad lead used Vec3.yRot) and
added the push as (v + force * axis) + relative * 0.11, so a pulled
mob's Motion and yaw drifted once it had turned.

Both ends of a single lead now turn with the same Vec3.yRot helper as a
quad lead, and the push is added as one sum. The sum is the line the
quad lead shares, so a quad lead's Motion now follows vanilla's formula
too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:14:24 +01:00
bulkandClaude Opus 5.5 f858ee752f Native hooks: keep native_push_voice with the helpers, not the hook points
#12869 made push_voice a helper (tools/native-hook-fragments/push_voice.json lists it under
"helpers", with no hook) but left native_push_voice in the hook points' impl block, which
catalog_matches_hook_points reads as the list of hook points: the test failed on main with
push_voice as the only difference. The method moves, unchanged, to the helpers section next to
native_add_toast; its path (game::native_hooks::Game::native_push_voice) is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:13:27 +00:00
DUzzL e47abbc7fe Read any numeric fuse tag when /data loads a primed TNT 2026-11-09 01:13:13 +01:00
Horizon agent 90796bc21b Merge remote-tracking branch 'origin/main' into m-12793
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/projectiles.rs
2026-11-09 01:12:27 +01:00
Florian ERNSTandCodex 773e7850b5 Start the item cooldown after successful block placement
Reuse the existing component cooldown helper after the final successful
placement and hand replacement, preserving block-first interaction order.
Add real Guest/Game packet regressions and a scoped connected-client probe.

Co-Authored-By: Codex <[email protected]>
2026-11-09 01:10:44 +01:00
Horizon agentandClaude Opus 5.5 1df9099c8f Drop the connected-statistics harness that needs an outside checkout
tools/test_connected_statistics_save_parity.py and its probe ran only against
paper-pumpkin-parity, a framework outside this repo, so nobody here (or CI) can
run them. The fix and its Game tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:09:07 +01:00
Evrim CicekandClaude Opus 5.5 338a8c36f8 A falling boat or minecart resets its fall through water and vines a fast move passes
Entity.move casts the move (up to 8 blocks) through fall-damage-resetting
blocks and water when the entity has a fall and moves a block or more, and
resets the fall on a hit before the move's own drop counts. Boats, derailed
minecarts and a LAN guest's boat move through their own collision and
skipped it. Water is clipped through the shared fluid shape cache, as the
player's ray does.

Fixes #13005.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:08:42 +03:00
Horizon agent 266bdda26e Merge remote-tracking branch 'origin/main' into m-12994
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-09 01:08:21 +01:00
Horizon agent f5515b409c Merge remote-tracking branch 'origin/main' into m-12976
# Conflicts:
#	crates/horizon-client/src/game/piston_push.rs
2026-11-09 01:08:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ee0a6c2093 HDR float frame: scaled screenshots keep HDR
With HDR on the float frame, an F2 taken at a Screenshot Quality above 1
or a Resolution Scale above 100% drew its larger frame without HDR: no
HDR lighting, no HUD layer and no tone curve. The saved PNG was the plain
SDR picture, much darker than the same view's unscaled F2, and it had no
-hdr.png.

The scaled frame now keeps HDR on the layered float frame and draws in
HDR's own targets at the larger size, without presenting. Its PNG is the
unscaled F2's picture at that size (the world mapped to SDR as
Hdr::sdr_screenshot maps it, the HUD over it by coverage), with its
-hdr.png beside it. Then the large targets are let go, so they don't
stay in video memory, and the window's next frame makes its own again.

HDR off, frames that aren't layered, unscaled screenshots and the
window's own frames are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:02:29 +01:00
Ludovic Marie 976346aff5 Merge pull request #13014 from horizon-rust/docs/contributing-policies-2026-11-09
🤖 Agent: the large-PR process says the spec check is run by maintainers' agents; nothing enforces it in CI yet, so reviewers should ask for the 'Spec: #' link on PRs over ~500 lines.
2026-11-09 01:00:55 +01:00
Ludovic Marie 141842b509 Merge pull request #13010 from horizon-rust/docs/readme-no-parity-percent
🤖 Agent: tools/parity_gaps.py anchored its badge fallback on the vanilla-parity badge this removes; it now inserts at the end of the badge row, and test_parity_gaps checks that order.
2026-11-09 01:00:51 +01:00
Florian ErnstandClaude Opus 5.5 e5f333e885 Test: a fence leash pulls a pig as vanilla does
Checkpoint before the fix: the hosted Game test that binds the live knot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:58:48 +01:00
Horizon agentandClaude Opus 5.5 7da0bdf46d docs/layers.md: the file-size check is #13015
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:58:02 +01:00
Horizon agentandClaude Opus 5.5 63e8ff40fe CI and pr_ready: a Rust file over 3000 lines may not grow
From the 2026-11-08 contributors' stage: game.rs (~21.7k lines) and
gfx.rs (~10k) slow incremental builds. tools/check_file_size.py
compares each added, changed or renamed .rs file with the merge base:
over 3000 lines it may shrink or stay, never grow; a new file over the
limit fails. No list of files; third_party/ and @generated files are
not checked. Runs on pull requests (with their base) and in pr_ready.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:57:19 +01:00
Horizon agentandClaude Opus 5.5 e3242b385b CONTRIBUTING: large PRs, 26.3 only, vendor libraries, JVM bridge
Writes down the project's decisions of 2026-11-09: pull requests over
~500 lines start from an approved spec issue and an agent check against
it; parity work stays on 26.3 (redstone first), newer servers via Via;
AMD FSR may be vendored but NVIDIA-licensed libraries only as an
optional native mod that downloads them; the JVM bridge is a separate
optional download, never on by default. The PR template points large
PRs at the spec issue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:54:03 +01:00
Horizon agentandClaude Opus 5.5 991a9b5451 docs/layers.md: the target (Horizon layer in its own repo) and the step plan
Ludovic, 2026-11-09: this repository keeps the engine and the platform,
a complete vanilla-like game; the Horizon layer (its extras, cosmetics,
shop, telemetry, sponsors, the launcher) moves to its own repository
after the split and ships with every official launcher download. So
Horizon uses only the platform's public API. Adds the seam, the
official download paths, the stage's asks (file-size rule, one address
list, sponsors in Horizon, a platform exposing what vanilla uses,
extras as native mods), the 47 exceptions and the M1-M6 plan with its
review gate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:54:03 +01:00
Horizon agent f3418e1525 Merge branch 'layers/check-addresses' into layers/addresses-home 2026-11-09 00:51:14 +01:00
Horizon agent c4c0b54fad Merge branch 'layers/check-deps' into layers/check-addresses 2026-11-09 00:51:13 +01:00
Horizon agentandClaude Opus 5.5 a33d57b36e check_layers: each layer's allowed layers; Horizon uses the platform only
Ludovic, 2026-11-09: the Horizon layer moves to its own repository
after the split, so it builds against the platform's public API and
never the engine. tools/layers.json now says which layers each layer
may depend on (engine: engine; platform: engine, platform; horizon:
platform, horizon). The launcher's and horizon-server's 7 engine
dependencies join the exceptions (47).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:50:40 +01:00
Horizon agentandClaude Opus 5.5 b4e940a4ec README: drop the 100% parity badge and heading
The README now says what the harnesses compare instead of a parity
percentage: the "vanilla parity 100%" badge and the "Java parity: 100%"
heading go, the harness table and the player-visible gaps block stay.
tools/parity_gaps.py no longer anchors its badge on the parity badge,
and the README tests check the table without a percentage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:50:20 +01:00
DangerousbrosandClaude Opus 5.5 02cc303caa Far terrain: the seam is sampled fresh on a return, and fine tiles wait for the near terrain
Kept tiles at the seam with the near chunks (`Plan::stands_in`) are dropped
on a return and sampled first, as before kept tiles: brought back, they drew
grey ground and caves there until the near terrain loaded. The fine levels
(cells a chunk wide or less) wait while the near terrain loads and the seam
is open, ten seconds at most: their thousands of meshes held up the near
chunks' and the seam's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:49:54 +00:00
voduffandClaude Opus 5.5 9e053b5fab Spawn yaw: /summon without NBT faces 0, spawn eggs wrap their draw (#12973)
- SummonCommand.createEntity loads a mob summoned without NBT from
  {id:..}: yaw 0 and no level-random draw (Horizon drew one, which also
  shifted every later level roll).
- EntityType.create snaps a spawn egg's, a dispensed egg's and a mob
  bucket's mob to Mth.wrapDegrees(level.random.nextFloat() * 360).
- SpawnEggItem.spawnOffspringFromSpawnEgg snaps the baby to yaw 0.

spawn_mob_internal takes a SpawnYaw (Level, Created, Fixed); other
callers keep the old draw. Seed sync proof gains three cases (18/18;
main's code: 16/18).

Fixes #12973

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:47:26 +00:00
Horizon agent 6865327231 Merge remote-tracking branch 'origin/main' into s-12884
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_server.rs
2026-11-09 00:43:38 +01:00
Horizon agent 39f04d6797 Merge remote-tracking branch 'origin/main' into s-12852
# Conflicts:
#	crates/horizon-client/src/game/vibration_tests.rs
2026-11-09 00:42:16 +01:00
Horizon agent 20cf432402 Merge remote-tracking branch 'origin/main' into s-12912
# Conflicts:
#	crates/horizon-client-render/src/block_entity_render.rs
#	crates/horizon-client/src/game.rs
2026-11-09 00:41:35 +01:00
Ludovic Marie 3a00fb7f85 Merge pull request #12891 from Detimes/hooks-bundles
🤖 Agent: the launcher lets a bundle's package depend on the bundle's own crates (last part of the #12409 split; no game code).
2026-11-09 00:41:29 +01:00
Ludovic Marie d44bca934e Merge pull request #12857 from NikitaMGrimm/fix-dispensed-egg-persistence
🤖 Agent: a mob from a dispensed spawn egg is not persistent, except the elder guardian and copper golem, which vanilla makes persistent.
2026-11-09 00:41:23 +01:00
Ludovic Marie ce5ba9d529 Merge pull request #12847 from Mik00000/perf/structure-locate-vanilla-order
🤖 Agent: findNearestMapStructure searches ring by ring across every random-spread set, takes each set's first hit per ring and stops at the first ring with a hit.
2026-11-09 00:41:17 +01:00
Ludovic Marie 0497e8bc74 Merge pull request #12840 from baytyone/parallel-mob-ticks-groundwork
🤖 Agent: the hub's per-player sweep runs through par_chunks_mut from 16 players up and stays serial below, where threads cost more than they save.
2026-11-09 00:41:14 +01:00
Ludovic Marie a5d17ed836 Merge pull request #12837 from NikitaMGrimm/vanilla-tick-script-pointer
🤖 Agent: the vanilla tick-script client ignores the person's keyboard and pointer while a script drives it.
2026-11-09 00:41:10 +01:00
Ludovic Marie 857c741402 Merge pull request #12836 from NikitaMGrimm/scripted-run-input-lock
🤖 Agent: scripted runs ignore the person's input, --control windows open unfocused, and the launcher passes --control-socket only from its MCP play.
2026-11-09 00:41:06 +01:00
Ludovic Marie d3017b792e Merge pull request #12890 from Detimes/hooks-inventories
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:41:02 +01:00
valuttaandClaude Opus 5.5 465a6752f4 Selectors test type= before reading tags
A datapack's @e[type=marker,tag=...] selectors got quadratically slower
with every other entity in the level: select_in_level ran the tag/score/team
filter on every candidate before the type test, and tags_of finds each
entity by a linear search (about 40% of the server thread with 8,000
falling blocks around).

EntitySelector tests the type first. With type= and no scores= or team=
(whose holders may be made as they are read), the rest of the extra filter
is a pure test of each candidate, so the candidates of another type now
leave before it. The type test moves into selector_types_match, used for
both passes.

Fixes #13003

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:40:49 +01:00
DetimesandClaude Opus 5.5 0e8f5dbb8a Merge upstream/main; keep the shared box list and send_shared out
Merges main (keeping its NativeServerPlayer fields next to `fake`) and drops
the two parts that reach every server, as asked in review: the players' boxes
built once per tick (BUILDING_CACHE) and `send_shared` for broadcasts. The
first moves to its own PR with a fix and tests; the second only saved a copy
for fake players and is dropped. The fake-player tick benchmark stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:40:26 +00:00
Horizon agent fcb88320e1 Merge main into #12891 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:41 +01:00
Horizon agent 3808ecf716 Merge main into #12857 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:37 +01:00
Horizon agent 0fcdcc425a Merge main into #12847 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:34 +01:00
Horizon agent 1700772be1 Merge main into #12840 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:31 +01:00
Horizon agent 167e772c20 Merge main into #12837 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:28 +01:00
Horizon agent 298ca95813 Merge main into #12836 (main's test_nightly_set fix, #12783) 2026-11-09 00:37:02 +01:00
Ludovic Marie 9803a016c4 Merge pull request #12869 from redstonerthebest/mod-api/push-voice
🤖 Agent: push_voice keeps one FIFO per (mod, source), so a speaker's blocks play in order while speakers play together; the helper is a stamped fragment.
2026-11-09 00:35:06 +01:00
DUzzL 6ea8967d2f Use the guest's authoritative dimensions for horizontal piston contact 2026-11-09 00:34:33 +01:00
Florian ERNSTandCodex 121c6b27d5 Save connected-player statistics with world metadata
Write actual per-connection statistics after the existing player body
queue, retaining the current UUID mapping and JSON writer. Cover missing
and stale files through the real Game save command and connected input
fixture, with separate disconnect positives.

Co-Authored-By: Codex <[email protected]>
2026-11-09 00:29:01 +01:00
Ludovic Marie 7d0f8b956b Merge pull request #12979 from horizon-rust/layers/design-doc
🤖 Agent: docs only; the order engine < platform < Horizon follows Ludovic's rule (engine never depends on platform), which makes today's exceptions 40, all from the four mixed engine crates.
2026-11-09 00:27:55 +01:00
Ludovic Marie 6de492a6fc Merge pull request #12913 from MrCloudy2/fix/projectile-bubble-column
🤖 Agent: every applyEffectsFromBlocks projectile (throwables, hurting projectiles, shulker bullets) is pushed by the bubble columns it moved through, unclamped, on the server as on the client.
2026-11-09 00:27:51 +01:00
Evrim CicekandClaude Opus 5.5 67e5c2d205 Projectiles, rockets and falling blocks save and load OnGround
Entity.load reads OnGround and saveWithoutId writes the live flag. Arrows,
thrown items, fireballs, spit, bullets and tridents move by setPos and keep
the loaded flag; a firework rocket and a falling block take theirs from
Entity.move, and a piston push sets it on all of them. Llama spit and
shulker bullets also keep their saved or summoned UUID.

Fixes #12898.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:27:50 +03:00
Ludovic Marie 669e6b33d2 Merge pull request #12864 from J1zeee/fix/12752-ender-chest-end-portal-block-entities
🤖 Agent: an ender chest and an end portal get their block entities, and the ender chest's holds none of the player's items, so none are saved to the chunk (EnderChestBlockEntity saves nothing).
2026-11-09 00:27:47 +01:00
Horizon agentandClaude Opus 5.5 e9846abaf3 Mod audio: one queue per source, played in order; a native-hook fragment
push_voice mixed every queued chunk at once, so a speaker's second block
played over its first, and finished chunks were popped from the front of
one shared queue whatever their source. Each (mod, source) pair now has
its own queue: a source plays its head chunk, hands the rest of a buffer
to the next one, and sources play at the same time. Positioned audio
fades linearly to silence at 48 blocks (distance_gain(None, _) was always
1). A source holds two seconds at most, 64 sources play at once, and
nothing is queued without an output device or outside the window's game.

The generated host files are gone on main: push_voice is a fragment
(tools/native-hook-fragments/push_voice.json), stamped at build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:27:21 +01:00
Horizon agent e263c6a6ef Merge remote-tracking branch 'origin/main' into n-12869
# Conflicts:
#	crates/horizon-client-render/src/audio.rs
#	crates/horizon-client/src/game/mod_api_host_generated.rs
#	crates/horizon-client/src/mod_api/generated_host.rs
#	native-hooks.json
2026-11-09 00:26:35 +01:00
Ludovic Marie 3b695e3b18 Merge pull request #12942 from vnsmoreira/fix/spectator-water-vision
🤖 Agent: a spectator's underwater vision ramps 10 times faster, as LocalPlayer.getWaterVision does for spectators.
2026-11-09 00:24:33 +01:00
Ludovic Marie 78482b23de Merge pull request #12933 from Solenopsisbot/perf/recipe-source-reuse
🤖 Agent: filtered recipe sources are shared instead of rebuilt per caller; the recipe lists are unchanged.
2026-11-09 00:24:29 +01:00
Ludovic Marie 672e276940 Merge pull request #12927 from vnsmoreira/fix/end-clock
🤖 Agent: the End's clock is saved and ticked, so its time no longer resets on reload.
2026-11-09 00:24:25 +01:00
Ludovic Marie 896d960bd7 Merge pull request #12925 from senjinthedragon/audio-reopen-dead-stream
🤖 Agent: a dead audio output stream is reopened instead of leaving the game silent until restart.
2026-11-09 00:24:21 +01:00
Horizon agentandClaude Opus 5.5 fc912ff3a1 horizon-addresses: the Horizon layer's one home for built-in addresses
New crate (Horizon layer, no dependencies) holding the official server
and its earlier and status addresses, the cosmetics and telemetry
services, the STUN servers and the Friends help page.
horizon-telemetry, horizon-cosmetics and horizon-friends keep their
public constants, now read from it. tools/addresses.json loses 7
entries (82 -> 75). Same values, so players see no change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:24:19 +01:00
Ludovic Marie 2a1437a1c0 Merge pull request #12921 from Solenopsisbot/perf/hud-stack-attachments
🤖 Agent: HUD render-pass attachments stay in an inline SmallVec instead of a heap Vec per pass; the passes are unchanged.
2026-11-09 00:24:18 +01:00
Ludovic Marie 1c385ab9b0 Merge pull request #12919 from iseaprojects/perf/save-light-layers
🤖 Agent: item components decode in one pass over the compound instead of one lookup per known component.
2026-11-09 00:24:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 06dc58c8a4 Layer check: the portable crates stay free of desktop libraries
docs/layers.md#engine: horizon-core, -world, -protocol, -items,
-command, -client-core and -assets hold vanilla's rules with no desktop
in them, so a port can build on them. The check now fails when one takes
a crate of the repository outside that list, or winit, wgpu, cpal,
gilrs, arboard or ureq (normal and build dependencies; tests may use
anything). All seven pass today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:24:12 +01:00
Ludovic Marie 85878fa594 Merge pull request #12914 from Solenopsisbot/perf/hud-slot-memory
🤖 Agent: empty HUD slots build no sprite sources.
2026-11-09 00:24:10 +01:00
Ludovic Marie f35632c2a9 Merge pull request #12905 from senjinthedragon/pack-boat-water-mask
🤖 Agent: shader packs get the boat's water patch as a depth-only mask; only packs that read it change.
2026-11-09 00:24:07 +01:00
Ludovic Marie de4ed2745c Merge pull request #12899 from Solenopsisbot/perf/shared-item-defaults
🤖 Agent: item default components are shared instead of cloned per stack.
2026-11-09 00:24:03 +01:00
valuttaandClaude Opus 5.5 bab2e82183 Falling blocks move in place in the list
A few thousand falling sand blocks took the server tick to 600-800 ms:
tick_one_falling took each block out of self.falling and put it back, every
tick, and each of the two shifted every block after it (55% of the server
thread in memmove). The block was only taken out so fall_step could borrow
it beside self; nothing the move reads looks at the falling blocks.

It now moves a copy and writes it back where it is. One that lands, drops
or goes still leaves the list before its block is placed or dropped, and
on an error the list keeps it as it was.

Fixes 12991

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:23:47 +01:00
Horizon agent ef2ce44b59 Merge branch 'layers/check-deps' into layers/check-addresses 2026-11-09 00:22:33 +01:00
Horizon agent f4938d63aa Merge branch 'layers/design-doc' into layers/check-deps 2026-11-09 00:22:06 +01:00
Horizon agentandClaude Opus 5.5 0b5e417e4c check_layers: a new dependency target excuses no new exception
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:22:05 +01:00
Horizon agentandClaude Opus 5.5 7c2db89229 docs/layers.md: only a newly split crate may carry exceptions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:21:36 +01:00
Horizon agentandClaude Opus 5.5 fe04f52326 CI and pr_ready: built-in outside addresses stay in one home
tools/check_addresses.py reads the string literals of shipped Rust
(tests and #[cfg(test)] items skipped) and finds every host: a URL's
host or a whole host-name string; placeholders and local IPs skipped.
tools/addresses.json: the Horizon layer's home, vanilla's endpoints,
strings that are not addresses (with reasons), and today's 82
addresses outside the home, which may move with their code but never
gain a host or grow (--base). Messages point at docs/layers.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:20:35 +01:00
Ludovic Marie 7a56b3516d Merge pull request #12882 from fbikat/fix-12845-boolean-nbt
🤖 Agent: boolean NBT fields read any numeric tag, as getBooleanOr does, not only a byte.
2026-11-09 00:17:39 +01:00
Ludovic Marie bd3c2d7a14 Merge pull request #12943 from vermingov/content-mod-jukebox-songs
🤖 Agent: a content mod's jukebox songs load under the mod's own namespace, so they cannot replace vanilla songs.
2026-11-09 00:16:03 +01:00
Ludovic Marie 230ff171f0 Merge pull request #12889 from Detimes/hooks-player-state
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:16:00 +01:00
Ludovic Marie 9a12a64459 Merge pull request #12888 from Detimes/hooks-entities
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:15:56 +01:00
Ludovic Marie fa727686e3 Merge pull request #12886 from Detimes/hooks-block-events
🤖 Agent: part of the #12409 split; its fragment stamps at build with the others (stamp test passes on the union).
2026-11-09 00:15:53 +01:00
Ludovic Marie 1e981c99c6 Merge pull request #12883 from Detimes/hooks-commands
🤖 Agent: first part of the #12409 split; its hook fragments are stamped at build and tests.test_stamp_native_hooks passes on the union.
2026-11-09 00:15:49 +01:00
Horizon agentandClaude Opus 5.5 790d1e3c27 CI and pr_ready: crates depend only on their own layer or a lower one
tools/check_layers.py reads the four workspaces' cargo metadata (no
deps, offline) and tools/layers.json (engine < platform < horizon, plus
tool). It fails on a crate with no layer, an upward normal/build
dependency 'allowed' doesn't list, an 'allowed' entry a branch adds
between known crates (--base), or shipped code on a dev tool; each
message names the docs/layers.md section. Stale exceptions are notes.
Starts with today's 40. The PR template gets a Layer section with the
reviewer checklist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:14:59 +01:00
Nikita Maximilian Grimm e3cf8bb456 Merge remote-tracking branch 'origin/main' into hdr-settings
# Conflicts:
#	docs/graphics.md
2026-11-09 00:14:18 +01:00
Horizon agentandClaude Opus 5.5 08819d4636 docs/layers.md: engine < platform < Horizon, with each layer's rules
Per Ludovic's direction the engine never depends on the platform or
Horizon. Each layer says what belongs, real examples, what never
belongs and what it may depend on; a decision table says where a new
feature goes; a reviewer checklist. Linked from CONTRIBUTING.md and
CLAUDE.md. 40 upward dependencies today.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:12:31 +01:00
Ludovic Marie e5e6e26d91 Merge pull request #12984 from horizon-rust/docs/team-remove-qwerner
🤖 Agent: removes Qwerner88880's trusted-reviewer line after the Team vote; the pinned login set in test_nightly_set is updated in the same commit, so verify stays green.
2026-11-09 00:11:57 +01:00
Ludovic Marie 243ab4e220 Merge pull request #12929 from NikitaMGrimm/hdr-p0
🤖 Agent: shader API 0.2 packs get the HDR output and the layered HUD; with the env switch off, the frame is the same as main's.
2026-11-09 00:11:04 +01:00
Ludovic Marie 3799edbc34 Merge pull request #12928 from NikitaMGrimm/hud-8bit
🤖 Agent: the HDR HUD layer draws into an 8-bit target, so each HUD draw clamps and blends as vanilla's does; only with the HDR env switch on.
2026-11-09 00:11:01 +01:00
Ludovic Marie bab8bf1166 Merge pull request #12923 from TheMrZZ/fix/h169-mob-glide-travel
🤖 Agent: a fall-flying mob uses the glide travel, as LivingEntity.travelFallFlying does, not the ground travel.
2026-11-09 00:10:57 +01:00
Ludovic Marie f0d91bb791 Merge pull request #12903 from vivescene/perf/moving-piston-sync
🤖 Agent: the client's moving pistons are synced once per frame's batch of packets instead of once per packet.
2026-11-09 00:10:53 +01:00
Ludovic Marie 05e56173c2 Merge pull request #12892 from vivescene/fix/panorama-block-outline
🤖 Agent: the block outline is not drawn while the title screen's panorama renders.
2026-11-09 00:10:49 +01:00
Horizon agentandClaude Opus 5.5 25c5fd1db7 Remove Qwerner88880 from the trusted reviewers and the modding lead
The Horizon Team voted him out of the Team (2026-11-09).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:09:43 +01:00
k3yhandClaude Opus 5.5 4243b253d2 FluidKind indexes a fluid table; fluid numbers come from it
Step 2 of #6301 (horizon-world, and what the client needs to build): FluidKind
is a fluid's index in a table, its minecraft:fluid network id, with vanilla's
five first and the enum's names kept as constants. Each row has the fluid's
template (water or lava behaviour), its family's source and flowing forms,
and NeoForge's tick delay, drop-off, slope distance and source conversion,
which vanilla's leave to their template's rules (lava's ultrawarm ones
included). FlowingFluid's code reads them from the table; matches on the
five fluids match on the template. No behaviour change: fluid masks stay
8 bits (they widen with the behaviour cache's version when mod fluids join).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:09:35 -05:00
Ludovic Marie 577ea049d3 Merge pull request #12880 from d3xc0/android-touch-2
🤖 Agent: the touch aim helpers come from Starling under MIT and are credited; nothing calls them yet, so players see no change.
2026-11-09 00:08:18 +01:00
Ludovic Marie c1b0538540 Merge pull request #12878 from Solenopsisbot/perf/region-nbt-scratch
🤖 Agent: region NBT decoding reuses a thread-local scratch instead of allocating one per chunk.
2026-11-09 00:08:14 +01:00
Ludovic Marie 385393ec3c Merge pull request #12867 from MrCloudy2/fix/wind-charge-box
🤖 Agent: the wind charge box is 0.15F lower and its pick box is bounds.inflate, pinned by the new wind_charge_box harness.
2026-11-09 00:08:11 +01:00
Ludovic Marie 85ce314e76 Merge pull request #12865 from Solenopsisbot/perf/remote-guardian-scratch
🤖 Agent: the client's guardian eye look reuses its scratch instead of collecting a new Vec each frame.
2026-11-09 00:08:07 +01:00
Ludovic Marie bbe70b1ca2 Merge pull request #12861 from TheMrZZ/fix/invulnerable-time-save
🤖 Agent: invulnerable_time is kept apart from the hurt cooldown, so saving and loading it no longer resets the other; the new harness pins it.
2026-11-09 00:08:03 +01:00
Ludovic Marie ba81d33cb7 Merge pull request #12856 from Solenopsisbot/perf/projectile-wind-scratch
🤖 Agent: the pickable-projectile scan reuses one scratch vector instead of allocating per tick.
2026-11-09 00:07:59 +01:00
Ludovic Marie fb123d2479 Merge pull request #12854 from Solenopsisbot/perf/rest-load-fanout
🤖 Agent: the world's remaining resource loads join the existing scoped batch, so they no longer run one after another.
2026-11-09 00:07:56 +01:00
Ludovic Marie fa5554339e Merge pull request #12853 from NikitaMGrimm/parity/fill-block-nbt
🤖 Agent: /fill loads the given block NBT into each placed block entity and counts the blocks whose tags changed.
2026-11-09 00:07:52 +01:00
Ludovic Marie 0ebfe7d116 Merge pull request #12851 from ilguerro/fix/cauldron-vibrations
🤖 Agent: a cauldron emits fluid_pickup / fluid_place game events after its sound, as vanilla's cauldron interactions do.
2026-11-09 00:07:49 +01:00
Ludovic Marie bff36a3b38 Merge pull request #12843 from TheMrZZ/fix/last-hurt-by-mob-save
🤖 Agent: last_hurt_by_mob keys are written at save time; the new last_hurt_by_mob harness pins them (reading them back is not part of this PR).
2026-11-09 00:07:45 +01:00
Ludovic Marie 265b1a3cb7 Merge pull request #12860 from DenisHumen/fix/offhand-server-hop
🤖 Agent: joining a server or replaying clears the off hand too, not only the main inventory.
2026-11-09 00:07:42 +01:00
Ludovic Marie a2b3f4fe86 Merge pull request #12922 from MrCloudy2/fix/piston-push-triggers
🤖 Agent: a piston pushing an entity onto a pressure plate presses it; main's projectile block effects are kept alongside.
2026-11-09 00:07:38 +01:00
Ludovic Marie 03093772e4 Merge pull request #12846 from NikitaMGrimm/summoned-eye-of-ender
🤖 Agent: a summoned or loaded eye of ender flies, shatters after 80 ticks and saves its Item, Motion and Fire.
2026-11-09 00:07:35 +01:00
Ludovic Marie 62495116b5 Merge pull request #12900 from TheMrZZ/fix/h077-blending-heights-save
🤖 Agent: an old chunk keeps the blending heights computed from its neighbours in its save, as vanilla's BlendingData does, instead of recomputing them on load.
2026-11-09 00:07:31 +01:00
Ludovic Marie 549c7a2bc8 Merge pull request #12893 from TheMrZZ/fix/allay-pickup-cooldown
🤖 Agent: the allay's item_pickup_cooldown_ticks memory is saved and read back, so a reloaded allay does not pick its thrown item up again at once.
2026-11-09 00:07:27 +01:00
Horizon agentandClaude Opus 5.5 9b17b20107 docs: the three layers (engine, platform, Horizon) and every built-in address
Maps each crate and the mixed crates' modules to a layer, lists today's
11 upward dependencies and every built-in outside address with its use.
From duckybertduck's 3-layer proposal and arismoko's mod API boundary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:03:26 +01:00
Ludovic Marie 5a562fb5a2 Merge pull request #12664 from CocofireHD/fix/12493-mob-pickup-particle
🤖 Agent: a mob picking up an item sends ClientboundTakeItemEntity, so clients see the item fly to it.
2026-11-09 00:01:57 +01:00
Ludovic Marie b4948662ae Merge pull request #12641 from itarqos5/perf/particle-collision-skip
🤖 Agent: particle collision skips sections whose palette holds no colliding state; the test compares it with the old full scan on 4000 boxes.
2026-11-09 00:01:53 +01:00
Ludovic Marie f1e42dcaf9 Merge pull request #12813 from MSedgeMC/fix/open-double-chest-half
🤖 Agent: an open double chest menu closes when one of its halves is broken (the container's size no longer matches), as CompoundContainer.stillValid does through ChestMenu.stillValid.
2026-11-09 00:01:50 +01:00
Ludovic Marie d9bd370a39 Merge pull request #12812 from MSedgeMC/fix/lectern-book-tag
🤖 Agent: a lectern accepts what the #lectern_books item tag lists, not a hard-coded pair of items.
2026-11-09 00:01:46 +01:00
Ludovic Marie e1899cca8f Merge pull request #12931 from vnsmoreira/packs/polytone-particle-modifiers
🤖 Agent: a Polytone particle modifier sets colour, life and size once per particle as it spawns, for the types it targets, vanilla and mod ones alike.
2026-11-09 00:01:43 +01:00
Ludovic Marie 834ecc9625 Merge pull request #12862 from Ragbie/fix/tp-on-ground
🤖 Agent: /tp zeroes the motion and sets on-ground unless fall-flying, as TeleportCommand.performTeleport does, and /spreadplayers zeroes it too.
2026-11-09 00:01:39 +01:00
Ludovic Marie f923aa5118 Merge pull request #12859 from Solenopsisbot/perf/pack-tail-fanout
🤖 Agent: Polytone particle emitters and dimension environments now load in assemble's existing scoped batch instead of after it; the values and their order are unchanged.
2026-11-09 00:01:35 +01:00
valuttaandClaude Opus 5.5 9b185113e5 Pistons push only the bodies in their reach
A clock of a thousand pistons spent most of each tick on its pushes: every
moving block walked every entity in the level and read its box, and the
collision view looked each piston up by scanning two lists.

- The tick's bodies are filtered once: a moving block's box sits within a
  block of its position and sweeps at most one more, so every box a push
  tests lies inside the block grown by 2. Only the pushed body moves during
  the pushes, so a body outside all of those boxes at the start is never
  reached. Each moving block then skips a body outside its own search area
  before the box math (level.getEntities in moveCollidedEntities).
- Step heights are read once per tick, not once per moving block.
- advance_piston_collision finds each piston through an index taken before
  the pushes, checked on use, falling back to the old search.

Fixes #12974

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:58:25 +01:00
Horizon agentandClaude Opus 5.5 47d2e49fc7 Bubble column test: every tick ends with the moved-through columns
ThrowableProjectile.tick and AbstractHurtingProjectile.tick both end with
applyEffectsFromBlocks after setPos, so the column pushes again after the
move, the first tick included; the first-tick scan alone is ThrowableProjectile's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:58:10 +01:00
Horizon agent 92618fa3a9 Merge the ender chest storage fix 2026-11-08 23:58:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 f51e2b0ec1 A new mob or armor stand draws its UUID from its own random first
Vanilla's Entity makes its UUID from the first two draws of its own random
(Mth.createInsecureUUID), and LivingEntity.<init> then draws the yaw from it,
before a kind's constructor draws such as Chicken's egg timer. Horizon seeded a
server mob's own random from its id and position and gave mobs and armor
stands a clock-seeded UUID when first asked.

mobs::living_random makes that random: fresh, the UUID drawn and returned, the
yaw drawn. Spawned and loaded mobs (summons, spawn eggs, natural spawns, saved
chunks) take it through Mob::with_random and mob_from_nbt_with_random; placed,
dispensed and loaded armor stands take its UUID. A UUID in the tag still wins.
Mob::new keeps its id-seeded random for the client's copies. Applying NBT to a
live mob (/data, a spawn egg's entity data) keeps its random, as Entity.load
fills in the same entity.

The seed sync proof reads the UUIDs of a summoned chicken, pig and armor stand,
of a pig from a dispensed egg and of a dispensed armor stand, a chicken's
EggLayTime, and the next one a summoned chicken draws after laying: 15/22 on
main, 22/22 with this.

Part of #12561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:56:18 +01:00
iseaprojectsandopencode 35f1f912a3 Block sprites: make the mip chains a chunk of layers at a time
`mip_chain` generated the sprite array's mips one layer at a time: at startup
and at every pack load or reload, a few thousand layers at 64 px for a 64x
pack ran on the pack worker alone, while the rest of the reload's decode
stages already spread over `par::par_map`.

Every layer makes its own chain from its own pixels, so the layers now run
through `par_map` in chunks of 16: a worker allocates one chain per chunk
rather than one per layer, and the chunks are appended in order, so the chain
stays layer major and each layer's settings still come from its global index.

The new `pack_load_bench` row over the HD pack's 1,100 layers takes 62 down to
11 ms. On a cutout-heavy array (1,300 layers at 64 px, a quarter of the texels
transparent) the same stage goes from 394 to 47 ms, and the vanilla-sized one
from 57 to 11 ms. A test asserts the parallel chain is byte for byte the one a
single thread making every layer gives, from the first layer and from the
middle of the array.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 18:55:39 -04:00
SolenopsisbotandCodex b5254c9f6d perf: retain shader pack sky scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:52:44 +11:00
martipops 4132d833ff Merge remote-tracking branch 'upstream/main' into ui/shader-shell 2026-11-08 17:52:31 -05:00
martipopsandClaude Opus 5.5 d88c9de224 Shaders on the shell: review fixes
- A float option's slider position is its stop, the maximum the last one,
  as `slider_value` reads it: the arrow keys no longer stick when the range
  isn't a whole number of steps. `nudge_option` is gone; a test walks a 0.3
  step both ways.
- The right button steps no value back, as on vanilla's `CycleButton`.
- Apply keeps its violet primary look.
- A link found by the search goes back to the page it was found on.
- `Screen::let_go` is `slider_released`; the docs say exactly what Undo
  puts back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:52:24 -05:00
voduffandClaude Opus 5.5 f687a35393 Inventory preview: enchanted armour and held items show their glint (#12666)
The inventory's player and the smithing table's armour stand turned the
model's vertices into HUD quads keeping only position, UV and colour, so
the armour glint (overlay ARMOR_GLINT) and the item glint were dropped.

- preview_glint_quads builds a glint face for each glint layer face, culled
  and turned like the model's; armour scrolls its model UV by
  ARMOR_ENTITY_GLINT_TEXTURING (0.16), items keep their transformed UV.
- HudBatch::depth_glint_since draws those faces as a Blend::DepthGlint run
  in the model's depth pass: additive, depth tested, never written
  (RenderPipelines.GLINT).
- hud.wgsl binds enchanted_glint_armor and picks it for u >= 4.

Fixes #12666

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:47:45 +00:00
SolenopsisbotandCodex 25a495c107 perf: keep flat renderer groups inline
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:43:25 +11:00
bulkandClaude Opus 5.5 817641fa36 Move the frame's world update out of game.rs into game/world_update.rs
Pure move (#11857). Game::update_world, prepare_lod and clear_lod leave game.rs; private
methods become pub(super), the same reach they had in game.rs. Port map rows and the
AGENTS.md table follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:23 +00:00
SolenopsisbotandCodex 64d2225b47 perf: keep shader post attachments inline
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:37:42 +11:00
TeiwazikandClaude Opus 5.5 0cd71497b9 Motion vectors: rockets, tridents and thrown potions move by their own motion
Their vertices were drawn under the camera-only run, so frame generation and
temporal upscaling reprojected them as if they stood still. Each now opens a
MOTION_OBJECT run under its entity id and closes it with a camera-only run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:34:04 +01:00
J1zeeeandGLM 9358df9085 Update the generated-block-entities test for its ender chest entity
Now that an ender chest is an EntityBlock (`for_block` returns a Chest), a
generated ender chest gets its real block entity instead of falling through
to the unmodelled raw list. The test pinned the old fall-through behaviour,
so CI's `-p horizon-client-lib --lib` failed on it (#12864). Expect both
entities now: the sculk sensor keeps its first slot, the ender chest follows
as a Chest at (-27, -40, 57), raw stays empty, and a second pass keeps both
without doubling.

Co-Authored-By: GLM <[email protected]>
2026-11-09 01:33:52 +03:00
martipopsandClaude Opus 5.5 bb827b3bfe Settings shell: a declared foot button leaves room for the pad's glyph
A half-width Apply beside Undo drew its X glyph over its label. Like
vanilla's buttons (`ui::action_button`), the label of a foot button that
declared its job now starts past the glyph while the pad drives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:30:35 -05:00
MrCloudy2 0f2374a9f1 Merge remote-tracking branch 'origin/main' into fix/piston-push-triggers
# Conflicts:
#	crates/horizon-client/src/game/piston_push.rs
2026-11-08 23:30:21 +01:00
SolenopsisbotandCodex f6e0f987ca perf: keep HUD blend runs inline
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:29:46 +11:00
Nikita Maximilian Grimm f60f2a7cd6 Merge remote-tracking branch 'origin/main' into parity/fill-block-nbt 2026-11-08 23:28:34 +01:00
AppaandCodex 58ba6ee7bf Cite the level-side query used by previews
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 14:25:59 -08:00
DUzzL fae353e82a Read TNT explosion_power from any numeric tag 2026-11-08 23:25:45 +01:00
Horizon agentandClaude Opus 5.5 982afd44b7 Format the merged lan.rs test modules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:25:17 +01:00
SolenopsisbotandCodex 27d330d03b perf: retain sound engine tick scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:25:09 +11:00
Horizon agent 1570016310 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client-render/src/particles/polytone.rs
2026-11-08 23:23:16 +01:00
bulkandClaude Opus 5.5 edf6782e02 Move using the held item out of game.rs into game/use_hand.rs
Pure move (#11857). Game::use_hand, apply_consume_effect and release_bow leave game.rs;
private methods become pub(super), the same reach they had in game.rs (the game module and
its children). Port map rows and the AGENTS.md / docs/architecture.md tables follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:22:41 +00:00
MrCloudy2 03a5d8f278 Merge remote-tracking branch 'origin/main' into fix/projectile-bubble-column
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
#	tools/port_map/game2-006.tsv
2026-11-08 23:22:13 +01:00
Horizon agent 55d85acf5c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-11-08 23:21:58 +01:00
Ludovic Marie d5504cd328 Merge pull request #12649 from TheMrZZ/fix/recipe-book-display-variants
🤖 Agent: transmute recipes show one recipe book entry per display, pinned by the new recipe_book_variants harness.
2026-11-08 23:21:45 +01:00
Horizon agent f528cc4298 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/render_tick.rs
2026-11-08 23:21:39 +01:00
Horizon agent 309ac52058 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/storage.rs
2026-11-08 23:21:18 +01:00
Horizon agent 5e7b000a0e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/books.rs
2026-11-08 23:21:11 +01:00
Horizon agent 7ee63f2db5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 23:20:09 +01:00
Florian Ernst e5534306c7 Merge origin/main into fix/allay-pickup-cooldown
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 23:19:27 +01:00
Florian Ernst 30e4b26b37 Merge origin/main into fix/h077-blending-heights-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/save.rs
2026-11-08 23:19:18 +01:00
Ludovic Marie 29be573768 Merge pull request #12669 from ilguerro/fix/entity-die-kill
🤖 Agent: Entity.kill posts entity_die for every non-living /kill target, and wool items dampen vibrations.
2026-11-08 23:19:17 +01:00
k3yhandClaude Opus 5.5 cb1e8d891e Content mods declare fluids, with network ids after vanilla's
Step 1 of #6301 (#5553's JSON): data/<id>/horizon/fluid/<name>.json copies
minecraft:water or minecraft:lava with NeoForge's FluidType and
BaseFlowingFluid.Properties numbers, a bucket item and block of the mod's
own, and its FluidModel textures and tint. Each fluid takes two ids in
minecraft:fluid after vanilla's (its source, then flowing_<name>), joins
the content identity and the command registries. Nothing placeable yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:19:15 -05:00
AppaandCodex 0741d53c75 Preserve the level side in client placement previews
Forward DryLevel's side flag so client placement does not prime TNT or
queue piston moves. Check powered client placements against vanilla and
keep server-side preview behavior covered.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 14:18:43 -08:00
Horizon agent ce2ec03a01 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:18:25 +01:00
Horizon agentandClaude Opus 5.5 8de6ff0bca Ignore the off-hand join test without prepared resources
It reads HORIZON_TEST_RESOURCES, which the unit test run (and CI verify) does
not set, so it failed with an unwrap on None there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:18:25 +01:00
Florian ErnstandClaude Opus 5.5 1501a0fd60 Merge origin/main into fix/last-hurt-by-mob-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:18:04 +01:00
Florian ErnstandClaude Opus 5.5 ba342ec0f6 Merge origin/main into fix/invulnerable-time-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:18:01 +01:00
SolenopsisbotandCodex 546db0269c perf: retain streaming update scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:17:01 +11:00
bulkandClaude Opus 5.5 51c2e96495 Move the command line Options out of game.rs into game/launch_options.rs
Pure move (#11857). Options and its impl (player_name, read, parse) leave game.rs; private
items, fields and methods become pub(super), the same reach they had in game.rs (the game
module and its children). Port map rows and the AGENTS.md table follow the code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:16:29 +00:00
Nikita Maximilian Grimm a9fb6ffc9a Merge branch 'hud-8bit' into hdr-p0 2026-11-08 23:16:13 +01:00
Nikita Maximilian Grimm 159fb6af86 Merge remote-tracking branch 'origin/main' into hud-8bit 2026-11-08 23:16:11 +01:00
martipopsandClaude Opus 5.5 d1a4478daa Shell and Shaders tests: a tall window that is tall, the next arrow, fresh frames
The tall window was 427x240 at its automatic scale too, the search case
pressed the shown name rather than its next arrow, and two draws ending
the same widget frame read the first one's widgets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:16:04 -05:00
Nikita Maximilian Grimm efda24ee40 Merge remote-tracking branch 'origin/main' into summoned-eye-of-ender 2026-11-08 23:14:39 +01:00
vermingovandClaude Opus 5.5 3dd49e2848 Content mods: jukebox songs load under their own namespace
load_songs read data/minecraft/jukebox_song only and named every song
minecraft:<file>, so a content mod's disc never found its song. Read
each content namespace as well, as vanilla's registry loader lists
every namespace (ResourceManagerRegistryLoadTask.load).

Part of #12910.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017xeMvE6sM435T8F5rAyvWU
2026-11-08 23:14:36 +01:00
Ludovic Marie 7b59041a89 Merge pull request #12583 from MSedgeMC/fix/horse-any-rider-bolt
🤖 Agent: RunAroundLikeCrazyGoal.canContinueToUse has no isMobControlled check, so continue now reads !tame && !nav.is_done() && is_vehicle.
2026-11-08 23:11:08 +01:00
Ludovic Marie 5fe5747f44 Merge pull request #12790 from DUzzL/fix/12712-sulfur-cube-bucket
🤖 Agent: the client predicts the sulfur cube bucket pickup, as vanilla's client does.
2026-11-08 23:11:05 +01:00
Ludovic Marie 841a5a4acd Merge pull request #12785 from RLC248/fix/12556-display-save-defaults
🤖 Agent: display and interaction entities save typed defaults; the command_exec oracle is extended.
2026-11-08 23:11:02 +01:00
Ludovic Marie e5397553a2 Merge pull request #12782 from oloion/fix/ru-translation-placeholders
🤖 Agent: tooltips snap to whole pixels; creative tab tooltips go through draw_tooltip_lines.
2026-11-08 23:10:58 +01:00
Ludovic Marie 196348ee3e Merge pull request #12781 from Mayonesu0/up-12382-cobweb-fall
🤖 Agent: the server player's cobweb/berry fall reset happens before the landing check, as in vanilla.
2026-11-08 23:10:55 +01:00
Ludovic Marie a44f73e9ae Merge pull request #12775 from MrCloudy2/fix/projectile-piston-cobweb
🤖 Agent: projectiles get makeStuckInBlock in cobwebs and a piston push zeroes it; new projectile_piston_cobweb harness.
2026-11-08 23:10:51 +01:00
Ludovic Marie 3fa342468c Merge pull request #12771 from iseaprojects/i18n/launcher-fr
🤖 Agent: 55 French launcher strings, placeholders kept.
2026-11-08 23:10:47 +01:00
Ludovic Marie b7b1d73a66 Merge pull request #12770 from TheMrZZ/fix/orb-score-store
🤖 Agent: an orb is a score holder named by its UUID; the new orb_score_store harness pins it.
2026-11-08 23:10:43 +01:00
Horizon agent 3fe80aae12 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 23:10:25 +01:00
Horizon agent af996ff963 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/resource_tables.rs
2026-11-08 23:10:03 +01:00
Horizon agent 633cac87b5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/save.rs
2026-11-08 23:09:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 592c4f55ef Fog: a spectator's water vision ramps up ten times faster
LocalPlayer.aiStep adds 10 to waterVisionTime each tick for a spectator
whose eye is in water, 1 for anyone else. Horizon always added 1, so a
spectator saw the water darker and foggier for 30 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:09:00 -03:00
Horizon agent 4c100ad4f1 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/armor_stands.rs
2026-11-08 23:08:07 +01:00
SolenopsisbotandCodex 99893d81c0 perf: share filtered recipe sources
Co-Authored-By: Codex <[email protected]>
2026-11-09 09:07:50 +11:00
iseaprojectsandopencode 34b9d2cd64 Items: decode a stack's components in one pass
`decode_components` read the 23 component IDs it models one at a time with
`Tag::get(&str)`: each call encodes the ID to UTF-16 and walks the compound's
`BTreeMap`, whether or not the stack carries it. Most stacks carry one or two,
so most of those lookups were pure overhead, and a chest pays for 27 of them.

`Seen::read` walks the compound once, dispatching each key against the 23 IDs
through a 64-byte ASCII buffer, and the fields then decode from those
`Option<&Tag>`s with the expressions they used before. A key that is not ASCII
cannot be a component ID and falls through to `other` as it did; the stored
form, the output and the second pass over unmodelled components are unchanged.

A one-component stack decodes in 0.36 instead of 0.93 us (2.6x) and an empty
compound in 0.089 instead of 0.56 us (6.3x). The decode stage of a 256-column
save batch drops from 106 to 81 ms (1.31x). A test asserts the one-pass decode
equals the per-ID implementation over five compounds, and
`save_batch_bench --split` times the four save and load stages apart.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 18:07:36 -04:00
Ludovic Marie 0b2279aaa3 Merge pull request #12833 from DUzzL/fix/12623-minecart-slope-effects
🤖 Agent: a cart's slope step is swept for entity-sensing blocks.
2026-11-08 23:06:51 +01:00
Ludovic Marie 72c9d15042 Merge pull request #12826 from Mayonesu0/up-12384-shovel-efficiency
🤖 Agent: detectEquipmentUpdates fires on a same-item stack change, so Efficiency applies.
2026-11-08 23:06:47 +01:00
Ludovic Marie 32f28b9d08 Merge pull request #12796 from DUzzL/fix/12559-living-timers
🤖 Agent: DeathTime is kept on load, impulse grace time is saved and ticked, and an armadillo picks its scute time on load.
2026-11-08 23:06:44 +01:00
Ludovic Marie 89f236e3ef Merge pull request #12794 from Stink-O/native/stamp-world-warp-hold
🤖 Agent: world_hold/world_warp stamped 414/415 (NATIVE_HOOKS_API 415); the hook stamp test passes on the union with the other hook PRs.
2026-11-08 23:06:39 +01:00
Ludovic Marie e2a28f96b8 Merge pull request #12792 from NikitaMGrimm/api02-room-to-grow
🤖 Agent: shader API 0.2 refuses packs that construct Horizon's input structs.
2026-11-08 23:06:35 +01:00
Ludovic Marie eee4242951 Merge pull request #12791 from DUzzL/fix/12607-firework-dragon
🤖 Agent: firework bursts hurt the dragon through its body's hurt rule.
2026-11-08 23:06:32 +01:00
martipops b1d469b419 Merge remote-tracking branch 'upstream/main' into ui/shader-shell
# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-11-08 17:06:32 -05:00
Ludovic Marie 44aa36f166 Merge pull request #12789 from NikitaMGrimm/fix-shulker-empty-items
🤖 Agent: an empty shulker box omits Items, with a command_exec corpus case.
2026-11-08 23:06:27 +01:00
Ludovic Marie 3f0328f42f Merge pull request #12784 from DUzzL/fix/12662-minecart-cobweb
🤖 Agent: minecarts read stuckSpeedMultiplier from cobwebs like other entities.
2026-11-08 23:06:24 +01:00
martipopsandClaude Opus 5.5 d0e38cc6c7 Shaders moves onto the settings shell, whose page list collapses when full
Shaders is a `Screen` now: the pack list and the picked pack's screens are
sheets read from the pack, and the shell draws and drives them. The shell
gains what Shaders needed, for every screen on it: a page list that
collapses its last section into one `‹ Name ›` row when the pages don't
fit, sections under a line, pages out of the list that a row links to
(Escape goes back), Apply beside Undo, and a slider's `let_go`. Undo puts
back the pick, the Shadow Cache and the packs' options; Apply shows only
while a `shaders/` pack's options differ from its saved ones. Left and
Right step a pack's options, and the right button steps a value back.
ShaderUi's own drawing, scrolling, focus, search and page list are gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:06:07 -05:00
Nikita Maximilian Grimm fcc01b662b Merge branch 'hud-8bit' into hdr-p0 2026-11-08 23:06:06 +01:00
Nikita Maximilian Grimm fae81ab428 Merge remote-tracking branch 'origin/main' into hud-8bit 2026-11-08 23:06:05 +01:00
Ludovic Marie 0030a4add8 Merge pull request #12764 from iseaprojects/i18n/it-it-rest
🤖 Agent: it_it gets its 547 missing game keys.
2026-11-08 23:04:59 +01:00
Ludovic Marie c2be77292a Merge pull request #12759 from baytyone/map-locate-reads-each-saved-chunk-once
🤖 Agent: map search caches saved-chunk structure references per session, like StructureCheck; maps' own references are checked first.
2026-11-08 23:04:56 +01:00
Ludovic Marie c5a4b2cdd5 Merge pull request #12747 from NikitaMGrimm/hdr-sdr-screenshot
🤖 Agent: float HDR frame screenshots are tone-mapped, and the float frame stays behind its env switch.
2026-11-08 23:04:51 +01:00
Ludovic Marie caac31c8df Merge pull request #12746 from NikitaMGrimm/hdr-auto
🤖 Agent: HDR gains Off/On/Auto with Off the default; the display is watched only when HDR isn't Off.
2026-11-08 23:04:48 +01:00
Ludovic Marie dd208c4f97 Merge pull request #12732 from vnsmoreira/packs/polytone-particle-fixes
🤖 Agent: Polytone custom particle fixes measured against Java; the additive overlay branch only applies with fog 45.
2026-11-08 23:04:44 +01:00
Ludovic Marie 7092049967 Merge pull request #12718 from iseaprojects/tooling/lang-coverage
🤖 Agent: tools/lang_coverage.py only reads files and reports missing keys.
2026-11-08 23:04:40 +01:00
Vinícius MoreiraandClaude Opus 5.5 4006b3da52 Polytone: particle modifiers on vanilla and mod particles
Resource packs' particle modifiers now change the particles of the types
they target as each one spawns: colour (channels, packed colour or a
colormap), lifetime, size and speed. Observed on the real mod: the
expressions run once at spawn, `size` multiplies the particle's size as
vanilla's Particle.scale does, and `speed` follows setPower.

Modifiers with a `filter` are skipped: particles here keep the sprite of the
block or item they carry, not the block or item itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:03:56 -03:00
Horizon agent b44fa6e966 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:46 +01:00
Horizon agent 0331afdf3d Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:43 +01:00
Horizon agent 0952f173b5 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:40 +01:00
Horizon agent bff215ab70 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:37 +01:00
Horizon agent 13b8277019 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:35 +01:00
Horizon agent 3587cef272 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:32 +01:00
Horizon agent 92d612f109 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:29 +01:00
Horizon agent 04d8249f08 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:27 +01:00
Horizon agent 30628bf79c Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:24 +01:00
Horizon agent 19e86d6782 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:21 +01:00
Horizon agent 0f82100ddd Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:18 +01:00
Horizon agent a0794bfd2e Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:15 +01:00
Horizon agent aac0f59fd7 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:12 +01:00
Horizon agent 962ce1dfbc Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:08 +01:00
Horizon agent 5c684b8cf1 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:05 +01:00
Horizon agent c504f1347a Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:02:01 +01:00
Horizon agent 0ab94f3e13 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:58 +01:00
Horizon agent 8e02a3cb80 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:54 +01:00
Horizon agent a5cb5bdadd Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:49 +01:00
Horizon agent 3c7500e007 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:45 +01:00
Horizon agent 31c060e41b Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:38 +01:00
Horizon agent b3e597d983 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:33 +01:00
Horizon agent 84b445e017 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 23:01:28 +01:00
Vinícius MoreiraandClaude Opus 5.5 a31ee46ddf End: its world clock is saved and ticks apart from the game time
ServerClockManager keeps one clock per WorldClock: the End's (the End sky
flash's) is saved in world_clocks.dat and advances with advance_time, apart
from the game time. Horizon sent the game time as the End's clock, so a
world opened from Java flashed at other times than in Java.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:01:23 -03:00
Ludovic Marie c23d7cdf4b Merge pull request #12876 from foudretbr/feat/registry-native-builds
🤖 Agent: registry-only; dispatch is refused for revoked files and both endpoints are rate-limited per proxy-seen address.
2026-11-08 23:00:08 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 264ea0cc7c HDR float frame: shader API 0.2 packs get HDR output and the layered HUD
Under HORIZON_HDR_FRAME=float, a 0.2 pack with HDR on now gets the
layered HDR frame that HDR without a pack uses, where the device keeps
the HUD on a layer of its own. Frame's display fields are the display's
(hdr 1, headroom, paper white, peak, black level), read every frame.
The pack's [tonemap] maps the light, or else Horizon's no-pack tone
curve does: the default tonemap stage no longer clips at the headroom
in HDR, and Horizon's curve is skipped after a pack's own. The HUD is
laid over the picture by coverage as without a pack. F2's SDR
screenshot rolls highlights off only where Horizon lifted the world
(Hdr::lighting): under a pack it is clipped at white, as before.

With HDR off, or without the HUD layer, the fields stay SDR's and the
pack's float picture finishes on the swapchain's 8-bit format
(Readback::finish) before the hand, the camera overlays and the HUD
draw, scaled screenshots included. So each overlay draw clips at white
as on vanilla's frame, and HDR output stays off there. The frame also
follows the HDR offer changing at runtime, so HDR On under a pack turns
on once the display offers it.

The shader API reference now says that Horizon lifts the shadows onto
black_level after [tonemap].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:59:39 +01:00
Horizon agentandClaude Opus 5.5 bee1d42fd2 Format the ender chest save test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:59:27 +01:00
Florian ERNSTandCodex b3aded6e7d Use glide travel for eligible mobs
Share the existing pure glide arithmetic with Mob travel while retaining
its collision path, NoAI exclusion and existing damage hooks. Preserve
Player glide behavior and map fly-into-wall damage explicitly.

Co-Authored-By: Codex <[email protected]>
2026-11-08 22:57:14 +01:00
Ludovic Marie 8bb7788b8e Merge remote-tracking branch 'origin/main' into fix-12864 2026-11-08 22:57:13 +01:00
Horizon agentandClaude Opus 5.5 1c888ac5b2 Ender chest: keep no copy of the player's items in its block entity
Closing it now moves the items back and leaves the entity empty, and its saved form
has no Items: EnderChestBlockEntity saves nothing of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:57:13 +01:00
Ludovic Marie bcb76434bc Merge remote-tracking branch 'origin/main' into fix-12876 2026-11-08 22:56:34 +01:00
Horizon agentandClaude Opus 5.5 29cbcd69d1 registry: rate-limit native build polls, refuse revoked files, key limits on the proxy-seen address
The first X-Forwarded-For hop is what the client sent, so the per-address limit
could be dodged; take X-Real-IP or the hop the proxy appended. revoked.toml is now
checked before a build is dispatched, and status polls (one GitHub API call each)
share a rate limit like requests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:56:34 +01:00
Florian ErnstandClaude Opus 5.5 6e42a09d63 Keep a full neighbour chunk's blocks from ordinary feature writes
While a new chunk decorates, a full neighbour stands in the region as
ImposterProtoChunk(chunk, false): its setBlockState, setBlockEntity,
removeBlockEntity and markPosForPostProcessing change nothing, and
WorldGenRegion.setBlock still answers true, while BulkSectionAccess (ores)
writes its sections. Horizon rebuilt a full neighbour as an ordinary
decorated proto chunk and copied every block that changed into it, so the
trees and plants of new chunks grew into old full chunks.

proto_beside_final now marks its proto chunk full (not saved). Decoration
records the region's full chunks before their terrain moves into the
region, and the two ordinary write entry points, set_block and
set_chunk_block, change nothing in them after the zone check and answer
as before. Section writes are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:55:23 +01:00
SolenopsisbotandCodex abbe20ec90 perf: keep HUD attachments on the stack
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:55:00 +11:00
MrCloudy2andClaude Opus 5.5 87c51909d4 A piston's push presses the pressure plate it carries an entity onto
moveEntityByPiston's applyEffectsFromBlocks: the entity-sensing blocks the
pushed box covered sense it during the push, so a plate powers its
neighbours before the piston pushes the next entity.
Harness: tools/test_piston_push_plate_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:53:27 +01:00
Ludovic Marie 20111ddf7b Merge pull request #12777 from iseaprojects/i18n/launcher-it
🤖 Agent: 142 it launcher strings, placeholders kept; merged key by key with main's lang changes.
2026-11-08 22:52:32 +01:00
Ludovic Marie 252a012722 Merge pull request #12776 from iseaprojects/i18n/launcher-de
🤖 Agent: 213 de launcher strings, placeholders kept; merged key by key with main's lang changes.
2026-11-08 22:52:28 +01:00
Ludovic Marie 7f9e634e22 Merge pull request #12772 from iseaprojects/i18n/launcher-pt-br
🤖 Agent: 137 pt-br launcher strings, placeholders kept; merged key by key with main's lang changes.
2026-11-08 22:52:24 +01:00
Ludovic Marie f4b8e96eca Merge pull request #12592 from MSedgeMC/fix/loot-set-custom-data
🤖 Agent: set_custom_data accepts the tag as an object or as SNBT text (LENIENT_CODEC) and merges it into the existing custom_data component key by key.
2026-11-08 22:52:20 +01:00
Ludovic Marie 77614f2697 Merge pull request #12819 from martipops/ui/shell-tabs
🤖 Agent: shader screens collapse into one hud_kit::tabs row only when they don't all fit; a tall view keeps the list as it was.
2026-11-08 22:52:15 +01:00
Ludovic Marie 0cb33a7139 Merge pull request #12654 from vnsmoreira/packs/polytone-post-chains
🤖 Agent: Polytone post chains, their targets and uniforms; PolytonePost::read is None without chains, so packs without them pay no frame cost. Holds #12539 and #12653's fade.
2026-11-08 22:52:11 +01:00
SenjinandClaude Opus 5.5 cd069f2065 Audio: a dead output stream is opened again
cpal reports a dead ALSA stream (alsa::poll() returning POLLERR, over
and over) as a backend error, which was only logged: the game stayed
silent until a restart. A run of backend errors close together (three
within two seconds) now counts as a lost device, as OpenAL Soft reports
a stopped backend under vanilla (ALC_CONNECTED), and the output is
opened again. The dead stream lets go of the device first, and a failed
reopening is tried again every two seconds, five times, before the
output stays off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:49:09 +01:00
Nikita Maximilian Grimm 271b3626a2 Merge remote-tracking branch 'origin/main' into summoned-eye-of-ender 2026-11-08 22:48:47 +01:00
Ludovic Marie aac977d7dd Merge pull request #12751 from fbikat/fix-12521-piglin-bogged-nbt
🤖 Agent: AbstractPiglin's CanPickUpLoot defaults to true when absent, Piglin always writes CannotHunt, and bogged save and load sheared; the rebase keeps #12685's tracker asserts.
2026-11-08 22:47:31 +01:00
Ludovic Marie 3b34e7ba33 Merge pull request #12824 from Ragbie/fix/vehicle-onground-fall-distance
🤖 Agent: vehicles save and load OnGround and fall_distance; the new corpus case checks them across a chunk reload.
2026-11-08 22:47:27 +01:00
Ludovic Marie 7c5a4a621d Merge pull request #12618 from ThatCakeIsAPie/perf/lazy-templates
🤖 Agent: pool templates load lazily on first use, the first insert kept; 59 structure tests pass.
2026-11-08 22:47:23 +01:00
SolenopsisbotandCodex f2c1081089 perf: avoid blank HUD slot source buffers
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:45:09 +11:00
Horizon agentandClaude Opus 5.5 ab56dc20bd Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:24 +01:00
Horizon agentandClaude Opus 5.5 76d91d6b27 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:23 +01:00
Horizon agentandClaude Opus 5.5 fb0a077202 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:22 +01:00
Horizon agentandClaude Opus 5.5 b90fee34be Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:20 +01:00
Horizon agentandClaude Opus 5.5 42a0d12f62 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:19 +01:00
Horizon agentandClaude Opus 5.5 40a9813dcd Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:18 +01:00
Ludovic Marie fa7924b3fb Merge pull request #12832 from iseaprojects/perf/paletted-pack-uniform
🤖 Agent: a uniform section packs without unpacking its 4096 cells.
2026-11-08 22:43:53 +01:00
Ludovic Marie e327c79d1f Merge pull request #12829 from RINNECODER/up7/carried-features
🤖 Agent: carried mod features get their config unwrapped, so mod ores generate.
2026-11-08 22:43:49 +01:00
Ludovic Marie b18000dccf Merge pull request #12827 from NikitaMGrimm/fix-ominous-spawner-motion
🤖 Agent: the ominous item spawner keeps its loaded Motion, with a corpus case.
2026-11-08 22:43:45 +01:00
Ludovic Marie 9c83ccf52f Merge pull request #12825 from horizon-rust/perf/rgss-trilinear-taps
🤖 Agent: RGSS taps at the fractional mip through the linear mip filter (-0.95 ms in the forest scene).
2026-11-08 22:43:41 +01:00
Ludovic Marie 321a391209 Merge pull request #12823 from TheMrZZ/fix/hoglin-repeat-attack
🤖 Agent: the hoglin's retaliation broadcast keeps a nearer target; new hoglin_repeat_attack harness.
2026-11-08 22:43:38 +01:00
Ludovic Marie 2f400ca79e Merge pull request #12811 from MSedgeMC/fix/lectern-book-signal
🤖 Agent: the lectern signal adds 1 only when the stack is a book (hasBook asks for a book-content component).
2026-11-08 22:43:34 +01:00
Ludovic Marie 42d1e6d7d7 Merge pull request #12810 from MSedgeMC/fix/local-locked-container
🤖 Agent: a locked container refuses the local open with the action bar and chest_locked sound (LockCode reads the main hand).
2026-11-08 22:43:31 +01:00
Ludovic Marie ae40b07eeb Merge pull request #12806 from iseaprojects/fix/11696-armor-worn-before-reduction
🤖 Agent: armor worn before the hit reads armor; the new helmet_damage harness pins it.
2026-11-08 22:43:27 +01:00
Ludovic Marie 16fdd05784 Merge pull request #12803 from NikitaMGrimm/entity-ticking-gate
🤖 Agent: potions, orbs, rockets and clouds freeze outside entity-ticking chunks, with a corpus case.
2026-11-08 22:43:23 +01:00
Ludovic Marie b6036c08c1 Merge pull request #12802 from fbikat/fix-12778-snow-golem-pumpkin
🤖 Agent: snow golem Pumpkin is saved and loaded, default true, as SnowGolem.addAdditionalSaveData does.
2026-11-08 22:43:19 +01:00
MrCloudy2andClaude Opus 5.5 6b8be62697 Port ledger: projectile bubble columns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:41:14 +01:00
SolenopsisbotandCodex 1f11377f60 perf: share entity models across startup loaders
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:39:58 +11:00
Horizon agentandClaude Opus 5.5 7457de69bb Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:29 +01:00
Horizon agentandClaude Opus 5.5 8db87db310 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:19 +01:00
Horizon agentandClaude Opus 5.5 385bd4d0b1 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:08 +01:00
Horizon agentandClaude Opus 5.5 0989100291 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:53 +01:00
Horizon agentandClaude Opus 5.5 a174de80d3 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:41 +01:00
Horizon agentandClaude Opus 5.5 42a73e846d Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:28 +01:00
MrCloudy2andClaude Opus 5.5 24e5d3d707 A bubble column pushes every projectile in it, unclamped
Projectile.onInsideBubbleColumn / onAboveBubbleColumn through the
applyEffectsFromBlocks of ThrowableProjectile, AbstractHurtingProjectile and
ShulkerBullet ticks, and handleFirstTickBubbleColumn for throwables. The
server pushed only ender pearls, with the clamped push of other entities.
Harness: tools/test_projectile_bubble_column_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:20 +01:00
Horizon agentandClaude Opus 5.5 598a294156 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:17 +01:00
Horizon agentandClaude Opus 5.5 3bdce4d658 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:38:07 +01:00
Ludovic Marie da0cab5bfa Merge pull request #12676 from MSedgeMC/fix/anvil-enchantments-presence
🤖 Agent: the whole stations series (anvil repairable, grindstone max stack, smithing trim material/pattern holders, enchantable, removed components): stack components override item defaults, as ItemStack.get does.
2026-11-08 22:37:55 +01:00
Horizon agentandClaude Opus 5.5 5c6329ba54 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:37:55 +01:00
Ludovic Marie e8b162cd2f Merge pull request #12761 from iseaprojects/i18n/de-de-rest
🤖 Agent: de_de gets its 553 missing keys; the split_ui locale test now expects German.
2026-11-08 22:37:51 +01:00
DUzzL b3f312cf99 Read attribute base from any numeric tag in mob NBT 2026-11-08 22:37:48 +01:00
Ludovic Marie 87ba93d8d1 Merge pull request #12744 from RLC248/fix/12615-follow-tracking-emitters
🤖 Agent: tracking emitters follow the live entity and keep emitting at the last position after removal; tick(&level) is kept for other callers.
2026-11-08 22:37:48 +01:00
Ludovic Marie 5c6d7b6dab Merge pull request #12621 from ThatCakeIsAPie/parity/pack-biome-ambience
🤖 Agent: pack biomes own their music and ambient sounds over the dimension's.
2026-11-08 22:37:44 +01:00
Ludovic Marie 795ae8e8a0 Merge pull request #12617 from ThatCakeIsAPie/perf/decoration
🤖 Agent: the lake grid as column bitmasks keeps vanilla's x,z,y and sum order; biome feature bitsets.
2026-11-08 22:37:41 +01:00
Ludovic Marie 7cd858f208 Merge pull request #12563 from kacpherek/fix/blaze-server-effects
🤖 Agent: blaze server effects (burn fx off, slow-fall order, levelEvent 1018) plus #12562's float literals; mob_ai blaze scenarios.
2026-11-08 22:37:36 +01:00
Ludovic Marie fd055a0020 Merge pull request #12613 from TheMrZZ/fix/dragon-fireball-motion
🤖 Agent: a dragon fireball stops at its owner's hit point that tick (AbstractHurtingProjectile.tick) and keeps its motion.
2026-11-08 22:37:32 +01:00
DUzzL c30e611ec3 Read dragon Health, DragonPhase and DragonDeathTime from any numeric NBT tag 2026-11-08 22:36:42 +01:00
Horizon agentandClaude Opus 5.5 a5f7ec2c4c Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:36:30 +01:00
Horizon agentandClaude Opus 5.5 9a4ac94b73 Merge main (the reviewer test fix, #12783)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:34:37 +01:00
Vinícius MoreiraandClaude Opus 5.5 efaa1660e9 Polytone: post chains, their targets and uniforms
Resource packs' Polytone post chains (wiki "Shaders") run their vanilla
post_effect chain over the world while their activation_condition holds,
with a uniform block per expression uniform, the PolyGlobals block (the
world's projection and camera, the sun's angle, the time of day and the
frame's tick delta, the player's position) and the packs' post_targets,
kept between frames, in every chain. Post passes now read the frame's depth
with the chains' rows (row 0 at the bottom), as their colour already was;
its values stay reverse-Z, as 26.3's are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:34:34 -03:00
Vinícius MoreiraandClaude Opus 5.5 f39318eeb0 Polytone: GPU particles fade in and out on a smoothstep
A GPU particle type's fade (wiki "GPU Particles": the fractions of its life spent
fading in and out) ramped its alpha linearly. Measured in Java on a line of
particles whose age grows with x, Polytone's ramps ease in and out: a quarter of
the way through the fade-in a particle has about a sixth of its alpha, not a
quarter. Rain dies at the ground well inside its fade-in, so each drop was up to
twice as bright as in Java; a column of the freepack's rain now matches Java band
by band.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:34:34 -03:00
Nikita Maximilian GrimmandClaude Opus 5.5 d398fbc8b0 HDR HUD layer: an 8-bit target, so each HUD draw clips as vanilla's
The float HDR frame's HUD target was in the frame's float format, so a
HUD draw no longer clipped at white before the next one blended over it:
a text highlight added over a light backdrop, then a 50% black panel,
came out at 0.65 instead of vanilla's 0.5. The HUD target is now 8-bit
(Rgba8Unorm), and the coverage pipelines, the only ones that draw on it,
target that format. The composite subtracts the backdrop as the 8-bit
target holds it, so pixels no HUD draw touches stay exactly the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:32:35 +01:00
valuttaandClaude Opus 5.5 39af844866 Sync the client's moving pistons once per frame's packets
A clock of a thousand pistons froze the window: the client rebuilt
World::moving_pistons after every BlockEvent that starts a move and every
block change that ends one, and each rebuild compared every previous entry
with every current one and scanned the pending uploads for every fallback
entry. A pulse sends about three packets per piston, so it cost O(N^3).

Packets now mark the pistons stale and poll_remote syncs them once after
the frame's packets, before the tick or the frame reads them. The sync
itself is linear: a hash set of the positions drawn, and one
section_mesh_pending answer per section.

Fixes #12901

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:32:23 +01:00
Vinícius MoreiraandClaude Opus 5.5 81337c3a2f Polytone: the Pack Options page for packs' config entries
Resource packs' Polytone config entries (wiki "Polytone Configs") get a page:
the card of a pack with options carries a small sliders button at its right
edge (as Respackopts does), which opens that pack's options in the frame of
Horizon's settings screens. Numbers are sliders, texts cycle, booleans switch;
entries sit in their sections, named and described by the pack's own
translations, with the performance hint. Done keeps what changed in
polytone-configs.json beside options.txt, and the packs load again with it, so
overlays and expressions that read the configs follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 18:31:04 -03:00
SolenopsisbotandCodex 3192adafe0 perf: reuse shared item defaults in world rest
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:26:55 +11:00
martipops a6705ea839 Merge remote-tracking branch 'upstream/main' into ui/shell-tabs
# Conflicts:
#	AGENTS.md
#	docs/ui-kit.md
2026-11-08 16:25:43 -05:00
Horizon agentandClaude Opus 5.5 d9b3005690 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:43 +01:00
Horizon agentandClaude Opus 5.5 eef19d5cf9 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:35 +01:00
Horizon agentandClaude Opus 5.5 1eb93f5884 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 4bc55da08d Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 dcd90c2b1f Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 41a72c6eda Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:34 +01:00
Horizon agentandClaude Opus 5.5 3ae606abce Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:33 +01:00
Horizon agentandClaude Opus 5.5 10eb936805 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:25:33 +01:00
29ac896b4c Harness: boats and minecarts keep OnGround and fall_distance across a chunk reload
NikitaMGrimm's group from #12517: oak and cherry boats, a bamboo raft,
minecarts and a command block minecart, read live, after an unload and
reload, and three ticks later. 73/73 against vanilla.

Co-authored-by: NikitaMGrimm <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:25:02 +03:00
SenjinandClaude Opus 5.5 f9eb983ba1 Shader packs: boats keep the water out of their hulls
A boat's water patch (RenderTypes.waterMask: depth only) went to a pack's
entity program as an ordinary textured quad, whose alpha test threw its
transparent texels away, so the water showed inside the hull; and the
pack's shadow pass drew the water there too (caustics, light under water).

The patches now draw depth only into a depth of their own (the entity
program's pipeline without colour writes, at one texel of the white
sprite so no level or alpha test drops them), and translucent terrain
discards what lies behind them (GbuffersInputs::water_mask). The shadow
pass's water discards what lies inside them, from their camera-relative
boxes (GbuffersInputs::water_patches, horizonWaterPatch*). A hull before
its patch keeps its motion matching (at_velocity).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:20:26 +01:00
Horizon agentandClaude Opus 5.5 e13a206334 Split screen test: German now translates the off status
This PR fills de_de's missing keys, so the status the test used to show the
English fallback is German now. The fallback itself stays covered by lang's
layers_selected_language_over_english.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:16:53 +01:00
Horizon agentandClaude Opus 5.5 2ba45c262d Particles: the emitter probe reads live bodies through tick_tracking
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:16:28 +01:00
DetimesandClaude Opus 5.5 0be41c06be Mod API: cheap fake players
native_set_player_fake / native_expect_fake_player / native_is_player_fake
and NativeServerPlayer.fake. A fake player keeps a small loaded area, is no
tracking viewer or spawn anchor, is sent only the packets a bot answers, and
skips the client-only parts of the guest tick. Adds a tick benchmark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:16:24 +00:00
DetimesandClaude Opus 5.5 3e4cfbe7ea Mod API: entity hooks and helpers
living_experience_drop, potion_splash_v2, player_died_v2, entities_in,
living_helpers (swing, fire, effects, attribute base, use item, lightning),
npc_mob_path, advancement_display, and entity_join_level for displays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:14:48 +00:00
fbikatandClaude Opus 5.5 110e686f4d Piglins pick up loot when CanPickUpLoot is absent and save CannotHunt; bogged save and load sheared
AbstractPiglin reads CanPickUpLoot with default true, so a piglin or brute
summoned or loaded without it picks up loot. Piglin always writes
CannotHunt; the brute doesn't. Bogged saves and reads sheared, so a
sheared bogged keeps its mushrooms off after a reload.

Fixes #12521.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:12:16 +00:00
valuttaandClaude Opus 5.5 646572e69b No block outline over the panorama
Opening a world in front of a block left its outline on Loading terrain's
panorama, drawn with the panorama's camera. The outline is built from the
player's target whenever the HUD shows; a panorama frame (the title screen,
LevelLoadingScreen's Reason.OTHER) replaces the level vanilla draws under its
opaque background, so skip it there as the debug crosshair already does.

Fixes #12872

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:12:10 +01:00
fbikatandClaude Opus 5.5 a8d74ca97b Read booleans from any number for end crystals, the dragon, primed TNT, signs, hive bees and interactions
getBooleanOr and Codec.BOOL take any numeric tag's nonzero byte. These sites
matched one exact byte; they now use entity_nbt::flag, or the new flag_or
for fields that default to true (ShowBottom). The render crate's signs use
the same int_value test.

Fixes #12845.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:08:03 +00:00
Florian ErnstandClaude Opus 5.5 fe74121c5b An allay saves and loads its item pickup cooldown
AllayAi sets ITEM_PICKUP_COOLDOWN_TICKS to 60 when an allay throws an
item, and the Brain saves it as {value: ...} and reads it back. Horizon
kept the cooldown for the allay's pickups (extra.ints[3]) but rebuilt
the saved Brain memories without it, and never read it.

The allay's Brain writes item_pickup_cooldown_ticks while the cooldown
runs and reads it back, as its liked_noteblock_cooldown_ticks already
does. The tick it spends at 0 before CountDownCooldownTicks erases it is
not kept: the model has no presence apart from the value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:06:51 +01:00
SolenopsisbotandCodex d170e4f674 perf: reuse region NBT decode scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 08:05:27 +11:00
J1zeee bde1cc63c5 Merge remote-tracking branch 'origin/main' into fix/12752-ender-chest-end-portal-block-entities
# Conflicts:
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/save.rs
2026-11-09 00:03:40 +03:00
Horizon agentandClaude Opus 5.5 0b5b68bdd2 Particles: keep tick(level) and add tick_tracking for the live bodies
Other open PRs (and tests) call ParticleEngine::tick(&level); only the game's
render tick needs the tracking-body reader. tick() is tick_tracking() with no
body, so both kinds of caller keep compiling whichever lands first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:02:36 +01:00
foudretbr 39d6f25e58 feat(registry): add POST /api/native-builds on-demand prebuilt endpoint
Implement on-demand native build request handling and status checks in horizon-mod-registry (#11859). Accepts valid target triples, commit base, and registry native mods, verifies hashes and versions against the index, deduplicates dispatches by request key, and queries release assets.
2026-11-08 22:00:25 +01:00
Florian ErnstandClaude Opus 5.5 4b9cc3eeae Test: an allay keeps its item pickup cooldown memory
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:00:11 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b00506bc0e HDR settings: Black Level 0 lifts nothing, Peak reaches 10 000 nits, every Paper White step
Black Level has no Auto: 0, the default, is true black, and the saved value
is the lift it says. Old files' 0 (Auto) reads as no lift, and the system's
reported black is no longer kept. Calibration's black step still sets a lift,
and the metadata's minimum luminance follows the setting.

Peak Brightness goes to PQ's 10 000 nits by hand, in the saved value and in
calibration: 200..2000 by 50 as before, then by 500. 0 stays Auto, and saved
values keep their meaning.

Paper White's slider has its 48 steps, so every 10-nit value is reachable
by keyboard and controller. Paper White's description no longer claims the
menus, which UI Brightness sets.

Closes #12841

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:58:04 +01:00
redstonerthebest 4a3058d4ad mod-api: push_voice, an audio path for mods
Adds Host::push_voice(samples, sample_rate, at): a mod queues mono samples and
the output callback mixes them with the game's sounds, with distance gain and
direction when the mod says where they were spoken.

The roadmap entry "Proximity voice chat as a native mod" names what is missing
for such a mod: a way to carry voice between players and an audio path in the
game. The carrying side already exists (net_send_to_server, native_net_from_server
on a mod's own channel); this adds the audio side.

- horizon-client-render: VoiceChunk and a queue in Mixer, mixed in mix() before
  the limiter; MixerBackend::push_voice caps the queue at two seconds and refuses
  a burst rather than delaying it; Audio::push_voice forwards to it.
- horizon-client: Game::native_push_voice, listed in native-hooks.json under
  helpers so the generator emits Host::push_voice (API 360), its trait default
  and the game's implementation.
- tests: a queued chunk is mixed centred and dropped after it played out; a
  position pans it by the listener's right vector (facing south, -X is right).

The audio itself could not be listened to on this machine (headless, no output
device), so the mixing is checked by tests only.
2026-11-08 20:57:45 +00:00
martipops be5c7e2fe2 Merge remote-tracking branch 'upstream/main' into ui/shell-tabs 2026-11-08 15:57:40 -05:00
SolenopsisbotandCodex 68bb5be687 perf: reuse remote guardian eye scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:54:09 +11:00
CocofireHDandopencode 0a63e373ad Merge remote-tracking branch 'origin/main' into fix/12493-mob-pickup-particle
# Conflicts:
#	crates/horizon-client/src/game/lan.rs

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 21:53:09 +01:00
SolenopsisbotandCodex 291a28fc99 perf: fan out Polytone pack tail loads
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:53:05 +11:00
Horizon agentandClaude Opus 5.5 70d3441c4a Eye of ender: keep the EyeOfEnder doc on its struct
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:52:11 +01:00
Riccardo GuerrieroandClaude Opus 5.5 54b6232191 Merge origin/main into fix/landing-vibrations
Picks up #12783 (the trusted-reviewers test), whose absence failed verify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42N6Dh4LhpXkVwZmHs
2026-11-08 21:51:41 +01:00
SolenopsisbotandCodex aea460736c perf: reuse pickable projectile hitbox scratch
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:51:26 +11:00
Riccardo GuerrieroandClaude Opus 5.5 af256dcc40 Merge origin/main into fix/cauldron-vibrations
Picks up #12783 (the trusted-reviewers test), whose absence failed verify.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42N6Dh4LhpXkVwZmHs
2026-11-08 21:51:13 +01:00
SolenopsisbotandCodex e54d91dc0c perf: overlap world rest resource loads
Co-Authored-By: Codex <[email protected]>
2026-11-09 07:50:31 +11:00
Evrim CicekandClaude Opus 5.5 fffa3bf6b8 /tp stops its target and puts it on the ground, as vanilla's does
Entity.teleportTo builds its transition with no motion, so
teleportSetPosition leaves the entity still (/tp, /spreadplayers, a
cross-level /tp, the mod API's teleport), and TeleportCommand.performTeleport
then zeroes the vertical motion and grounds the target unless it is a
fall-flying living entity, and stops a mob's navigation. An end gateway's
or a vehicle's own teleport keeps its motion as before.

Fixes #12834

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 23:39:01 +03:00
DenisHumenandClaude Opus 5.5 f70686be76 Empty the off hand when joining a server
A new connection cleared the inventory and armor but kept the off-hand item,
so a server that never sends slot 45 left the last server's item as a ghost.
ClientPacketListener.handleLogin makes a new LocalPlayer with an empty Inventory.

Fixes #12809

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:34:06 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 fe70a48402 Dispensed eggs: elder guardians and copper golems stay persistent
Their constructors call setPersistenceRequired, as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:32:46 +01:00
Florian ErnstandClaude Opus 5.5 67ebf35e57 A mob's hurt cooldown is not its invulnerable_time
Vanilla keeps two fields: LivingEntity.damageCooldownTime, the hurt
cooldown a hit sets (never saved, kept by an in-place /data load), and
Entity.invulnerableTime, saved as invulnerable_time, which counts down in
commonTick and makes the entity invulnerable while above 0. Horizon used
one field for both, so a hurt mob saved its cooldown as invulnerable_time
and a summoned invulnerable_time only acted as a hurt cooldown.

Mob gets invulnerable_time, the only field the key loads into and saves
from. It counts down at the start of the mob's tick and joins the
Invulnerable flag in the damage guard (#bypasses_invulnerability passes).
The cooldown stays transient and is carried across a /data rebuild again.
Conversion copies invulnerable_time, and the skeleton trap's riders and
horses start with 60 of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:31:10 +01:00
Horizon agent 1406684c20 Merge remote-tracking branch 'pr/12676' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
2026-11-08 21:30:15 +01:00
Horizon agent 4b4dca948c Merge remote-tracking branch 'pr/12640' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
2026-11-08 21:30:07 +01:00
Horizon agent b2a457fdef Merge remote-tracking branch 'pr/12639' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
#	crates/horizon-items/src/item_data/trim.rs
2026-11-08 21:29:59 +01:00
Horizon agent d9eda46d06 Merge remote-tracking branch 'pr/12638' into stations
# Conflicts:
#	crates/horizon-client/src/stations.rs
2026-11-08 21:29:34 +01:00
a 1f149381f8 Merge remote-tracking branch 'pr/12681' into stations 2026-11-08 21:28:07 +01:00
a 02bbcbd466 Merge remote-tracking branch 'pr/12679' into stations 2026-11-08 21:28:07 +01:00
a e0d14e4edf Merge remote-tracking branch 'pr/12677' into stations 2026-11-08 21:28:07 +01:00
Horizon agent b4b6eefcfb Merge remote-tracking branch 'origin/main' into fix-12641
# Conflicts:
#	crates/horizon-client/src/game/render_tick.rs
2026-11-08 21:27:17 +01:00
Horizon agentandClaude Opus 5.5 6f31c8d6d1 Merge origin/main: ambient particles of pack biomes come from main (fb4805072f)
main landed the pack biomes' ambient particles meanwhile; this branch keeps
their music and ambient sounds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:27:06 +01:00
Horizon agent 545cb15f18 Merge remote-tracking branch 'origin/main' into fix-12617
# Conflicts:
#	crates/horizon-world/src/worldgen/generator_decoration.rs
2026-11-08 21:26:37 +01:00
Horizon agent e2c9f5dd08 Merge remote-tracking branch 'origin/main' into fix-12563
# Conflicts:
#	crates/horizon-client/src/game/boats.rs
2026-11-08 21:26:31 +01:00
Horizon agentandClaude Opus 5.5 389a495f09 saved_templates test: insert into the lazy template store
main added saved_templates.rs, whose test wrote resources.templates directly;
the templates are now a TemplateStore, so the test fills its loaded map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:24:20 +01:00
Horizon agent 7f2fdba6ba Merge remote-tracking branch 'origin/main' into fix-12618 2026-11-08 21:23:15 +01:00
Horizon agent 7f63f42e27 Merge remote-tracking branch 'origin/main' into fix-12613
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-08 21:22:51 +01:00
Horizon agentandClaude Opus 5.5 91879dc898 Dragon fireball: an owner hit stops it at the hit location
AbstractHurtingProjectile.tick moves the fireball to the hit location for every
hit that is not a MISS; DragonFireball.onHit then does nothing for its owner, so
it waits there with its motion instead of taking the free step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:22:04 +01:00
Horizon agentandClaude Opus 5.5 4dd5d0ce71 set_custom_data: the tag and the component may be SNBT text (TagParser.LENIENT_CODEC)
Packs write "tag": "{a:1b}"; the function ignored it, and an existing
custom_data component written as text was replaced instead of merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:21:12 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b0bb5d2a9e Dispensed egg group: despawn far away, and keep 0b through a reload
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:20:30 +01:00
Horizon agentandClaude Opus 5.5 860355f55e recipe_book_variants_tests: drop a doc paragraph about another test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:18:24 +01:00
J1zeeeandNemotron 3233aaabe9 Fix ender chest and end portal block entities missing from /setblock
ender_chest and end_portal are EntityBlocks in vanilla 26.3 but Horizon's
BlockEntity::for_block had no handler for them: they fell through to the
container_size() fallback, which returns None for both, so no block entity
was created and /data get block answered "not a block entity".

Add explicit handlers: ender_chest -> Chest with 27 slots (matching
container_size), end_portal -> new EndPortal variant with no data. Register
EndPortal in fits/id/contents/slots/slots_mut so it behaves like the other
zero-slot entities (DaylightDetector, Bell).

Also fix two traps found in review:
- close_ender_chest removed the block entity entirely; it now copies the
  items back into self.ender_items and leaves the entity in place, so a
  /data get block after opening/closing still reports the entity.
- Containers::view excluded Beehive/Campfire/Brushable from being treated
  as containers (so droppers don't insert into them); EndPortal needed the
  same exclusion.

End-to-end verified: /setblock ender_chest/end_portal then /data get block
returns the correct id, and accessing the ender chest's container leaves
its block entity intact.

Co-Authored-By: Nemotron <[email protected]>
2026-11-08 23:18:05 +03:00
Horizon agentandClaude Opus 5.5 8d8ef94673 RunAroundLikeCrazy: canContinueToUse has no isMobControlled check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:14:55 +01:00
Mik00000 f9374b154b perf(worldgen): expand structure locate rings across candidate sets with vanilla stop order 2026-11-08 22:08:14 +02:00
Florian ErnstandClaude Opus 5.5 009baf97ad Test: a hurt cooldown survives an edit and is not saved
Checkpoint before a fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:08:01 +01:00
MrCloudy2andClaude Opus 5.5 e7cd3c4c6c Put a wind charge's box 0.15 below its position
AbstractWindCharge.makeBoundingBox builds the box from y - 0.15F. Pistons,
cobwebs, water, other projectiles' hits and the punch pick box all use it.
Harness: tools/test_wind_charge_box_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:05:16 +01:00
DangerousbrosandClaude Opus 5.5 3fd4fed964 Far terrain: tiles the view leaves are kept, and taken back on a return
A replan dropped every sampled tile outside the new plan, so a teleport back,
a trip out and home again or the far distance lowered and raised sampled the
whole view again, and asked again for all its chunks' detail. Tiles leaving
the plan are now kept (lod::kept) with their detail masks, for 10 minutes and
up to 512 MB, the finest dropped first; a return takes them back, the
coarsest level first and the next once the meshes are in, so the view fills
coarse to fine as sampling does. A refresh or a newly kept chunk forgets the
kept tiles it makes stale, and tiles of chunk-wide cells over the full-detail
columns are sampled again as before. status reports lod.kept_tiles and
lod.kept_mb.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:01:55 +00:00
Florian ErnstandClaude Opus 5.5 539933e0a1 A mob hurt by a mob saves last_hurt_by_mob
LivingEntity.addAdditionalSaveData writes last_hurt_by_mob and
ticks_since_last_hurt_by_mob while the mob that hurt it is recorded, and
baseTick drops that record once the attacker is dead or 100 ticks have
passed. Horizon kept the record for its AI but never saved it, and a
value read from loaded data was written back as it was, for ever.

A mob's saved data, and the view advancement criteria test, now write
both keys from the live record while the attacker is a living mob in
the level and at most 100 ticks have passed, and drop both otherwise.
This is a save-time projection: the record itself, its end for the AI,
no_anger, a player attacker and reading the keys back are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:56:38 +01:00
Nikita Maximilian Grimm bfd5c69005 Merge commit '65d988b03' into hdr-p0-base 2026-11-08 20:55:24 +01:00
Nikita Maximilian Grimm 54cae6c3ef Merge commit '2d4a33879' into hdr-p0-base 2026-11-08 20:55:22 +01:00
RLC248andCodex cf23b0da77 Pass enchantment registry in tooltip tests
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:52:10 +01:00
iseaprojectsandopencode 0ce693d743 Saving: pack a one-value section from its stored words
`PalettedContainer::pack` unpacked all 4,096 cells of a section into a
scratch `Vec<u32>` to find its distinct values, then re-packed them. A
section that holds one value (air, stone, anything filled by one block)
saves as one palette entry and no indices, and that is most sections of a
column, so the unpack was most of their save cost: 3.85 us to pack one.

`uniform()` answers from the stored words instead and returns the same
`Packed` at 0.03 us; a list or global palette compares its words with the
repeated pattern of the first cell, so nothing is allocated and a mixed
section pays one scan that stops at the first mismatching word (measured
at no cost). The saved bytes are unchanged: one distinct value was already
`bits: 0` with no data. One saved column goes from 802 to 704 us.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 15:51:13 -04:00
Nikita Maximilian GrimmandClaude Opus 5.5 e5bbad5f2f A summoned or loaded eye of ender flies, shatters after 80 ticks and saves its Item
An eye_of_ender from /summon or a saved chunk was kept as an inert
compound: it never moved, never shattered, and saved no Item. Vanilla's
EyeOfEnder.tick moves it by its motion (it has no target without
signalTo), and on tick 81 it shatters without dropping its item; it
saves only its Item (one of it, an eye by default), not its life.

Fixes #12807

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:50:50 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 c052c5523a Scripted runs: a person's input, focus and pointer stay out of them
A person at the computer could spoil a screenshot sequence, a
HORIZON_TICK_SCRIPT scenario or a record-replay with a stray key or mouse
move, and an agent's window took their pointer.

- While one of those runs, the window's keyboard, mouse, wheel, cursor and
  IME input of origin user, raw mouse motion, mods' raw keys and the
  hardware pad don't reach the game and aren't recorded. The run's own
  input (the socket, the replay) still plays; closing the window still
  works.
- A person clicking into the window or away from it during a run takes
  effect when the run ends: a replay's look and clicks depend on the focus
  and the grab.
- During a run the pointer grab stays inside the game; when it ends, the
  system pointer follows the grab the run left.
- A visible game an agent starts opens without the focus, and its grab
  stays internal (as --offscreen's does) until a person clicks into it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:50:37 +01:00
baytyoneandZCode 5d5e00f783 Hub: the per-player tracking sweep runs shared out
Each player's range check, removals and RemoveEntities packet are its own slice of the level's entities, so the sweep of the players of a level now runs through horizon_threads::par_chunks_mut (#8158): what they start tracking meets the pairs after, in the players' order, and every player's packets keep their order, as vanilla's do. HORIZON_SERIAL_TICK=all keeps the sweep on the server thread, the serial reference the parallel phase is checked against.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-09 00:49:02 +05:00
Florian ErnstandClaude Opus 5.5 f7e6433729 Test: a mob saves the mob that hurt it for 100 ticks
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:48:11 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2d4a338796 HDR float frame: screenshots stay as drawn with HDR Lighting off
With HDR Lighting off Horizon lifts nothing, yet the SDR screenshot's
shoulder still rolled 0.6..1 of paper white down, so white came out at
0.8 in linear light (230 of 255) against the HDR-off screenshot's 255. The shoulder is now a
pipeline constant, off while HDR Lighting is off: the world is clipped at
white as SDR draws it, and the HUD composite gives the HUD's target back.

Refs #12628

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:47:43 +01:00
DetimesandClaude Opus 5.5 48ec2657df Mod API: inventory_changed hook and typed inventory helpers
inventory_changed is heard once per changed kind per tick; the *_typed
helpers return NativeInventoryError; Cursor and Crafting kinds (live only);
create_player_file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:46:39 +00:00
Arthur BlackandClaude Opus 5.5 a05497bbcd Touch aim helpers for on-screen controls
- Add touch::aim: camera turn from a drag, tap ray through the view, hotbar slot under a finger
- Add which held items act on a short tap and which aim with the crosshair
- Move the attack gauge to the hotbar when the touch scheme has no crosshair
- Add look sensitivity (overall, horizontal, vertical) to the touch settings, sanitized
- Adapted from Starling (MIT, mostly by Vasysik); credited in THIRD_PARTY_NOTICES.md

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-08 11:44:14 -08:00
Nikita Maximilian GrimmandGPT 6.1 Sol 40d0364acc Shader packs: follow picked HDR frames until refused
Use the picked manifest's frame before a load outcome is known, preserving
one upfront renderer replacement and one compile on a successful pick.
Follow the resolved selection and its recorded outcome so a refusal,
validated API change, missing path or disabled owner can update the frame.
Keep rejected fingerprints across renderer resets and retry only changed
files. Frame attempts stay bounded by key changes; pack inputs are unchanged.

Known limits: existing pick/drop and hot-reload transition-frame ordering;
invalid edits followed by a later rebuild fall back to no pack until fixed;
split guests and shaders-format pending lifetime beyond this key change.
No request replay or transactional publication. Step 5, API 0.2 HDR output
and linear blending remain out of scope.

Closes #12799

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 20:42:36 +01:00
RINNECODERandClaude Opus 5.5 ce99d8445d port-pack: carried mod features get 26.3's shape
A mod's configured features kept their 1.21.1 `config` wrapper: the carry
only rewrote block states, while the `config` unwrapping, patch and
placement rewrites lived in the world-pack upgrade. 26.3 skips a feature
that keeps `config`, so no carried mod ore (Occultism silver, Iron's
mithril, Powah uraninite) ever generated. The carry now runs the
world-pack upgrade's feature and placed-feature rewrites too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:40:23 -06:00
itarqos5andClaude Sonnet 5.5 b5723fd33d Terrain: take RGSS's four taps at the fractional mip level
rotated_grid took each of its four taps at the two mip levels around the
exact level and mixed them by fract(exact) in the shader: eight texture
instructions. The sprite sampler's mip filter is linear, so one tap at the
fractional level is that mix; it now takes four. GPU time per frame on the
benchmark scenes (RX 580, 1080p, render distance 16, same binary): forest
-17%, ocean -15%, village -17%, mountain -14%. The picture differs only by
the GPU's limited-precision level blend.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 19:39:14 +00:00
Vinícius MoreiraandClaude Opus 5.5 09ccb39c4f Polytone: a custom particle's light_level is a floor under its block light
`light_level` is the block light a particle gives itself (wiki "Custom Particle Types"): the
particle is drawn with at least that block light, as the GPU particles already were. Measured
beside the real mod at night: a lit, light_level 8 particle draws warm and bright where Horizon drew
it with the dark world light, as a pack's light shafts under a canopy.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:38:28 -03:00
Mayonesu0andClaude Sonnet 5.5 5273939a30 Apply held-item modifiers when the stack changes, not only the item
Player.tick resets the attack strength only for a different item, but
LivingEntity.detectEquipmentUpdates re-applies the equipment's attribute
modifiers whenever the stack differs (!ItemStack.matches). tick_hand gave
the player its modifiers only on the item-change reset, so swapping to the
same tool with Efficiency (or enchanting it in place) never set
minecraft:mining_efficiency and digging stayed at the plain tool speed.

Fixes #12384

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 20:37:52 +01:00
DetimesandClaude Opus 5.5 68f4708d84 Mod API: per-usage command permissions, vanilla roots, permission groups, server helpers
Each usage of a mod's command keeps its own permission node; a usage can
replace a vanilla root (NativeCommand::replace_vanilla); a command name may
start with slashes. Adds the permission_groups hook and primary_group,
whitelist_add/remove, log, set_game_mode and the server_stopped hook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:34:46 +00:00
DetimesandClaude Opus 5.5 aa3819d4a5 Launcher: a bundle's api package may depend on its own crates by path
The staged api package can name the bundle's [[crate]]s by relative path;
they are rewritten to their staged copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:51 +00:00
DetimesandClaude Opus 5.5 3316c45f44 Mod API: chat hooks and helpers
player_chat_v2 (with the sender's permission level), chat_decorate (the
shown line sent as unsigned content beside the signed body), chat_as,
player_join_message, player_leave_message and the join/leave helpers
(has_played_before, motd, broadcast_quiet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:49 +00:00
DetimesandClaude Opus 5.5 36896744bf Mod API: block change hooks
block_grow (crops, stems, cactus, cane, bamboo, spreads), tnt_primed_v2,
hanging_break_v2 and entity_change_block_v3 can cancel the change; the
explosion_detonate hook names the entity that exploded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:46 +00:00
DetimesandClaude Opus 5.5 dc22fb4396 Mod API: player effect icons, attribute modifiers, more player state
effects_v2/set_effects_v2 carry the icon flag, attribute_modifiers lists the
transient modifiers, NativeServerPlayer gains using_item, blocking,
sprinting and gliding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:32:45 +00:00
MSedgeMC f7a1f50728 Read the lock from the player's main hand
LockCode.canUnlock uses Player.getMainHandItem, whichever hand the click came
from, so a key in the off hand does not unlock a container. The lock check
read the acting hand; it now reads the selected main-hand slot, and the test
covers an off-hand key being refused.
2026-11-08 19:30:06 +00:00
RLC248andCodex f4aa6beb4a Search creative items by localized enchantment tooltips
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:29:25 +01:00
RLC248andCodex 8b65ee2372 Bounce items from slime support at honey edges
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:28:19 +01:00
Evrim CicekandClaude Opus 5.5 f523a60066 Boats, rafts and minecarts save and load OnGround and fall_distance
Entity.load reads OnGround and fall_distance and saveWithoutId writes the
live values; Horizon wrote false and 0 and read neither. Boats now count
their fall as AbstractBoat.checkFallDamage does (on a host's tick and a
LAN guest's move), reset it in water, and minecarts no longer reset it on
a move up or none, which vanilla's Entity.checkFallDamage never does.

Fixes #12517

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:28:01 +03:00
DUzzL 27d7fdd859 Sweep a minecart's rail step for block-inside effects on slopes 2026-11-08 20:25:25 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 34b0265744 /fill loads the block's NBT into the block entities it places
FillCommand.fillBlocks places each block with BlockInput.place, which
loads the tag into the block entity there and counts a block whose tag
changed its block entity even when the state was already in place. A
hollow fill's core is plain air. Horizon placed the state and dropped
the tag, so every block entity /fill made kept its defaults.

Fixes #12814

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:22:46 +01:00
MSedgeMC abcbf3739d Breaking a half of an open double chest closes the menu
ChestBlock's menu provider builds a CompoundContainer over both halves, whose
stillValid asks both block entities; breaking either half ends the menu.
Horizon closed the menu only when the clicked half was removed, so the other
half breaking left a shorter menu over the remaining half.
2026-11-08 19:21:02 +00:00
MSedgeMC 45bcdca495 A pack's #minecraft:lectern_books item goes onto the lectern
LecternBlock.useItemOn accepts any stack in ItemTags.LECTERN_BOOKS; Horizon
matched the two vanilla item ids directly, so a data pack adding its own book
to the tag could not put it on a lectern.
2026-11-08 19:20:48 +00:00
MSedgeMC 4e724e319b A lectern's comparator output counts only a book as a book
LecternBlockEntity.getRedstoneSignal adds one only when hasBook(): the stack
carries a written_book_content or writable_book_content component. Horizon
added one for any stack in the Book slot, so a non-book item a command left
in a lectern read 15 at its last page where vanilla reads 14.
2026-11-08 19:20:13 +00:00
MSedgeMC 6b2fadfcd5 A locked container refuses the local player's open
BaseContainerBlockEntity.createMenu asks canOpen, whose LockCode takes the
player's main hand or a spectator; a refusal sends the container.isLocked
action bar naming the provider and one chest-locked sound. Horizon kept a
container's lock on disk and checked it for LAN guests, but the integrated
player's open_menu went straight to the loot roll and the menu, so a locked
chest opened for anyone.

The local open funnel now checks every block entity of the provider - both
halves of a double chest - before it opens, with vanilla's message and sound.
2026-11-08 19:20:04 +00:00
Florian ErnstandClaude Opus 5.5 96f1fb36a2 Format hoglin_hurt's signature
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:19:17 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2e258065d7 Ominous spawner group: read only the motion the issue covers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:18:07 +01:00
martipopsandClaude Opus 5.5 0256b28b72 Merge upstream/main into ui/shell-tabs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:57 -05:00
martipopsandClaude Opus 5.5 8200d52bdd Shaders: screens that don't fit the page list collapse, none left out
Fixes #12801. In a short view (427x240 GUI px: an 854x480 window at GUI
scale 2, or 720p at its automatic scale) Shaders' page list has room for
four of a pack's screens, and the rest were dropped with nothing to say
so. Now, when they don't all fit, they collapse into one `‹ Name ›` row
with pips (`tabs::collapsed`). Left of the name opens the screen before,
right of it the one after; while searching, the nearest ones with
matches. Each part is a `Tab` target, so RB and LB still step through
every screen and back to the pack list. When the screens fit, the list
is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:53 -05:00
martipopsandClaude Opus 5.5 ce61a0e1de HUD kit: a collapsed page strip
`hud_kit::tabs::collapsed` lays a page list out in one row: the selected
name between `‹ ›` arrows, cut to fit, with a pip per page under it. An
arrow with no page that way is dimmed, and when the pips don't fit across
the row they are a window round the selected one. `draw` paints it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:52 -05:00
iseaprojectsandopencode 9791592034 Wear the player's armor before the hit reads its armor value
`LivingEntity.getDamageAfterArmorAbsorb` calls `hurtArmor` before it reads
the armor and toughness attributes, so a piece that breaks on a hit takes no
part in the reduction of that same hit. #11696 showed a chestplate at 239 of
240: vanilla saves Health 16.0, Horizon 16.64.

Every source that reaches `hurt_player_from` (mob attacks, projectiles,
`/damage`, fireworks, the wither, pufferfish) and `hurt_player_by` (thorns,
the dragon, lightning, ender pearls, the world border) now takes the
synchronous equipment path that `damages_helmet` already used: it wears the
armor between the hit's selection and its armor read. Explosions, potions and
the library's environmental ticks (`motion.rs`, `landing.rs`, `hunger.rs`)
still queue the wear for the next drain and are left for a follow-up.

The helmet-phase advancement keeps its reported amount, so no hit's
`ENTITY_HURT_PLAYER` value changes. `tests/fixtures/helmet_damage.json` gains
a `mob_attack` hit that breaks a boot beside one that leaves it worn; the
helmet-damage harness compares both against real vanilla.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 15:11:27 -04:00
fbikatandClaude Opus 5.5 afd3e48ccd Snow golems save and load Pumpkin
SnowGolem writes Pumpkin and reads it with getBooleanOr("Pumpkin", true).
Horizon had no snow golem case in entity_nbt, so a golem sheared in game
wore its pumpkin again after a reload and {Pumpkin:0b} was ignored.

Fixes #12778.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:08:34 +00:00
RLC248andCodex 56dbf356a0 Canonicalize boolean flags after shared raw save overlays
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 20:08:25 +01:00
Nikita Maximilian Grimm 478e7b45bd Merge remote-tracking branch 'origin/main' into entity-ticking-gate 2026-11-08 20:07:44 +01:00
Florian ErnstandClaude Opus 5.5 db32617271 A hoglin's retaliation broadcast keeps a nearer target
HoglinAi.maybeRetaliate broadcasts the attacker to the visible adult
hoglins, and each takes it only when it is not pacified and its current
attack target is not strictly nearer (setAttackTargetIfCloserThanCurrent).
Horizon's hoglin alerted from its hurt-by goal instead: every hoglin in
range without a target got the attacker, overwritten after the tick. A
hoglin hit in the same tick as a neighbour hit its own attacker once,
then took the neighbour's attacker behind the glass and never hit again.

The hoglin's hurt-by goal no longer alerts. hoglin_hurt broadcasts a
retaliation to the sensed visible adult hoglins, and the event applies
the pacified guard and the nearest-target rule. Piglin alerts and the
generic SetTarget are unchanged. With a player on either side the
current target stays, since each mob's player is its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 20:02:58 +01:00
Nikita Maximilian Grimm ad7effc349 Merge remote-tracking branch 'origin/main' into fix-dispensed-egg-persistence 2026-11-08 20:01:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 0299f2fe2f Parity: the vanilla tick-script client never takes or moves the pointer
Off Linux's virtual screen, the vanilla 26.3 client that VanillaTickScript
drives sits on the person's desktop, and vanilla's MouseHandler took the
person's pointer: the join grabbed and hid it, a mouse move turned the
player, and every screen the script opened warped the pointer to the
window's middle (releaseMouse has no focus check).

- SDL drops the keyboard, text and mouse events once the script is
  installed, and releases what was held; the script's lines never went
  through them.
- MouseHandler is replaced by one whose grab is the flag alone, so screens
  open and close without touching the system pointer.
- The harness opens the window without the focus
  (SDL_WINDOW_ACTIVATE_WHEN_SHOWN=0). Window.focused starts true and only
  SDL focus events change it, so the window asks SDL for its real focus
  while resources load: the join no longer warps the pointer.
- Window.onResize re-grabs whenever the flag is set: SDL drops the window's
  resize events (pixel-size events still resize the frame).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:57:10 +01:00
DUzzL a145d469c6 mobs: keep DeathTime on load, tick the impulse grace time, pick the armadillo scute time 2026-11-08 19:56:48 +01:00
baytyoneandZCode 0ec0dbb2f4 horizon-threads: par_chunks_mut, the shared groundwork of #8158
Chunks of a slice run on helper threads under a scope, the calling thread working too; HORIZON_SERIAL_TICK=all keeps every chunk on the caller as the serial reference. Not yet used: the hub's per-player pass and the mob loop's speculate-and-commit, the pieces of #8158 it is for, follow separately.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-08 23:55:16 +05:00
Nikita Maximilian GrimmandClaude Opus 5.5 c5e3ef940b Freeze thrown potions, orbs, rockets and clouds outside entity-ticking chunks
ServerLevel.tick ticks an entity only in an entity-ticking chunk. Thrown
potions, lingering-potion and plain area effect clouds, firework rockets
and experience orbs ticked anywhere loaded, so they kept moving and
ageing while their chunk unloaded or past the simulation distance.

Fixes #12769

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:55:11 +01:00
Nikita Maximilian Grimm b83ab5e01d Merge remote-tracking branch 'origin/main' into fix-ominous-spawner-motion 2026-11-08 19:54:44 +01:00
Florian ErnstandClaude Opus 5.5 e8a1e5b997 Test: a hoglin keeps attacking its attacker beside another hit hoglin
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:52:02 +01:00
RLC248andCodex acd32b8d31 Preserve projectile entity fields and portal cooldowns
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 19:51:48 +01:00
DUzzL 8695ecb3b4 Hurt the ender dragon with firework explosions 2026-11-08 19:48:49 +01:00
RLC248andCodex 08ed0dcb83 Save typed defaults for display and interaction entities
Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 19:47:21 +01:00
Mayonesu0andClaude Sonnet 5.5 6aa575f7d1 Server's player forgets a fall in a cobweb or sweet berry bush before landing
A server's player copy skipped makeStuckInBlock's fall reset (its client
moves it), and ran its landing check before the block effects, so a
player falling into a cobweb was hurt on landing. handleMovePlayer moves
the player first (checkInsideBlocks) and only then runs doCheckFallDamage:
add an early pass that applies just the fall/impulse reset of a cobweb or
sweet berry bush the moves touched.

Fixes #12382

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 19:37:30 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 65d988b030 Shader API 0.2: refuse constructing Frame and the stage inputs
0.2 becomes the only line served at the HDR note's step 5, so the
additions we expect (which pixels are light sources for post, a
full-screen peak, an SDR output's display values) must not break packs.
A pack that builds `PostInput(...)` positionally would stop compiling
when a field is added.

- `horizon-shader check` refuses, for 0.2 packs, a `Compose` of `Frame`,
  any stage input (every `StageKind`'s input and `DisplacementInput`) or
  the post helpers' `SurfaceData` in the pack's files, found in naga's IR
  (function bodies and module constants) and located by span. Reading
  fields and passing a received value along are unchanged; `Surface`
  stays constructible.
- 0.1 is left unchanged: it is retired at step 5 and its structs won't
  grow.
- The 0.2 reference: `[tonemap]` is a pure mapping to the output its
  `frame` describes, once per pixel of each output (Horizon may add
  outputs such as an SDR screenshot); alpha is coverage only, never
  light; `Surface.color` is non-negative unbounded linear light; `Frame`,
  the inputs and `SurfaceData` may gain fields. Changelog entry in
  docs/api/shaders.md.

Closes #12673

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:34:02 +01:00
iseaprojectsandopencode a4bf12b2da Italian: the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:32:53 -04:00
iseaprojectsandopencode 42635ff154 Italian: the launcher's mods, packs and porting
What the launcher says about a mod, its jar and what of it came over, the
resource, shader and data packs, and the Modrinth entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:32:52 -04:00
iseaprojectsandopencode 79a2d9d554 Italian: the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:32:52 -04:00
iseaprojectsandopencode 0d45709554 German: the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:30:34 -04:00
iseaprojectsandopencode 936a48fe4b German: the launcher's mods, packs and porting
What the launcher says about a mod, its jar and what of it came over, the
resource, shader and data packs, and the Modrinth entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:30:33 -04:00
iseaprojectsandopencode afc1d6a0e0 German: the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:30:33 -04:00
Nikita Maximilian Grimm 226c1ceeef Merge remote-tracking branch 'origin/main' into fix-shulker-empty-items 2026-11-08 19:28:16 +01:00
iseaprojectsandopencode 21a5d735ea Portuguese (Brazil): the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:28:12 -04:00
iseaprojectsandopencode 0e525f3952 Portuguese (Brazil): the launcher's mods, packs and porting
What the launcher says about a mod, its jar and what of it came over, the
resource, shader and data packs, and the Modrinth entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:28:12 -04:00
iseaprojectsandopencode e5f945dc55 Portuguese (Brazil): the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:28:12 -04:00
Nikita Maximilian GrimmandClaude Opus 5.5 00299919d9 HDR: keep the setting's description, so its translations stay
Every language file keys its translation of the HDR setting's
description by the English text. Changing that text left them stale,
and the language test refuses a key en_us.json no longer has. The old
description is still true with Auto, so keep it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:27:03 +01:00
iseaprojectsandopencode 5661b09d71 French: the rest of the launcher
Accounts and installs, versions and builds, the updater, the appearance
settings, the AI agent and data collection pages, and the single words the
screens reuse.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:25:53 -04:00
iseaprojectsandopencode 40c12c20e0 French: the launcher's remote servers over SSH
The screen that reaches a server on another computer: what to type, why ssh
is missing, why {host} refused the key, and the horizon-server messages that
come back over the connection.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:25:53 -04:00
Florian ErnstandClaude Opus 5.5 a7c004b6ac An experience orb is a score holder for execute store
ScoreHolderArgument resolves @s to the source's entity, and every entity
is a score holder named by its UUID. Horizon gave orbs no holder name
('no UUID here'), so execute as <orb> store ... score @s found no entity
and stored nothing, and a score set by the orb's UUID was reported under
the UUID text instead of the orb's name.

An orb's holder name is now its registered UUID, the identity its saved
UUID reads already use. Projectiles are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:23:40 +01:00
Oloion 4d6f0a1c4a Fix tooltip box pixel alignment and Russian modifier formatting
- Snap tooltip coordinates to whole pixels to prevent border rasterization clipping
- Clamp tooltip x-position to stay within viewport bounds for wide Cyrillic strings
- Route creative tab tooltips through draw_tooltip_lines for consistent rendering
- Add regression tests for Russian modifier placeholder formatting and tooltip geometry

Fixes #12600
2026-11-08 21:23:37 +03:00
MrCloudy2andClaude Opus 5.5 bd13d0f39c A projectile in a cobweb's reach is stuck, and a piston's next push stops it
Entity.makeStuckInBlock: a cobweb or powder snow an entity's box meets sets
its stuckSpeedMultiplier, and its next Entity.move (a piston's push) sets
its movement to zero. Projectiles never took either: their own tick
(AbstractHurtingProjectile/ThrowableProjectile.tick's
applyEffectsFromBlocks) and a piston's push (moveEntityByPiston's
applyEffectsFromBlocks) left a cobweb's projectile flying. They now set
Projectile::stuck_speed, and a block's push spends it.

Harness: test_projectile_piston_cobweb_parity (dedicated servers, frozen
and stepped: a wind charge a piston lifts by a cobweb stops, one in open
air keeps its speed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:20:14 +01:00
iseaprojectsandopencode 468ab204c7 Italian: 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:19:51 -04:00
iseaprojectsandopencode aa208521eb Italian: 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:19:51 -04:00
iseaprojectsandopencode 92c1eb1b76 Italian: emotes, moderation, tempban and page entries
The emote wheel, `/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:19:51 -04:00
iseaprojectsandopencode 408d193d01 Italian: the graphics help texts, Realms, Extras, splitscreen 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 and the pack browser.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:51 -04:00
iseaprojectsandopencode 9f903d2b3f Italian: 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:19:50 -04:00
iseaprojectsandopencode e0615ba966 Italian: the Extras screen, the new options and the controller guide
The Extras entries, the options added since the file was last touched, and all
48 labels of the on-screen controller guide.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:19:50 -04:00
DUzzL ad183565d8 Predict the empty-bucket pickup of a sulfur cube on a client 2026-11-08 19:19:47 +01:00
Florian ErnstandClaude Opus 5.5 70c8fe35a2 Test: an experience orb stores into its own score through execute store
Checkpoint before the fix: the Game test and the strict Java harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:17:18 +01:00
iseaprojectsandopencode 4bcfbe6eb3 German: controller settings and recipe transfer
The controller page and the lines it prints while you rebind, the pad notice and
the recipe-transfer messages.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode 28722235de German: 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:16:58 -04:00
iseaprojectsandopencode 3f71bd7deb German: packs, options and page entries
The pack browser, the remaining options and three page entries.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode 3b3ed4b217 German: splitscreen
The splitscreen setup and prompts, which German was missing whole: monitor and
window picking, the per-player profiles, joining and leaving, and the agent and
repro notices that sit between them.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode a0747ddd25 German: pages, the mod browser, the graphics help texts and Realms
The page titles and `...` entries, the mod browser, the flat graphics keys whose
English sentence is also the key, the Realms sign-in line and the Extras
entries that belong with them.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:58 -04:00
iseaprojectsandopencode a3770664c2 German: the Extras screen, the new options and the controller guide
The Extras entries, the options added since the file was last touched, and all
48 labels of the on-screen controller guide.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 14:16:57 -04:00
Nikita Maximilian GrimmandClaude Opus 5.5 f03f847d4f Shulker boxes: an empty roll still restores the box's loot table
A box whose loot table rolled nothing now saves no Items, which
RolledLoot::restore must take as the roll, or the unopened box would
lose its LootTable. The group gains a box with LootTable minecraft:empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:16:13 +01:00
DUzzL 408e4fda1c Slow minecarts in cobwebs like Entity.makeStuckInBlock 2026-11-08 19:05:10 +01:00
baytyoneandZCode 7b92e8a8aa Map searches read each saved chunk's record once, like StructureCheck
A chest with exploration maps resolves each one through findNearestMapStructure with skip_existing_chunks, whose skip check read the saved chunk's record (a region read and an NBT parse) for every candidate of every search, and allocated a String per candidate for the reference set. The cost grew with every map resolved and every chunk saved: on the official SMP, uses grew from 0.27 s to 1.23 s inside use_block (#10255). The session now keeps each saved chunk's answer, as vanilla's StructureCheck cache does, and the reference set is checked by name without allocating. The reference file's format is unchanged.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-08 23:04:23 +05:00
RLC248andCodex bcd3d93200 Keep tracking particles anchored to moving entities
Resolve live position and posed bounds for each emission tick. Retain a removed target at its final body until expiry, and detach before entity-ID reuse.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 19:01:45 +01:00
Vinícius MoreiraandClaude Opus 5.5 2ece11744d Polytone: a particle's emitters see the live particles for limit and exclusion_radius
While the live particles tick they are out of the engine's list, and what their
particle_emitters spawned was made right away: its type's limit and
exclusion_radius counted only the particles spawned so far this tick. A pack's
emitter particle spawning a light ray every tick at one spot (exclusion_radius 1)
stacked a ray per tick where Java keeps one. These spawns now wait until the live
particles are back, still within the tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:52:10 -03:00
Vinícius MoreiraandClaude Opus 5.5 12b51783dd Polytone: additive custom particles take the fog
A custom particle with render_type additive_translucent takes the fog over its
colour in Java, as vanilla's particle shader gives it. Horizon drew it through the
additive entity pass, which does not, so the freepack's light rays came out bright
yellow where Java's are tinted by the pack's dense fog. Their quads now carry their
own overlay, which the additive pass fogs. Measured in Java on a single light ray
against a black wall: shape, tone and summed light now match within the variation
between shots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:52:10 -03:00
Vinícius MoreiraandClaude Opus 5.5 d9a8fb0667 Polytone: a custom particle's movement_aligned quad turns its texture's top against its motion
Measured in Java with the freepack's light rays and a still copy of them: a custom
particle drawn movement_aligned has its texture's top against its velocity, so a ray
rising toward the noon sun hangs its glow below the leaves it starts at. Horizon
turned it along the velocity, and the rays shone up through the canopy instead of
down under it. A GPU particle keeps its texture's top along its motion, as measured
with the freepack's rain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:51:56 -03:00
Vinícius MoreiraandClaude Opus 5.5 cc1825d017 Polytone: kill_when_still takes a particle a collision stopped
A custom particle with physics and kill_when_still (on by default) was removed on any
tick it did not move, so one that rests on purpose died on its first tick. Measured
in Java with a copy of the freepack's light ray chain (an emitter with physics that
rests beside a leaf and emits every tick of its 20): Java's emitter lives out its
life, Horizon's emitted once or twice, about a fifteenth of the particles. A particle
now goes when a collision stopped it, as a landed one is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:51:56 -03:00
Vinícius MoreiraandClaude Opus 5.5 cadfdbcfb1 Polytone: a custom particle's exclusion_radius
exclusion_radius (wiki "Custom Particle Types") keeps a custom particle from
spawning within that distance of a live one of its type. Horizon read it and spawned
anyway. The freepack's rain splash, which replaces the vanilla one, sets it to 1: its
ripples covered the ground about twice as densely as in Java.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:51:29 -03:00
iseaprojectsandopencode bb6e95d5fe lang_coverage: report the keys each language is missing
`lang.rs`'s test checks a bundled file for stale keys, repeated keys, empty
values and changed placeholders, and deliberately not for missing ones: a
language is allowed to lag, and `Language::load` falls back to `en_us`. So
nothing reports the size of the lag, and `fr_fr` missing 510 of 1047 keys is
invisible.

`tools/lang_coverage.py` prints, for the game's `assets/horizon/lang`:
missing, not in the reference, empty, repeated key or wrong indentation,
changed `%s`/`%1$s`/`%.2f`/`%%` placeholders (the same rule as `lang.rs`), and
values identical to English; and for `launcher/lang`: values identical to
English and a `{hole}` a value drops or adds. `--summary` is the line a pull
request pastes, `--json` the same as data, `--show-keys` the keys behind each
count, and `--fail-under` exits 1 so a later CI job can gate it.

It reads files only: no `.local/`, no network, nothing written.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-08 13:43:33 -04:00
RLC248andCodex 63db5cdd81 Normalize saved boolean flags for boats, minecarts and cushions
Preserve live gravity and grounded state while encoding retained entity
flags with Entity.saveWithoutId's canonical bytes and optional defaults.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-11-08 18:25:44 +01:00
MSedgeMC 81caf4c301 A grindstone keeps an explicit zero damage apart
ItemStack.matches compares the component patch, and on a non-damageable item
minecraft:damage:0 is a real component (its prototype has none). Horizon read
the number into the damage field and forgot its presence, so two books with
and without it merged.

Test: a book carrying minecraft:damage:0 and a plain one, both enchanted with
sharpness, give no grindstone result; two identical zero-damage books still
merge to two.
2026-11-08 17:21:07 +00:00
MSedgeMC efbd55185e A smithing trim reads an inline pattern holder
SmithingTrimRecipe's pattern is a TrimPattern holder: a registry name or an
inline asset_id/description/decal definition. Horizon parsed only the string
form, so an inline pattern became an empty name and the trim wore no pattern.

Test: a smithing_trim recipe whose pattern is the inline coast definition
gives a diamond chestplate whose trim holds that direct pattern.
2026-11-08 17:20:55 +00:00
MSedgeMC b68899faad A menu reads a removed max stack size as one
ItemStack.getMaxStackSize is getOrDefault(MAX_STACK_SIZE, 1), so a
!minecraft:max_stack_size patch caps the stack at one. Horizon fell back to
the item's own size, letting two stone stack into a single slot.

Test: in an inventory menu, a stone with the component removed and another
on the cursor stay at one each after a pick-up; on main they merge to two.
2026-11-08 17:20:14 +00:00
MSedgeMC 0618cea36e An anvil refuses an item without its enchantments component
AnvilMenu.createResult first asks EnchantmentHelper.canStoreEnchantments,
which is ItemStack.has of ENCHANTMENTS (stored for a book). A removal patch
leaves no component, so vanilla gives no result, not even a rename. Horizon
assumed every 26.3 item has one and renamed the stack.

Test: a diamond sword with !minecraft:enchantments and an enchanted book
with !minecraft:stored_enchantments both give no result at cost 0; the same
items without the removal rename at cost 1.
2026-11-08 17:20:03 +00:00
CocofireHDandQoder 8beaf4b572 Pickup: a mob's take reaches the clients as a take packet
ClientboundTakeItemEntityPacket is what makes a client add the item
pickup particle and play the pickup sound (ClientPacketListener.
handleTakeItemEntity). Only player pickups were broadcast, so when a
mob picked an item up the item simply vanished for every client
watching, with no flight into the mob (#12493).

add_pickup now sends Hub::take_item for a mob collector from the
server, with the taken entity's id and the stack's size. Player takes
keep their existing broadcast in the guest pickup path.

Co-Authored-By: Qoder <[email protected]>
2026-11-08 18:05:21 +01:00
itarqos5andClaude Sonnet 5.5 14a479c392 Particles: the collision pre-check skips sections that cannot matter
cells_without_collision, which every moving particle asks before resolving its movement,
read the block of each inner cell of its swept box and looked at each cell around it, so a
particle moving 0.4 blocks a tick cost about eight times a nearly still one (poof, smoke and
firework sparks about 205-225 ns, flames and end rods about 25). It now visits a column and
a section at a time and skips every section whose palette holds nothing that collides and
nothing that reaches out of its cell; where a section does hold such blocks and none reaches
out, only the inner cells are read. The answer is unchanged.

On the stress_particles mix (isolated benchmark, particles/tick_bench.rs) the engine tick goes
from 3.21 to 1.77 ms. The section test compares the new scan with the old per-cell one on 4000
random boxes, as well as with the full resolution.

tick_particles gets lap spans (tp_*), so the game's own trace shows where a particle tick goes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-08 16:18:46 +00:00
MSedgeMC ded1909d91 An enchanting table reads the stack's enchantable component
EnchantmentMenu's cost and list use ItemStack.get(ENCHANTABLE) and
ItemStack.isEnchantable, so a patch changes the value or removes it. Horizon
read the item's default, so a patched sword offered the default list and a
sword with the component removed still offered enchantments.

Test: a diamond sword patched to value 1 offers costs 5/13/30 with clues
unbreaking 1, sharpness 2 and smite 4; removing the component gives no
offers.
2026-11-08 16:16:41 +00:00
Florian ErnstandClaude Opus 5.5 92a19421d2 A transmute recipe sends one recipe book entry per display
ServerRecipeBook.addRecipes sends a ClientboundRecipeBookAddPacket entry
for each display of a recipe, and TransmuteRecipe.display gives one
shapeless display per material count: map cloning, with 1 to 8 maps, has
eight. The server's book entries skipped every recipe with more than one
display and had no transmute writer, so a learnt transmute recipe never
reached a player's book, although its removal did.

The book now writes a transmute recipe's entries, one per material count
with the recipe's own display ids: the input and that many materials, its
result (the result item, or every item of the input's tag with the
materials added to its count) and the crafting table, then the usual
group, category, placement and flags. A recipe it cannot write in full
gets no entry at all. The ghost recipe still uses only a single-display
recipe, as before.

Part of #12633

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:16:41 +01:00
MSedgeMC e17f52b8c2 A smithing trim uses the addition's trim material component
SmithingTrimRecipe.applyTrim reads PROVIDES_TRIM_MATERIAL from the addition
stack, a named material or an inline definition. Horizon read only the item's
default, so a gold ingot patched to provide copper applied a gold trim.

Test: a coast trim with a gold ingot patched to copper gives a diamond
chestplate trimmed copper/coast.
2026-11-08 16:16:33 +00:00
MSedgeMC dc881c302a A grindstone merge reads the stack's max stack size
GrindstoneMenu.mergeItems refuses a non-damageable pair when
ItemStack.getMaxStackSize is below 2. Horizon read the item's default, so a
pair of enchanted books patched to max_stack_size 1 merged to a count of two.

Test: two books with max_stack_size 1 and the same enchantment give no
result; unpatched books still merge to a count of two.
2026-11-08 16:16:27 +00:00
MSedgeMC 0e17b03481 An anvil repairs with the stack's repairable component
AnvilMenu.createResult asks ItemStack.isValidRepairItem, which reads the
REPAIRABLE component of the input stack: a component patch overrides the
item's default list. Horizon read only the default, so a sword patched to
repair with a stick refused it.

Test: a diamond sword patched to max damage 100, damaged 20 and repairable
with a stick takes one stick and gives damage 0 at cost 1.
2026-11-08 16:16:18 +00:00
Florian ErnstandClaude Opus 5.5 7f4a2fe8a0 Measure a multi-display recipe's recipe book entries (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 17:05:35 +01:00
Stink-OandClaude Opus 5.5 0ed839b8a1 mod API: stamp world_hold (414) and world_warp (415)
`python3 tools/stamp_native_hooks.py --only world_warp world_hold`. The other pending fragments stay pending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:00:32 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol dd3bac8217 HDR float frame: screenshots tone-mapped for SDR
Map the layered float world's light through a hue-preserving SDR shoulder
for ordinary screenshots and world icons. Reconstruct the HUD at its drawn
brightness over the mapped world without changing HDR presentation.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 16:56:16 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e39af976c9 A mob from a dispensed spawn egg is not persistent
SpawnEggItemBehavior.execute spawns the mob with EntitySpawnReason.DISPENSER
and never calls Mob.setPersistenceRequired, so it saves
PersistenceRequired 0b and can despawn. Horizon made it persistent; a
spawn egg used by hand already spawned a non-persistent mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:46:58 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 eca3a71c8c Ominous item spawners keep the Motion they were loaded with
Entity.load reads Motion and Entity.saveWithoutId writes it back; the
spawner never moves, so the given motion stays. Horizon saved
Motion [0, 0, 0] for every spawner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:45:38 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 ef2142e5a6 Shulker boxes: an empty box saves no Items list
ShulkerBoxBlockEntity.saveAdditional calls ContainerHelper.saveAllItems
with alsoWhenEmpty false, so an empty box writes no Items. Horizon wrote
Items: [] like the other containers, which always write the list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:44:28 +01:00
Florian ErnstandClaude Opus 5.5 bf7ec63e8e Dragon fireballs fly as the projectiles they are
DragonFireball is an AbstractHurtingProjectile: it moves and accelerates
as every hurting projectile does, saves and loads with its chunk, and on
impact bursts into dragon's breath, except on its own owner, which it
flies through. Horizon kept the dragon's fireballs in a list of their
own, ticked only in the End with its own motion, and loaded a summoned
or saved one as a static entity that never moved.

Every dragon fireball is now a projectile of its own kind in the shared
projectile engine. The dragon's attack makes one owned by the dragon
(its direction normalized as vanilla does); a summoned or loaded one
reads its motion, power and owner; it is saved, selected, tracked and
sent to clients like the other fireballs, with its dragon breath trail.
Its impact keeps the old list's cloud and burst unchanged, and the End
still ticks the breath clouds once. The old list and its tick are gone.

Fixes #12584

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:24:17 +01:00
MSedgeMC b8e5247b35 fix: set_custom_data merges into the item's custom data
SetCustomDataFunction merges its compound into the item's
minecraft:custom_data component, a nested compound key by key. The loot
engine had no arm, so a datapack table's data changed nothing. The item
layer already reads the component from `other`, so only the loot arm was
missing.
2026-11-08 15:11:59 +00:00
Florian ErnstandClaude Opus 5.5 cd18425b4a Measure the dragon's own fireball constructor and first tick (H127 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 16:09:10 +01:00
Lyle ColeandClaude Opus 5.5 5f94268324 Data-pack biomes: their own ambient particles, music and ambient sounds
The animate tick, the music and the ambient sounds read the built-in
biomes' files only: in a biome a data pack adds or replaces, the game
showed the built-in biome's particles (or none) and played the built-in
biome's or the dimension's music and ambience, where vanilla reads the
pack's biome (Biome.getAttributes, its visual/ambient_particles,
audio/background_music, audio/music_volume and audio/ambient_sounds).

- BiomeInfo keeps a biome's visual/ambient_particles. The animate tick
  uses a pack biome's own list (the tables keep the built-in ones); a
  particle type Horizon lacks still draws, so the others keep their place
  in the random sequence.
- MusicTables::at and AmbientTables::at read a pack biome's file
  (Biomes::pack_file); an attribute it leaves out comes from the
  dimension type, as vanilla's attribute layers do, not from the built-in
  biome it replaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-08 09:02:21 -06:00
MSedgeMC eb83113dbb An untamed horse bolts with any rider, not only a player
`RunAroundLikeCrazyGoal` asks `isVehicle()`: any player or mob on the
horse makes an untamed one bolt (unless a zombie horse is
`isMobControlled()`). Horizon checked only the controlling player, so a
mob-ridden wild horse stayed calm.
2026-11-08 14:53:00 +00:00
Florian ErnstandClaude Opus 5.5 1522c80b8f Measure a summoned dragon fireball's motion (H127 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 15:52:27 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol 9b0a0d56a9 HDR: an Auto setting that follows the system's HDR
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 15:19:41 +01:00
Kacpherek 69193a3635 mob_ai: blazes hovering, rising toward a player above and attacking 2026-11-08 14:43:26 +01:00
Kacpherek fb350ecfc4 A blaze's shot is level event 1018, as vanilla sends it
BlazeAttackGoal fires with levelEvent(1018) at the blaze's block, unless it
is silent: the client plays the shot at volume 2 with its own pitch. Horizon
played the sound itself, at volume 1, drawing its pitch from the blaze's
random, which put every later draw of its AI out of step.
2026-11-08 14:43:26 +01:00
Kacpherek 3b7a7a9ef3 A blaze slows its fall before it moves
Blaze.aiStep multiplies a falling blaze's motion by 0.6 before
super.aiStep(), so before travel adds this tick's gravity. Horizon did it at
the end of the AI step and slowed the new gravity too: a blaze fell at
-0.04704 instead of -0.0784 on its first tick.
2026-11-08 14:43:26 +01:00
Kacpherek b89066f4ac A blaze's burn sound and smoke are its client's, not the server's
Blaze.aiStep plays the burn sound and makes two large smoke particles only
in its isClientSide() branch; the window's game already makes them
(ambient::client_ai_step). The server's copy drew 13 more numbers from the
blaze's random every tick (a nextInt(24) and six nextDoubles), so every
later draw of its AI came out different, and a client following a server
heard the burn twice.
2026-11-08 14:07:26 +01:00
Florian ErnstandClaude Opus 5.5 f9e8af6a94 Save the blending heights an old chunk's new neighbour calculated
When a new chunk generates next to a chunk an older version saved,
BlendingData.getOrUpdateBlendingData calculates the old chunk's border
heights into its own BlendingData, and BlendingData.pack saves them with
the chunk. Horizon calculated them into SavedOldWorld's cache only; the
save kept the old chunk's blending_data as it was loaded, so the heights
were lost.

The Overworld folder now takes the calculated data of a chunk from that
cache when its save is requested, without calculating anything, and keeps
it with the queued record. The encoder replaces blending_data with
BlendingData.pack only when the loaded record has blending_data with the
same section range. The lookup reaches the cache through a weak link, as
the old world holds the folder. Other folders, new chunks and chunks no
calculation reached save as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:58:55 +01:00
Kacpherek 42cad38648 An extinguished candle's smoke rises at vanilla's widened 0.1F 2026-11-08 13:52:13 +01:00
Kacpherek 86b883ab33 Jukebox notes rise from vanilla's widened 1.2F 2026-11-08 13:52:13 +01:00
Kacpherek 8a0ec928fa A boat under water rises by vanilla's widened 0.01F 2026-11-08 13:52:13 +01:00
Kacpherek dce5e62714 Raiders move through a village at vanilla's widened 1.05F 2026-11-08 13:52:13 +01:00
Kacpherek f102160191 A sweep attack knocks back with vanilla's widened 0.4F 2026-11-08 13:50:16 +01:00
Kacpherek 125b46829c Skeletons and illusioners shoot from and aim with vanilla's widened floats 2026-11-08 13:50:09 +01:00
Kacpherek 5845d88a53 A blaze rises toward its target at vanilla's widened 0.3F 2026-11-08 13:50:00 +01:00
Kacpherek 02d9ee7962 A strafing ender dragon's height offset starts at vanilla's widened 0.4F 2026-11-08 13:49:56 +01:00
Kacpherek 8a197bf691 A dying ender dragon rises by vanilla's widened 0.1F 2026-11-08 13:49:53 +01:00
Kacpherek 34897bdcd3 A snow golem aims and throws from vanilla's widened floats 2026-11-08 13:49:42 +01:00
Kacpherek dc5420e82e A dolphin attacks at vanilla's widened 1.2F 2026-11-08 13:49:31 +01:00
Kacpherek 9850f641b5 A fox attacks and walks to berries at vanilla's widened 1.2F 2026-11-08 13:49:31 +01:00
Kacpherek 652be13517 A villager throws from and at vanilla's widened 0.3F 2026-11-08 13:49:21 +01:00
Kacpherek e1294dbaf6 A sniffer's culling box grows by vanilla's widened 0.6F 2026-11-08 13:49:11 +01:00
Kacpherek 381c191dc1 A rendered minecart samples its rail at vanilla's widened 0.3F 2026-11-08 13:49:07 +01:00
05a9ed3057 /kill on a dropped item, cushion, end crystal or marker makes a vibration
Entity.kill posts entity_die for every entity it removes, so /kill now
does for dropped items (a wool stack dampens it, ItemEntity
.dampensVibrations), cushions, end crystals (after EndCrystal.kill's
onDestroyedBy, even an invulnerable one) and the entities Horizon only
stores (markers, displays, interactions). An armor stand's own death is
a living source no warden can target.

A blast's kill of a painting, item frame, cushion or armor stand is
credited to its causing entity (BlockAttachedEntity.kill /
ArmorStand.kill with DamageSource.getEntity): the mob, the player who
lit the TNT or broke the crystal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 13:17:05 +01:00
Lyle ColeandClaude Opus 5.5 7a065ec02b Decoration: per-call lookups and re-reads cut (lakes, ores, tags, biome filter)
Per-call costs in decoration, ported from the To The Max fork onto main
(main's own ore-sphere walk, FxHash tick sets and HashMap are kept):
- Biome filter: each biome's feature list is a bitset, not a BTreeSet.
- matching_block_tag: the decoration world keeps the last tag's states
  (environment scans ask one tag up to 32 times per position).
- Block ticks: the region keeps the last 16 default states it resolved
  instead of going through the registry's name map each time.
- Lakes: the spot divisions once per axis coordinate, not per cell; the
  16x16x8 grid as a byte per column, edges tested a column at a time.
- Ores: a bound per region chunk on OCEAN_FLOOR_WG (its heightmap maximum
  on first use, raised by the world's own writes, the only writes that
  move it) answers "no column" without reading up to 121 entries; the
  blob's sin/cos only once a column is found. Target rules that draw
  nothing (all of vanilla's) are memoized per (state, Y ranges held).
- Speleothem Column.scan reads each block once.

Identical output: chunkgen_bench snapshots (32x32, seed 0) and the To The
Max world checksums are unchanged. Interleaved pairs, 4 threads, built
with one codegen unit (the fast profile's 16 units moved unrelated
stages by +-10% between builds):
- vanilla chunkgen_bench --size 32: decorate wall 334-340 -> 317-318 ms
  (-6%); proto unchanged.
- To The Max (seed 0, 16x16): decoration 4.2-4.4 -> 3.9 ms a chunk
  (-9%); finished chunks 152.3-155.7 -> 154.3-155.7/s (terrain-bound
  at 4 threads).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-07 19:30:09 -06:00
Lyle ColeandClaude Opus 5.5 df7f146ed9 Jigsaw pools: load each element's template on its first use
Decoding a template pool loaded every element's template at once
(gunzip, NBT, palette), where vanilla's pool elements call
StructureTemplateManager.getOrCreate only when the assembler first
touches them. In To The Max's bench area the abandoned camp's pools
loaded 180 templates and its assembly used 18 (villages: 32 loaded, 5
used), holding the start chunk while neighbouring threads waited on it.

Pool elements now hold a LazyTemplate (the id and the shared template
store) that loads through the same cache on first use, so templates stay
comparable by pointer. Data packs' template files still win over the
prepared ones (the store keeps them). When two threads load one file at
once, both get the first copy kept (before, the second insert replaced
the first). Its Debug prints the template as before, so the
structure-start checksum is unchanged.

Identical output: chunkgen_bench snapshots (32x32, seed 0) and the To The
Max world checksums are unchanged. Interleaved pairs, 4 threads, built
with one codegen unit:
- vanilla chunkgen_bench --size 32: structures 0.563-0.568 -> 0.493-0.524
  ms a chunk (-10%); proto wall 1321 -> 1284-1304 ms.
- To The Max (seed 0, 16x16): structures 1.5 -> 1.1 ms a chunk (-27%);
  finished chunks 153.6-155.5 -> 155.1-155.9/s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-07 19:30:09 -06:00
1d29778ef7 Let sculk hear cauldron fluid transfers
Post FLUID_PICKUP or FLUID_PLACE after the sound in
CauldronInteractions.fillBucket, emptyBucket and the bottle interactions.
Keep lowerFillLevel's earlier BLOCK_CHANGE and washing-only behavior.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-07 21:18:48 +01:00
01b4e8b995 Let sculk hear trampled farmland and broken turtle eggs
Forward landing block events through the player and mob server paths.
FarmlandBlock.turnToBaseBlock posts BLOCK_CHANGE from the landing entity;
TurtleEggBlock.decreaseEggs and Level.destroyBlock post BLOCK_DESTROY
without a source, carrying the affected block state.
Silence a queued egg event when its block write was cancelled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-11-07 21:18:13 +01:00
556 changed files with 34249 additions and 6261 deletions

No files matched your search

+12 -1
View File
@@ -2,7 +2,8 @@
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). Over about 500 lines: link the issue with its
approved spec too ("Spec: #123"), CONTRIBUTING.md "Large pull requests". -->
## Kind
@@ -16,6 +17,16 @@
- [ ] tooling: tools, CI, tests, harnesses
- [ ] docs
## Layer
<!-- engine, platform or Horizon (docs/layers.md, "Where a new feature goes"). Reviewers check:
- the new code sits in a crate or module of that layer;
- no new dependency its layer may not use (CI's layer check): no engine code calling platform or Horizon code,
and Horizon code using the platform's public API only, never the engine directly;
- Horizon features in the game are behind `horizon-extras`, and platform features that change the vanilla
look are off by default;
- no new built-in server address outside the Horizon layer. -->
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
-1
View File
@@ -13,5 +13,4 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
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)
+15
View File
@@ -78,6 +78,21 @@ jobs:
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
+12 -4
View File
@@ -25,6 +25,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine uses engine; platform uses engine and platform; Horizon (its extras,
cosmetics, shop, telemetry, launcher) uses platform and Horizon only, never the engine: it moves to its own repository
later. `tools/check_layers.py` checks it. New Horizon code goes behind `horizon-extras`; built-in addresses go in
`horizon-addresses`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
@@ -61,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `horizon-addresses` | Every built-in address of an outside service vanilla doesn't contact (Horizon layer; [docs/layers.md](docs/layers.md#built-in-addresses)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
@@ -70,6 +75,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
@@ -101,6 +108,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -152,17 +160,17 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`), `launch_options.rs` (the command line); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`, `world_update.rs` (`fn update_world`, far terrain) |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| 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` |
| Player actions | game.rs (`fn tick_mining`, `fn break_block`); G: `use_hand.rs` (`fn use_hand`), `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`, `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` |
| 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`, `shader_ui.rs`; a page list too long for its column collapses into one row), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, the collapsed page strip `tabs` the shell's page list uses, [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` |
+34 -1
View File
@@ -38,10 +38,20 @@ and compares their outputs.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
Say in your pull request which vanilla classes and methods you followed.
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
(ViaVersion, ViaProxy) in front of them.
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.**
- Vanilla code never depends on the platform (mods, shader packs, HDR, the settings system) or on Horizon
(its extras, cosmetics, the shop, official servers, telemetry).
- The platform never depends on Horizon.
- Horizon uses only the platform, never the engine: it will live in its own repository.
[docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
@@ -80,6 +90,15 @@ license. Shader packs are a clean-room rewrite of the pack format, produced from
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.
## Vendor libraries and Java mods
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
default. The game may offer to turn it on when it finds a Java mod; the player decides.
## Before asking for review
Run the self-check and paste the block it prints into your pull request:
@@ -116,7 +135,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
## Pull requests
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
pieces that each make sense alone. Small pull requests get reviewed and merged the same day. Bigger than that:
[large pull requests](#large-pull-requests).
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
@@ -139,6 +159,19 @@ generation, the launcher's updates) wait for a local proof first ([merging](docs
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
### Large pull requests
500 lines stays the default. A pull request that can't be split below it starts from a spec:
1. **Open an issue with the spec** before the code: what changes, the files it touches, the behaviour players and
other code will see, and the tests or harnesses that prove it.
2. **A maintainer approves the spec** in the issue. Code written before that may have to change or wait.
3. **The pull request links the approved issue** (`Spec: #123` in the description) and stays within it; a change of
plan goes back to the issue first.
4. **An agent check compares the code with the spec**, run by the maintainers' agents: anything the spec doesn't
cover, or that it promises and the code doesn't do, comes back to you.
5. **Then the normal review**, as for any pull request.
## Parity checks on forks
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
Generated
+20
View File
@@ -1374,6 +1374,10 @@ dependencies = [
"digest",
]
[[package]]
name = "horizon-addresses"
version = "0.1.0"
[[package]]
name = "horizon-anticheat"
version = "0.1.0"
@@ -1592,6 +1596,7 @@ dependencies = [
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1641,6 +1646,7 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"glam",
"horizon-addresses",
"png",
"serde",
"serde_json",
@@ -1663,6 +1669,7 @@ dependencies = [
"chacha20poly1305",
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"if-addrs",
"serde",
"serde_json",
@@ -1772,6 +1779,13 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-system",
]
[[package]]
name = "horizon-polytone"
version = "0.1.0"
@@ -1909,11 +1923,17 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-system"
version = "0.1.0"
[[package]]
name = "horizon-telemetry"
version = "0.1.0"
dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"ruzstd",
"serde",
"serde_json",
+3
View File
@@ -1,6 +1,7 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
members = [
"crates/horizon-addresses",
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-narrator",
@@ -27,6 +28,8 @@ members = [
"crates/horizon-theme",
"crates/horizon-prepare",
"crates/horizon-telemetry",
"crates/horizon-system",
"crates/horizon-platform",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+4 -5
View File
@@ -12,7 +12,6 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
</p>
@@ -138,13 +137,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs. What players still run into: [known issues](docs/known-issues.md).
The table is the project's own audit of what the harnesses compare, subsystem by subsystem: a score says how much of that subsystem the harnesses cover and pass, not that nothing else differs from vanilla. What players still run into: [known issues](docs/known-issues.md).
### Java parity: 100%
### What the harnesses compare
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (its harnesses cover the subsystem and pass). Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+1 -1
View File
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
<details><summary>Technical</summary>
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
+2 -1
View File
@@ -338,7 +338,8 @@ is preserved.
## Starling
`crates/horizon-client-render/src/touch/layout.rs` is adapted from the touch layout of
`crates/horizon-client-render/src/touch/layout.rs` and `crates/horizon-client-render/src/touch/aim.rs`
are adapted from the touch layout and touch helpers of
[Starling](https://github.com/starling-client/starling), an Android port of Horizon, at commit
[150a414b09a1](https://github.com/starling-client/starling/tree/150a414b09a18c900489d9462a7fc035dae744b7)
(`android` branch), written mostly by Vasysik. Starling is licensed under the same MIT license as
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "horizon-addresses"
version = "0.1.0"
edition = "2024"
rust-version.workspace = true
license = "MIT"
description = "Every built-in address of an outside service vanilla doesn't contact, in one place (docs/layers.md#built-in-addresses)"
[lints]
workspace = true
+36
View File
@@ -0,0 +1,36 @@
//! Every built-in address of an outside service vanilla doesn't contact, in one place
//! (docs/layers.md#built-in-addresses). `tools/check_addresses.py` keeps new ones from going
//! anywhere else.
//!
//! This is a Horizon-layer crate: the engine and the platform never depend on it. Platform code
//! that talks to a service gets the address passed in from a Horizon crate.
/// Horizon's official survival server (SMP), offered once at the top of the server list by the
/// game and the launcher (`launcher/links.json` may name another). Its SRV record leads to the
/// port.
pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// The official server's earlier addresses: the game and the launcher move a server list's
/// entries for them to [`OFFICIAL_SERVER`], once. A move appends the old address here (and to
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
/// (`HORIZON_COSMETICS_API` overrides it).
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
/// The telemetry ingest server (`crates/horizon-telemetry-server`): opt-in telemetry, the
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
/// Play with Friends' troubleshooting page, shown to hosts nobody can reach.
pub const FRIENDS_HELP: &str =
"https://github.com/horizon-rust/horizon/blob/main/docs/play-with-friends.md#troubleshooting";
+1
View File
@@ -27,6 +27,7 @@ horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
@@ -5,6 +5,7 @@
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
gfx::PreparedTextures,
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
@@ -403,6 +404,30 @@ fn main() {
);
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
// The mip chains the renderer uploads, from the same pack layers: the HD pack's
// 64 px block sprites fill the array, as they do for a player who has it on.
let mut atlas = Sprites::with_disk_cache(&root);
for name in &blocks {
atlas.insert(&root, name).unwrap();
}
atlas.finish();
let border = vec![255u8; (atlas.size * atlas.size * 4) as usize];
row(
"texture upload: sprite mip chains, both packs",
time(
runs,
|| {},
|| {
std::hint::black_box(PreparedTextures::new(
&atlas.pixels,
atlas.size,
atlas.mips.len() as u32,
&atlas.mips,
(&border, atlas.size),
));
},
),
);
row(
"GUI atlas: decode and stitch, both packs",
time(
+384 -8
View File
@@ -112,6 +112,8 @@ struct Mixer {
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
/// Mods' audio sources (`push_voice`), each with its queue.
mod_audio: Vec<ModAudioSource>,
/// A mod's muffle of the sounds it does not exempt.
muffle: Muffle,
limiter_peak: f32,
@@ -121,10 +123,158 @@ struct Mixer {
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
#[cfg(feature = "audio")]
device_lost: bool,
/// The stream's recent backend errors, which tell a dead stream from a hiccup.
#[cfg(feature = "audio")]
stream_errors: StreamErrors,
}
/// A stream's backend errors close together (`StreamErrors::dead`).
#[cfg(any(feature = "audio", test))]
#[derive(Default)]
struct StreamErrors {
/// When the current run of errors began, and how many it holds.
since: Option<std::time::Instant>,
count: u32,
}
#[cfg(any(feature = "audio", test))]
impl StreamErrors {
/// Errors this close together...
const WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
/// ...this many of them: the stream is dead. Under vanilla a device that fails unrecoverably
/// reads as disconnected (`ALC_CONNECTED`, the `ALC_EXT_disconnect` extension), and
/// `SoundEngine` opens the output again; cpal reports a dead ALSA stream (`alsa::poll()`
/// returning POLLERR, over and over) as a backend error like any other, so a run of them
/// stands for it. One alone is a hiccup the stream recovers from.
const DEAD_AFTER: u32 = 3;
/// One more backend error at `now`: whether the stream counts as dead.
fn dead(&mut self, now: std::time::Instant) -> bool {
match self.since {
Some(since) if now.duration_since(since) <= Self::WINDOW => self.count += 1,
_ => {
self.since = Some(now);
self.count = 1;
}
}
self.count >= Self::DEAD_AFTER
}
}
/// Opening the output again after it failed: a few more tries, a couple of seconds apart, so a
/// device that is gone for good isn't asked forever.
#[cfg(any(feature = "audio", test))]
#[derive(Default, Debug, PartialEq)]
struct Reopen {
/// The tries left; 0 while the output plays, or after the last try.
tries: u32,
/// Ticks since the last try.
ticks: u32,
}
#[cfg(any(feature = "audio", test))]
impl Reopen {
const TRIES: u32 = 5;
/// Two seconds of client ticks.
const TICKS: u32 = 40;
/// The output failed to open: whether to keep trying.
fn failed(&mut self, retrying: bool) -> bool {
self.ticks = 0;
self.tries = if retrying {
self.tries.saturating_sub(1)
} else {
Self::TRIES
};
self.tries > 0
}
/// A tick while tries are left: whether to try now.
fn due(&mut self) -> bool {
if self.tries == 0 {
return false;
}
self.ticks += 1;
self.ticks >= Self::TICKS
}
}
/// A camera's forward, up and right vectors.
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
/// How much audio one mod source may have waiting, in seconds (`push_voice`): a burst past it
/// is refused rather than delayed, so a stalled sender cannot pile up latency.
const MOD_AUDIO_SECONDS: f64 = 2.0;
/// How many mod audio sources may play at once, across mods.
const MOD_AUDIO_SOURCES: usize = 64;
/// The distance at which positioned mod audio has faded out, in blocks (linear, as a sound's
/// `AL_LINEAR_DISTANCE` fades).
const MOD_AUDIO_RANGE: f32 = 48.0;
/// One of a mod's audio sources (`push_voice`): a speaker, keyed by the mod and its own id
/// for it. Its chunks play one after another, oldest first, so each speaker's audio stays in
/// order while different speakers play at the same time.
struct ModAudioSource {
mod_id: String,
source: u128,
chunks: std::collections::VecDeque<ModAudioChunk>,
/// How far into the head chunk the callback has got, in its own samples.
played: f64,
}
/// A block of mono samples a mod queued.
struct ModAudioChunk {
/// Mono samples, -1 to 1.
samples: Vec<f32>,
/// The rate they were sampled at.
rate: u32,
/// Where they were spoken, for distance and direction; none plays them centred.
at: Option<[f64; 3]>,
}
impl Mixer {
/// Queues `samples` behind what `source` of `mod_id` already has waiting. False, and
/// nothing queued, when they are empty or the rate is 0, when the source would hold more
/// than [`MOD_AUDIO_SECONDS`], or when a new source would pass [`MOD_AUDIO_SOURCES`].
fn push_mod_audio(
&mut self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
if samples.is_empty() || rate == 0 {
return false;
}
let seconds = |len: usize, rate: u32| len as f64 / f64::from(rate);
let added = seconds(samples.len(), rate);
let chunk = ModAudioChunk {
samples: samples.to_vec(),
rate,
at,
};
match self
.mod_audio
.iter()
.position(|s| s.source == source && s.mod_id == mod_id)
{
Some(i) => {
let s = &mut self.mod_audio[i];
let waiting: f64 = s
.chunks
.iter()
.map(|c| seconds(c.samples.len(), c.rate))
.sum();
if waiting + added > MOD_AUDIO_SECONDS {
return false;
}
s.chunks.push_back(chunk);
}
None => {
if added > MOD_AUDIO_SECONDS || self.mod_audio.len() >= MOD_AUDIO_SOURCES {
return false;
}
self.mod_audio.push(ModAudioSource {
mod_id: mod_id.to_owned(),
source,
chunks: std::collections::VecDeque::from([chunk]),
played: 0.0,
});
}
}
true
}
/// The id of world `name` (1 and up), given once per name.
fn world_id(&mut self, name: &str) -> u32 {
let i = match self.world_names.iter().position(|w| w == name) {
@@ -164,6 +314,24 @@ impl MuffleFade {
[self.amount * left, self.lowpass * left]
}
}
impl MixerBackend {
/// Queues a mod's audio for the output callback (`Host::push_voice`). False when nothing
/// was queued: no output device, or what [`Mixer::push_mod_audio`] refuses.
pub fn push_voice(
&self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
self.live
&& self
.mixer
.lock()
.is_ok_and(|mut m| m.push_mod_audio(mod_id, source, samples, rate, at))
}
}
/// The mixer side of the engine (`Backend`).
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
@@ -286,6 +454,21 @@ pub struct CapturedSound {
pub except_entity: Option<u32>,
}
impl Audio {
/// Plays a mod's audio (`Host::push_voice`): mono samples, -1 to 1, at `rate`, behind what
/// `source` of `mod_id` already has waiting; `at` places them for distance and direction.
pub fn push_voice(
&self,
mod_id: &str,
source: u128,
samples: &[f32],
rate: u32,
at: Option<[f64; 3]>,
) -> bool {
self.backend.push_voice(mod_id, source, samples, rate, at)
}
}
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
@@ -327,6 +510,9 @@ pub struct Audio {
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
/// Opening the output again after it failed to (`Reopen`).
#[cfg(feature = "audio")]
reopen: Reopen,
/// The look at the output devices running on its own thread, until a tick
/// takes its answer: listing them can take 100 ms (#11490).
#[cfg(feature = "audio")]
@@ -836,6 +1022,62 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
mixer.muffle.stages = [[0.0; 2]; 2];
}
mixer.muffle.bus = bus;
// A mod's audio (`push_voice`): every source at once, each playing its own chunks in order;
// a chunk that ends mid-buffer hands the rest of the buffer to the next one.
if !mixer.mod_audio.is_empty() {
let (forward, up, right) = basis(mixer.listener_yaw, mixer.listener_pitch);
let listener = mixer.listener;
let frames = data.len() / channels.max(1);
for source in &mut mixer.mod_audio {
let ModAudioSource { chunks, played, .. } = source;
let mut f = 0;
while f < frames {
let Some(chunk) = chunks.front() else {
break;
};
let len = chunk.samples.len();
let [left, right_gain, gain] = match chunk.at {
Some(at) => {
let d = [
at[0] - listener[0],
at[1] - listener[1],
at[2] - listener[2],
];
let gain = distance_gain(Some(MOD_AUDIO_RANGE), dot(d, d).sqrt());
let [l, r] = stereo_gains(dot(d, right), dot(d, up), dot(d, forward));
[l * gain, r * gain, gain]
}
None => [1.0; 3],
};
let step = f64::from(chunk.rate) / f64::from(rate);
while f < frames && (*played as usize) < len {
let i = *played as usize;
let t = (*played - i as f64) as f32;
let next = chunk
.samples
.get(i + 1)
.copied()
.unwrap_or(chunk.samples[i]);
let s = chunk.samples[i] * (1.0 - t) + next * t;
let frame = &mut data[f * channels..(f + 1) * channels];
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right_gain;
} else {
frame[0] += s * gain;
}
*played += step;
f += 1;
}
if *played as usize >= len {
// The next chunk starts where this one's last step left off.
*played -= len as f64;
chunks.pop_front();
}
}
}
mixer.mod_audio.retain(|s| !s.chunks.is_empty());
}
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
@@ -903,6 +1145,7 @@ fn open_device(
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
m.device_lost = false;
m.stream_errors = StreamErrors::default();
}
Ok((stream, config))
}
@@ -1052,10 +1295,19 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
},
move |e| {
log::warn!("Horizon: audio stream error: {e}");
if matches!(e, cpal::StreamError::DeviceNotAvailable)
&& let Ok(mut m) = on_error.lock()
{
m.device_lost = true;
if let Ok(mut m) = on_error.lock() {
let dead = match e {
cpal::StreamError::DeviceNotAvailable => true,
cpal::StreamError::BackendSpecific { .. } => {
m.stream_errors.dead(std::time::Instant::now())
}
};
if dead && !m.device_lost {
if !matches!(e, cpal::StreamError::DeviceNotAvailable) {
log::warn!("Horizon: the audio stream stopped; opening the output again");
}
m.device_lost = true;
}
}
},
None,
@@ -1177,6 +1429,8 @@ impl Audio {
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
reopen: Reopen::default(),
#[cfg(feature = "audio")]
device_probe: None,
}
}
@@ -1340,6 +1594,8 @@ impl Audio {
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
reopen: Reopen::default(),
#[cfg(feature = "audio")]
device_probe: None,
}
}
@@ -1447,6 +1703,27 @@ impl Audio {
}
changed
}
/// Opens the output on the chosen device again; `retrying` after an earlier try failed.
#[cfg(feature = "audio")]
fn reopen_output(&mut self, retrying: bool) {
// The old stream lets go of the device first: a dead one may still hold it.
self._stream = None;
let device = self.device_name.clone();
match self.set_device(&device) {
Ok(()) => {
if retrying {
log::info!("Horizon: the audio output is back");
}
self.reopen = Reopen::default();
}
Err(e) if self.reopen.failed(retrying) => {
log::warn!("Horizon: could not reopen the audio output ({e}); trying again soon");
}
Err(e) => {
log::warn!("Horizon: could not reopen the audio output ({e}); it stays off");
}
}
}
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
#[cfg(feature = "audio")]
@@ -1454,10 +1731,9 @@ impl Audio {
if self.should_change_device() {
// SoundEngine.reload: stop every sound, open the output again.
self.stop_all();
let device = self.device_name.clone();
if let Err(e) = self.set_device(&device) {
log::warn!("Horizon: could not reopen the audio output: {e}");
}
self.reopen_output(false);
} else if self.backend.owner == 0 && self.reopen.due() {
self.reopen_output(true);
}
}
self.sync_volumes();
@@ -1858,6 +2134,46 @@ impl Sounds for Audio {
mod tests {
use super::*;
/// A run of backend errors close together is a dead stream; one or two, or errors far apart,
/// are hiccups.
#[test]
fn a_run_of_stream_errors_is_a_dead_stream() {
use std::time::{Duration, Instant};
let start = Instant::now();
let at = |ms: u64| start + Duration::from_millis(ms);
let mut errors = StreamErrors::default();
assert!(!errors.dead(at(0)));
assert!(!errors.dead(at(500)));
assert!(errors.dead(at(900)));
// Spread out, they never add up.
let mut apart = StreamErrors::default();
for k in 0..10 {
assert!(!apart.dead(at(k * 2500)), "error {k}");
}
}
/// A failed reopening is tried again every two seconds, five more times, then given up.
#[test]
fn a_failed_reopening_retries_a_few_times_then_stops() {
let mut reopen = Reopen::default();
assert!(!reopen.due(), "nothing to retry while the output plays");
assert!(reopen.failed(false));
let mut tries = 0;
for _ in 0..1000 {
if reopen.due() {
tries += 1;
if !reopen.failed(true) {
break;
}
}
}
assert_eq!(tries, Reopen::TRIES);
assert_eq!(reopen, Reopen::default());
// The next lost device starts a fresh run.
assert!(reopen.failed(false));
assert_eq!(reopen.tries, Reopen::TRIES);
}
#[test]
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
@@ -2517,6 +2833,66 @@ mod tests {
assert_eq!(level(&audio), [0.0; 2]);
}
#[test]
fn each_mod_audio_source_plays_its_own_chunks_in_order() {
let mut m = Mixer::default();
// Two speakers of one mod, each queuing two chunks, interleaved.
for (source, level) in [(1, 0.1), (2, 0.4), (1, 0.2), (2, 0.3)] {
assert!(m.push_mod_audio("voice", source, &[level; 4], 48_000, None));
}
let mut data = vec![0.0f32; 8 * 2];
mix(&mut m, &mut data, 2, 48_000);
// Each speaker's first chunk, then its second: never both of one speaker at once.
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
let expected = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
assert!(
left.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6),
"{left:?}"
);
assert!(m.mod_audio.is_empty(), "both sources played out");
// Order within a source: its second chunk follows its first.
assert!(m.push_mod_audio("voice", 1, &[0.25; 2], 48_000, None));
assert!(m.push_mod_audio("voice", 1, &[0.5; 2], 48_000, None));
let mut data = vec![0.0f32; 4 * 2];
mix(&mut m, &mut data, 2, 48_000);
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
assert_eq!(left, [0.25, 0.25, 0.5, 0.5]);
}
#[test]
fn mod_audio_sources_are_kept_apart_by_mod_and_capped() {
let mut m = Mixer::default();
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
assert!(m.push_mod_audio("b", 1, &[0.1; 48_000], 48_000, None));
assert_eq!(
m.mod_audio.len(),
2,
"the same id in two mods is two sources"
);
// Two seconds wait at most per source; a burst past that is refused, not delayed.
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
assert!(!m.push_mod_audio("a", 1, &[0.1; 1], 48_000, None));
assert!(!m.push_mod_audio("a", 2, &[], 48_000, None));
assert!(!m.push_mod_audio("a", 2, &[0.1], 0, None));
}
#[test]
fn mod_audio_is_panned_and_faded_by_where_it_was_spoken() {
let mut m = Mixer::default();
// Facing south (yaw 0), -X is the listener's right.
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([-4.0, 0.0, 0.0])));
let mut data = vec![0.0f32; 16 * 2];
mix(&mut m, &mut data, 2, 48_000);
let left: f32 = data.chunks(2).map(|f| f[0]).sum();
let right: f32 = data.chunks(2).map(|f| f[1]).sum();
assert!(right > left, "right {right} should beat left {left}");
// Past the range it is silent.
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([0.0, 0.0, 60.0])));
let mut data = vec![0.0f32; 16 * 2];
mix(&mut m, &mut data, 2, 48_000);
assert!(data.iter().all(|s| *s == 0.0));
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
@@ -1459,7 +1459,37 @@ impl BlockEntityRenderer {
registry: &BlockRegistry,
visibility: FaceVisibility,
) -> Result<Self> {
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
Self::load_with_visibility_inner(root, registry, visibility, None, None)
}
/// Load from what the client startup batch already decoded: its entity-model
/// document (none reads `entity-models.json`) and the item defaults, avoiding a
/// second read and decode of either.
pub fn load_with_visibility_models_and_defaults(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
models: Option<&Value>,
defaults: &horizon_world::item_defaults::Defaults,
) -> Result<Self> {
Self::load_with_visibility_inner(root, registry, visibility, models, Some(defaults))
}
fn load_with_visibility_inner(
root: &Path,
registry: &BlockRegistry,
visibility: FaceVisibility,
models: Option<&Value>,
defaults: Option<&horizon_world::item_defaults::Defaults>,
) -> Result<Self> {
let owned_models = if models.is_none() {
Some(serde_json::from_slice(&std::fs::read(
root.join("entity-models.json"),
)?)?)
} else {
None
};
let all = models.or(owned_models.as_ref()).expect("entity models");
let models = MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
@@ -1469,12 +1499,17 @@ impl BlockEntityRenderer {
// The packs' custom entity models (`.jem` files), before anything draws.
renderer.set_shapes(renderer.shapes_for(root));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
let owned_defaults = if defaults.is_none() {
Some(match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
})
} else {
None
};
for (item, components) in &items {
let items = defaults.or(owned_defaults.as_ref()).unwrap();
for (item, components) in items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
+4 -1
View File
@@ -871,7 +871,10 @@ impl RolledLoot {
/// Puts the loot table back in place of `Items` while they are still the
/// roll; false once the container holds anything else.
pub fn restore(&self, map: &mut Compound) -> bool {
if map.get(&"Items".into()) != Some(&self.items) {
// An empty shulker box saves no `Items`, which matches an empty roll.
let saved = map.get(&"Items".into());
let empty = matches!(&self.items, Tag::List(l) if l.is_empty());
if saved != Some(&self.items) && !(saved.is_none() && empty) {
return false;
}
map.remove(&"Items".into());
@@ -80,6 +80,8 @@ pub enum BlockEntity {
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `TheEndPortalBlockEntity`: no data, only identity.
EndPortal,
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
+138 -49
View File
@@ -423,49 +423,57 @@ impl SceneCollisions<'_, '_> {
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
// block in them can matter (nothing that collides, nothing that reaches out of its cell), and
// then none of their cells is read: a particle's box at speed spans dozens of cells.
for cz in (lo.z >> 4)..=(hi.z >> 4) {
for cx in (lo.x >> 4)..=(hi.x >> 4) {
// The scan passes the cells of a column that is not resident.
let Some(chunk) = self.world.chunks.get(&(cx, cz)).map(|c| &**c) else {
continue;
};
let (x0, x1) = (lo.x.max(cx << 4), hi.x.min((cx << 4) + 15));
let (z0, z1) = (lo.z.max(cz << 4), hi.z.min((cz << 4) + 15));
for sy in (lo.y >> 4)..=(hi.y >> 4) {
let reaches = self.section_reaches_out(chunk, sy);
if !reaches && self.section_blocks_all_empty(chunk, sy) {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
let (y0, y1) = (lo.y.max(sy << 4), hi.y.min((sy << 4) + 15));
if !reaches {
// Nothing in the section reaches out of its cell: only the inner cells matter.
for z in z0.max(lo.z + 1)..=z1.min(hi.z - 1) {
for y in y0.max(lo.y + 1)..=y1.min(hi.y - 1) {
for x in x0.max(lo.x + 1)..=x1.min(hi.x - 1) {
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
for z in z0..=z1 {
let outer_z = z == lo.z || z == hi.z;
for y in y0..=y1 {
let outer_y = y == lo.y || y == hi.y;
for x in x0..=x1 {
let outer = [x == lo.x || x == hi.x, outer_y, outer_z];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
if kind != 0 {
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
}
}
@@ -473,6 +481,23 @@ impl SceneCollisions<'_, '_> {
true
}
/// Whether every block a section's palette holds has an empty collision shape (air, plants);
/// false for a palette of the whole registry or a section outside the column.
fn section_blocks_all_empty(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
})
}
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
/// its cell; a global palette, or a section outside the column, may.
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
@@ -503,18 +528,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette: every block it can hold has an empty shape (air, plants).
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
}))
Ok(self.section_blocks_all_empty(chunk, section_y))
}
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
fn missing_chunks_stay_missing(&self) -> bool {
@@ -999,6 +1013,76 @@ mod shulker_collision_tests {
mod section_tests {
use super::*;
impl SceneCollisions<'_, '_> {
/// `cells_without_collision` as it was before it skipped sections: every cell of the box, one at a time.
fn cells_without_collision_by_cell(&self, bounds: Aabb) -> bool {
let (min, max) = (bounds.min_position(), bounds.max_position());
if !min.is_finite() || !max.is_finite() {
return false;
}
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
let lo =
BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let hi =
BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
true
}
}
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
/// reaching into the cell above) and the scene's stone: wherever the cells say no
/// block can collide, the full resolution returns the movement, to the bit; the rest
@@ -1056,6 +1140,11 @@ mod section_tests {
(next() - 0.5) * reach,
);
let fast = scene.cells_without_collision(bb.expand_towards(d));
assert_eq!(
fast,
scene.cells_without_collision_by_cell(bb.expand_towards(d)),
"{bb:?} by {d:?}"
);
let mut full = SceneCollisions {
world: &world,
binding: &binding,
+47 -1
View File
@@ -316,7 +316,10 @@ impl SkullSkin {
/// The item slots of the container `name`'s block carries, if any.
fn container_size(name: &str) -> Option<usize> {
match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
"minecraft:chest"
| "minecraft:trapped_chest"
| "minecraft:barrel"
| "minecraft:ender_chest" => Some(27),
n if n.ends_with("copper_chest") => Some(27),
"minecraft:hopper" => Some(HOPPER_SLOTS),
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
@@ -453,6 +456,14 @@ impl BlockEntity {
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name == "minecraft:ender_chest" {
// EnderChestBlock.newBlockEntity: 27 slots, player-specific inventory.
return Some(Self::Chest(vec![None; 27]));
}
if name == "minecraft:end_portal" {
// EndPortalBlock.newBlockEntity: no data, only identity.
return Some(Self::EndPortal);
}
if name.ends_with("copper_golem_statue") {
return Some(Self::CopperGolemStatue(Default::default()));
}
@@ -547,6 +558,7 @@ impl BlockEntity {
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::Bell => name == "minecraft:bell",
Self::Conduit(_) => name == "minecraft:conduit",
Self::EndPortal => name == "minecraft:end_portal",
Self::EnchantingTable => name == "minecraft:enchanting_table",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
@@ -588,6 +600,7 @@ impl BlockEntity {
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Bell => "minecraft:bell",
Self::Conduit(_) => "minecraft:conduit",
Self::EndPortal => "minecraft:end_portal",
Self::EnchantingTable => "minecraft:enchanting_table",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
@@ -625,6 +638,7 @@ impl BlockEntity {
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
@@ -658,6 +672,7 @@ impl BlockEntity {
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
@@ -696,6 +711,7 @@ impl BlockEntity {
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
@@ -949,4 +965,34 @@ mod tests {
assert!((250..450).contains(&paid), "{paid}");
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
}
/// An ender chest placed with /setblock gets its block entity (`EnderChestBlock.newBlockEntity`),
/// so `/data get block` answers with the entity and the chunk saves it.
#[test]
fn an_ender_chest_starts_as_a_block_entity() {
let entity = BlockEntity::for_block("minecraft:ender_chest").expect("an ender chest");
// `Chest` variant with 27 slots; `id()` reports the chest kind, block name comes from NBT.
assert_eq!(entity.id(), "minecraft:chest");
assert_eq!(entity.slots().len(), 27);
assert!(entity.slots()[0].is_none());
assert!(matches!(entity, BlockEntity::Chest(_)));
// `fits`: an ender chest entity fits an ender chest block.
assert!(entity.fits("minecraft:ender_chest"));
// A stone block is not an ender chest.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// An end portal placed with /setblock gets its block entity (`EndPortalBlock.newBlockEntity`),
/// so `/data get block` answers with the entity and the chunk saves it.
#[test]
fn an_end_portal_starts_as_a_block_entity() {
let entity = BlockEntity::for_block("minecraft:end_portal").expect("an end portal");
assert_eq!(entity.id(), "minecraft:end_portal");
assert_eq!(entity.slots().len(), 0);
assert!(matches!(entity, BlockEntity::EndPortal));
// `fits`: an end portal entity fits an end portal block.
assert!(entity.fits("minecraft:end_portal"));
// A stone block is not an end portal.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
}
@@ -479,6 +479,8 @@ impl Screen for ControllerScreen {
.collect(),
})
.collect(),
listed: true,
heading: None,
};
rows().into_iter().map(sheet).collect()
}
@@ -532,8 +534,9 @@ impl Screen for ControllerScreen {
true
}
/// This player's controller settings, bindings included, as the screen found them.
fn undo(&mut self, s: &mut Settings, before: &Settings) {
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.controller = before.controller.clone();
Vec::new()
}
/// Undo is there exactly while the controller settings differ from what the screen opened
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
+4 -13
View File
@@ -10,7 +10,7 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Local};
use horizon_telemetry::system::machine_arch;
use horizon_system::{command_output, machine_arch};
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
@@ -230,15 +230,6 @@ pub fn release_tag() -> Option<String> {
.then(|| folder.to_owned())
}
fn command_output(program: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(program)
.args(args)
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
@@ -269,7 +260,7 @@ fn operating_system() -> String {
} else if cfg!(target_os = "macos") {
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
} else if cfg!(windows) {
horizon_telemetry::system::windows_version()
horizon_system::windows_version()
} else {
None
};
@@ -284,11 +275,11 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
read("/proc/cpuinfo").and_then(|t| horizon_system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
horizon_telemetry::system::windows_cpu()
horizon_system::windows_cpu()
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
+9 -1
View File
@@ -432,7 +432,15 @@ fn celestial_color(in: Out) -> vec4<f32> {
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
if in.overlay == 45u {
// A Polytone custom particle drawn additively: the fog over its colour, as vanilla's
// particle.fsh, under the additive blend (no alpha cutout: its faint texels add up).
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
return color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
+43 -12
View File
@@ -169,6 +169,14 @@ pub struct Frame {
pub downfall: f32,
}
impl Frame {
/// `g.rain`: the mean of the rain and thunder levels, so plain rain is 0.5 and a
/// thunderstorm 1 (observed on the real mod: a pack's `g.rain` colormap row).
pub fn global_rain(&self) -> f64 {
(f64::from(self.rain) + f64::from(self.thunder)) / 2.0
}
}
/// The game side of a pack expression in an attribute value: `g`, `p`, `c`, `o` (the camera's
/// position) and `r`, `v` (the value so far) and the pack's `config`.
struct FrameHost<'a> {
@@ -178,6 +186,21 @@ struct FrameHost<'a> {
seed: u64,
}
/// `program`'s value for the frame (a pack's expression outside attribute layers: its `v` is 0).
pub(crate) fn eval_frame(
program: &horizon_polytone::expr::Program,
frame: &Frame,
configs: &horizon_polytone::pack::Configs,
) -> Option<horizon_polytone::expr::Value> {
let mut host = FrameHost {
frame,
configs,
value: 0.0,
seed: frame.game_time as u64,
};
program.eval(&mut host).ok()
}
impl horizon_polytone::expr::Host for FrameHost<'_> {
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::{Root, Value as V};
@@ -197,7 +220,7 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
let on = |root: Root| *target == V::Object(root as u32);
let num = |n: f64| Ok(V::Num(n));
match name {
"rain" if on(Root::Global) => num(f64::from(f.rain)),
"rain" if on(Root::Global) => num(f.global_rain()),
"time" if on(Root::Global) => num(f.game_time as f64),
"dayTime" if on(Root::Global) => num(f.day_time as f64),
"skyType" if on(Root::Global) => num(f64::from(f.sky_type)),
@@ -1167,20 +1190,25 @@ mod tests {
r#"{"default_value": 7}"#,
),
]);
let at = |rain: f32, chunks: i32| {
let at = |rain: f32, thunder: f32, chunks: i32| {
env.set_frame(Frame {
rain,
thunder,
render_distance: chunks,
..Frame::default()
});
sky_at(&env, "minecraft:plains", 0.0)
};
// `c.viewDistance` is in blocks: 16 chunks are 256.
assert_eq!(at(0.0, 16).fog_end, 224.0);
assert_eq!(at(0.25, 32).fog_end, 498.0);
assert_eq!(at(0.0, 0.0, 16).fog_end, 224.0);
// `g.rain` is the mean of rain and thunder: half rain is 0.25 (448 + 50), plain rain
// 0.5 and a thunderstorm 1, both past the `min` (224 + 100).
assert_eq!(at(0.5, 0.0, 32).fog_end, 498.0);
assert_eq!(at(1.0, 0.0, 16).fog_end, 324.0);
assert_eq!(at(1.0, 1.0, 16).fog_end, 324.0);
// `v` is the value the layers before gave: the default start 0, and 192.33 for clouds.
assert_eq!(at(0.0, 16).fog_start, 7.0);
assert_eq!(at(0.0, 16).cloud_height, 192.33 * 0.5);
assert_eq!(at(0.0, 0.0, 16).fog_start, 7.0);
assert_eq!(at(0.0, 0.0, 16).cloud_height, 192.33 * 0.5);
}
/// A PNG of RGBA pixels in one row.
@@ -1215,18 +1243,21 @@ mod tests {
),
("assets/mypack/polytone/colormaps/sky.png", &png),
]);
let sky = |rain: f32| {
let sky = |rain: f32, thunder: f32| {
env.set_frame(Frame {
rain,
thunder,
..Frame::default()
});
env.sample(&Sample::default()).sky_color
};
assert_eq!(sky(0.0), [1.0, 0.0, 0.0]);
assert_eq!(sky(0.3), [0.0, 1.0, 0.0]);
assert_eq!(sky(1.0), [0.0, 0.0, 1.0]);
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080).
assert_eq!(sky(0.7), [128.0 / 255.0; 3]);
// `g.rain` 0, 0.3 (rain 0.6), 1 (a thunderstorm).
assert_eq!(sky(0.0, 0.0), [1.0, 0.0, 0.0]);
assert_eq!(sky(0.6, 0.0), [0.0, 1.0, 0.0]);
assert_eq!(sky(1.0, 1.0), [0.0, 0.0, 1.0]);
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080):
// `g.rain` 0.7.
assert_eq!(sky(1.0, 0.4), [128.0 / 255.0; 3]);
}
#[test]
@@ -107,12 +107,7 @@ impl Screen for ExtrasScreen {
}
}
}
vec![Sheet {
id: (),
name: lang::get(TITLE),
about: lang::get(ABOUT),
groups,
}]
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
match row {
+25 -8
View File
@@ -99,10 +99,11 @@ impl FogState {
self.block_light_flicker += (a - b) * c * d * 0.1;
self.block_light_flicker *= 0.9;
}
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water.
pub fn tick(&mut self, eye_in_water: bool) {
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water, ten times faster
/// for a spectator.
pub fn tick(&mut self, eye_in_water: bool, spectator: bool) {
self.water_vision_time = if eye_in_water {
(self.water_vision_time + 1).min(600)
(self.water_vision_time + if spectator { 10 } else { 1 }).min(600)
} else {
(self.water_vision_time - 10).max(0)
};
@@ -707,11 +708,11 @@ mod tests {
fn water_vision_ramps() {
let mut s = FogState::default();
for _ in 0..100 {
s.tick(true);
s.tick(true, false);
}
assert!((s.water_vision(true) - 0.6).abs() < 1e-6);
for _ in 0..500 {
s.tick(true);
s.tick(true, false);
}
assert_eq!(s.water_vision(true), 1.0);
assert_eq!(s.water_vision(false), 0.0);
@@ -726,17 +727,17 @@ mod tests {
input.fog_type = FogType::Water;
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
let mut state = FogState::default();
state.tick(true);
state.tick(true, false);
let first = setup_fog(&tables, &state, &input);
assert_eq!(first.environmental_end, 24.0);
for _ in 0..600 {
state.tick(true);
state.tick(true, false);
}
let settled = setup_fog(&tables, &state, &input);
assert_eq!(settled.environmental_end, 96.0);
input.full_water_vision = true;
let mut fresh = FogState::default();
fresh.tick(true);
fresh.tick(true, false);
let clear = setup_fog(&tables, &fresh, &input);
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
@@ -782,4 +783,20 @@ mod tests {
assert_eq!(fog.environmental_start, -160.0);
assert_eq!(fog.environmental_end, 768.0);
}
#[test]
fn a_spectator_sees_clearly_under_water_ten_times_sooner() {
let mut swimmer = FogState::default();
let mut spectator = FogState::default();
for _ in 0..60 {
swimmer.tick(true, false);
spectator.tick(true, true);
}
// LocalPlayer.getWaterVision after 60 ticks: 60 and 600 ticks of vision.
assert!((swimmer.water_vision(true) - 0.36).abs() < 1e-6);
assert_eq!(spectator.water_vision(true), 1.0);
// Out of the water both lose it 10 a tick.
spectator.tick(false, true);
assert_eq!(spectator.water_vision_time, 590);
}
}
File diff suppressed because it is too large. Load diff
@@ -20,6 +20,10 @@ const MATERIALS: usize = 16;
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
/// The overlay of a resource pack's Polytone custom particle drawn additively
/// (`additive_translucent`): fog tints its colour as vanilla's particle shader does, which the
/// other additive geometry does not take.
pub const ADDITIVE_PARTICLE_OVERLAY: u32 = 45;
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
pub fn is_native_terrain(layer: u32) -> bool {
matches!(
@@ -36,7 +36,7 @@ pub(super) struct EntityOutline {
impl EntityOutline {
/// `entity` draws the silhouettes with the entity shader's vertex stage and
/// `fs_outline`; `format` is the frame's.
/// `fs_outline`; `format` is what it draws onto (`Renderer::overlay_format`).
pub(super) fn new(
device: &wgpu::Device,
entity: (
+2 -1
View File
@@ -19,6 +19,7 @@
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
use smallvec::SmallVec;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
@@ -825,7 +826,7 @@ impl Flat {
});
self.phase2_group = Some((group, count as u32));
}
let groups: Vec<(wgpu::BindGroup, u32)> = self
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
.segments
.iter()
.filter_map(|&(source, first, count)| {
+299 -12
View File
@@ -93,6 +93,8 @@ struct Layered {
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// Compiled only when a screenshot needs it, for its readback format and HUD mode.
screenshot: Option<(wgpu::TextureFormat, bool, bool, wgpu::RenderPipeline)>,
/// What `tonemap` and `composite` read, written before they run (`begin`).
params: wgpu::Buffer,
}
@@ -187,8 +189,9 @@ pub struct Hdr {
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
@@ -328,15 +331,23 @@ impl Hdr {
})
};
let layered = coverage.then(|| {
let with = |label, textures: &[u32], params, entry| {
let with = |label, textures: &[u32], params, entry, format| {
let layout = layout(label, textures, params);
let pipeline = pass(label, &layout, entry, format);
(layout, pipeline)
};
let hud = super::hud_coverage::HUD_FORMAT;
Layered {
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap", format),
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep", hud),
composite: with(
"Horizon HDR composite",
&[0, 4, 5],
true,
"fs_composite",
format,
),
screenshot: None,
params: params(),
}
});
@@ -445,12 +456,11 @@ impl Hdr {
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
// off; the last pass clips it at white.
// 8-bit, so each HUD draw clips and rounds as on the frame with HDR off.
let hud = layered.then(|| {
texture(
"HUD",
self.format,
super::hud_coverage::HUD_FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
)
});
@@ -512,17 +522,110 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// Lets the targets go, a scaled screenshot's: the next `frame` makes them again.
pub fn release_targets(&mut self) {
self.targets = None;
}
/// The float frame with HUD coverage (`Layered`).
pub fn layered(&self) -> bool {
self.layered.is_some()
}
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
/// once `keep_world` made it, and which an ordinary screenshot shows.
/// once `keep_world` made it.
pub fn hud(&self) -> Option<&wgpu::Texture> {
self.targets.as_ref()?.hud.as_ref()
}
/// The layered frame's SDR screenshot, directly in the readback's texture; the icon
/// maps only the world. Where Horizon lifted nothing (`lighting` off: HDR Lighting off,
/// or under a shader pack) nothing is mapped. Neither the display's targets nor its
/// parameters are changed.
pub fn sdr_screenshot(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
format: wgpu::TextureFormat,
world_only: bool,
) -> Option<wgpu::Texture> {
let shoulder = self.lighting;
let (l, t) = (self.layered.as_mut()?, self.targets.as_ref()?);
if l.screenshot
.as_ref()
.is_none_or(|(f, w, s, _)| (*f, *w, *s) != (format, world_only, shoulder))
{
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("HDR SDR screenshot"),
source: wgpu::ShaderSource::Wgsl(shader().into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("HDR SDR screenshot"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_sdr_screenshot"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[
("screenshot_world_only", f64::from(u8::from(world_only))),
("screenshot_shoulder", f64::from(u8::from(shoulder))),
],
..Default::default()
},
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
l.screenshot = Some((format, world_only, shoulder, pipeline));
}
let pipeline = &l.screenshot.as_ref()?.3;
let views = [
(0, &t.frame),
(4, t.coverage.as_ref()?),
(5, t.hud.as_ref()?),
]
.map(|(binding, texture)| (binding, texture.create_view(&Default::default())));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("HDR SDR screenshot"),
layout: &pipeline.get_bind_group_layout(0),
entries: &views
.each_ref()
.map(|(binding, view)| wgpu::BindGroupEntry {
binding: *binding,
resource: wgpu::BindingResource::TextureView(view),
}),
});
let copy = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon screenshot copy"),
size: t.frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
Self::draw(
encoder,
pipeline,
&group,
&copy.create_view(&Default::default()),
"HDR SDR screenshot",
None,
);
Some(copy)
}
/// On the layered frame, before `tonemap` and `composite`: what they read, for a frame that
/// drew a world (`world`) or not.
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
@@ -601,6 +704,20 @@ impl Hdr {
)
}
/// The display as a 0.2 pack's `Frame` describes it, with the values the last pass uses:
/// the headroom (the peak over paper white, at least 1), then paper white, the peak and
/// the black level in nits.
pub fn display(&self) -> [f32; 4] {
let paper = self.paper_white.max(1.0);
let headroom = (self.peak / paper).max(1.0);
[
headroom,
paper,
paper * headroom,
self.black.clamp(0.0, paper),
]
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
@@ -1142,8 +1259,8 @@ var<private> params: ReferenceParams;
});
}
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
/// for HDR10.
/// The first texel of `texture` once `encoder` ran: four half floats, 10-bit PQ codes
/// for HDR10, or 8-bit values over 255 for the HUD's target.
pub(super) fn texel(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -1180,6 +1297,9 @@ var<private> params: ReferenceParams;
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
}
if texture.format() == super::super::hud_coverage::HUD_FORMAT {
return std::array::from_fn(|i| f32::from(bytes[i]) / 255.0);
}
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
@@ -1412,6 +1532,173 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
/// What a 0.2 pack's `Frame` reads of the display: the headroom the tone curve eases
/// into, never below 1, and the black Horizon lifts after it, never above paper white.
#[test]
fn the_display_a_pack_reads_is_the_one_the_curve_maps_to() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak, hdr.black) = (203.0, 1000.0, 0.05);
let [headroom, paper, peak, black] = hdr.display();
assert_eq!((headroom, paper, black), (1000.0 / 203.0, 203.0, 0.05));
assert!((peak - 1000.0).abs() < 1e-3, "{peak}");
// A peak under paper white has no headroom: the display tops out at white.
(hdr.peak, hdr.black) = (150.0, 400.0);
assert_eq!(hdr.display(), [1.0, 203.0, 203.0, 203.0]);
}
#[test]
fn a_scaled_screenshot_shoots_at_its_size_and_leaves_the_window_its_own_targets() {
let Some((device, queue)) = gpu() else {
eprintln!("scaled screenshot GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let extent = |t: &wgpu::Texture| [t.width(), t.height()];
hdr.frame(&device, [4, 2]);
// `Renderer::draw_scaled_frame`: the frame at the screenshot's size, its SDR picture
// and HDR10 copy at that size too, then the targets let go.
assert_eq!(extent(&hdr.frame(&device, [8, 4])), [8, 4]);
let mut encoder = device.create_command_encoder(&Default::default());
let sdr = hdr
.sdr_screenshot(&device, &mut encoder, F::Bgra8Unorm, false)
.unwrap();
let pq = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
queue.submit([encoder.finish()]);
assert_eq!((extent(&sdr), extent(&pq)), ([8, 4], [8, 4]));
hdr.release_targets();
assert!(hdr.hud().is_none() && hdr.coverage_view().is_none());
// The window's next frame makes its own again.
assert_eq!(extent(&hdr.frame(&device, [4, 2])), [4, 2]);
assert_eq!(extent(hdr.hud().unwrap()), [4, 2]);
}
#[test]
fn sdr_screenshots_roll_off_the_world_and_keep_the_hud_as_drawn() {
let Some((device, queue)) = gpu() else {
eprintln!("SDR screenshot GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
// These display settings must not scale either the SDR world or the HUD.
(hdr.paper_white, hdr.peak, hdr.black, hdr.ui) = (300.0, 1500.0, 0.2, 0.25);
let shoot = |hdr: &mut Hdr, world: [f64; 3], hud: [f64; 3], c: f64, icon| {
let frame = hdr.frame(&device, [1, 1]);
let t = hdr.targets.as_ref().unwrap();
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, [world[0], world[1], world[2], 1.0]);
fill(
&mut encoder,
t.hud.as_ref().unwrap(),
[hud[0], hud[1], hud[2], 0.0],
);
fill(&mut encoder, t.coverage.as_ref().unwrap(), [c; 4]);
// Read half-floats so 8-bit quantization cannot conceal the curve or RGB ratios.
let copy = hdr
.sdr_screenshot(&device, &mut encoder, F::Rgba16Float, icon)
.unwrap();
texel(&device, &queue, encoder, &copy)
};
// Below the linear knee, through the last half-float below it, the stored
// channels survive exactly. All inputs here are exactly representable.
for gamma in [0.0f32, 0.25, 0.5, 0.75, 0.79248047] {
let w = [gamma, gamma / 2.0, 0.0];
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
}
// Independent numeric checkpoints in linear light, not a CPU copy of the shader.
for (light, expected) in [
(1.0f32, 0.8),
(2.0, 0.9111111),
(4.0, 0.9578947),
(8.0, 0.9794872),
] {
let g = f64::from(light.powf(1.0 / 2.2));
let got = shoot(&mut hdr, [g; 3], [1.0; 3], 0.0, false);
assert!(
(got[0].powf(2.2) - expected).abs() < 0.002,
"{light}: {got:?}"
);
assert!(got[0] < 1.0);
}
let mut last = 0.0;
for i in 0..65 {
let g = f64::from(i) / 16.0;
let got = shoot(
&mut hdr,
[g, g / 2.0, 0.0],
[g.min(1.0), (g / 2.0).min(1.0), 0.0],
0.0,
false,
);
assert!(got[0] >= last && got[0] <= 1.0, "{g}: {got:?}");
assert!((got[0] - 2.0 * got[1]).abs() < 0.002 && got[2] == 0.0);
last = got[0];
}
// Fully covered is exactly the HUD as its 8-bit target holds it, regardless of the
// world or UI Brightness.
let hud = [64.0, 128.0, 192.0].map(|v: f64| v / 255.0);
assert_eq!(
shoot(&mut hdr, [2.0, 1.0, 0.5], hud, 1.0, false),
[hud[0], hud[1], hud[2], 1.0].map(|v| half::f16::from_f64(v).to_f32())
);
// At zero coverage the mapped world; the icon also ignores a fully opaque HUD.
for (hud, c, icon) in [([1.0, 1.0, 0.5], 0.0, false), ([0.25; 3], 1.0, true)] {
let got = shoot(&mut hdr, [2.0, 1.0, 0.5], hud, c, icon);
for (g, expected) in got.into_iter().zip([0.983284, 0.491642, 0.245821, 1.0]) {
assert!((g - expected).abs() < 0.002, "{got:?}");
}
}
// R8's 128/255 coverage: recover the HUD against the clipped backdrop, then
// composite in gamma. The original HUD target and world's light stay intact.
let got = shoot(
&mut hdr,
[2.0, 1.0, 0.5],
[0.75, 0.875, 0.4375],
128.0 / 255.0,
false,
);
for (g, expected) in got.into_iter().zip([0.741675, 0.62182, 0.31091, 1.0]) {
assert!((g - expected).abs() < 0.002, "{got:?}");
}
let t = hdr.targets.as_ref().unwrap();
let encoder = device.create_command_encoder(&Default::default());
assert_eq!(
texel(&device, &queue, encoder, &t.frame),
[2.0, 1.0, 0.5, 1.0]
);
// The HUD's 8-bit target rounds what is drawn on it.
let encoder = device.create_command_encoder(&Default::default());
let stored = [191.0, 223.0, 112.0, 0.0].map(|v| v / 255.0);
let hud = t.hud.as_ref().unwrap();
assert_eq!(texel(&device, &queue, encoder, hud), stored);
// With HDR Lighting off or under a pack, Horizon lifts nothing: the screenshot is the
// SDR picture, clipped at white.
hdr.lighting = false;
for gamma in [0.5f32, 0.9, 1.0] {
let w = [gamma, gamma / 2.0, 0.0];
// Uncovered, the HUD's target holds the world as drawn; the icon ignores the HUD.
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
let got = shoot(&mut hdr, w.map(f64::from), [0.25; 3], 1.0, true);
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
}
// Partly covered, the 8-bit screenshot is the HUD's target as drawn.
let got = shoot(
&mut hdr,
[1.0, 0.5, 0.0],
[0.75, 0.875, 0.4375],
128.0 / 255.0,
false,
);
assert_eq!(
got.map(|v| (v * 255.0).round()),
[191.0, 223.0, 112.0, 255.0]
);
}
#[test]
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
let Some((device, queue)) = gpu() else {
@@ -1,6 +1,10 @@
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
/// The HUD's own target on the layered frame (`Hdr::hud`): 8-bit, so each HUD draw clips at
/// white and rounds as on vanilla's frame. The coverage pipelines are the only ones that draw
/// on it.
pub(crate) const HUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub(crate) fn supported(
attachments: u32,
@@ -73,8 +77,9 @@ impl Pipeline {
}
}
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
/// Keep the primary output and all its blend factors, including destination alpha, on the
/// HUD's target (`HUD_FORMAT`). Wrap the final shader result, so pack text's alpha, early
/// returns and discard all apply to coverage.
/// `None` for a fragment shader without a colour output at location 0.
pub(super) fn pipeline(
device: &wgpu::Device,
@@ -88,7 +93,8 @@ pub(super) fn pipeline(
wgpu::ShaderSource::Naga(m) => m.into_owned(),
_ => return None,
};
let primary = fragment.targets.first()?.clone()?;
let mut primary = fragment.targets.first()?.clone()?;
primary.format = HUD_FORMAT;
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
let additive = matches!(
(blend.src_factor, blend.dst_factor),
@@ -164,16 +164,24 @@ fn fullscreen_pass(
}
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
fn tonemap_entries() -> String {
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it. A pack's
/// `[tonemap]` is held to `frame.headroom`. Horizon's own (`default`) is a clip at white in
/// SDR; in HDR it leaves the light as it is for Horizon's tone curve (`Hdr::tonemap`), the
/// one HDR without a pack uses.
fn tonemap_entries(default: bool) -> String {
let held = if default {
"select(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), horizon_sanitize(out), frame.hdr == 1u)"
} else {
"min(horizon_sanitize(out), vec3<f32>(frame.headroom))"
};
StageKind::Tonemap.entries(Api::V0_2).replace(
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
&format!("return vec4<f32>(horizon_encode({held}), 1.0);"),
)
}
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
/// The tonemap of a 0.2 pack without `[tonemap]`: the picture as it is, held as
/// `tonemap_entries` holds Horizon's.
const DEFAULT_TONEMAP: &str =
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
@@ -350,6 +358,9 @@ pub struct NativeFrame {
pub api: Api,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
/// The HDR display a 0.2 pack's picture goes to this frame (`Hdr::display`: headroom,
/// paper white, peak and black level), set before `write`; `None` is SDR.
pub display: Option<[f32; 4]>,
}
/// A depth texture of `size` the world's depth is copied into.
@@ -559,6 +570,7 @@ impl NativeFrame {
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
mirror: None,
display: None,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
@@ -799,6 +811,13 @@ impl NativeFrame {
if self.api == Api::V0_2 {
inputs.sky_color = decoded(inputs.sky_color);
inputs.fog_color = decoded(inputs.fog_color);
if let Some([headroom, paper_white, peak, black_level]) = self.display {
inputs.hdr = 1;
inputs.headroom = headroom;
inputs.paper_white = paper_white;
inputs.peak = peak;
inputs.black_level = black_level;
}
}
if !self.reflects {
inputs.has_reflection = 0;
@@ -1054,7 +1073,7 @@ impl NativePost {
let source = tonemap.map_or_else(
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|s| s.source.clone(),
) + &tonemap_entries();
) + &tonemap_entries(tonemap.is_none());
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
let module = naga::front::wgsl::parse_str(&source)
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
@@ -1118,6 +1137,11 @@ impl NativePost {
})
}
/// Whether the pack has a `[tonemap]` of its own, which replaces Horizon's tone curve.
pub fn tonemaps(&self) -> bool {
self.display.as_ref().is_some_and(|d| d.label.is_some())
}
/// The passes' labels, in order, then the pack's tonemap's.
pub fn labels(&self) -> Vec<&str> {
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
@@ -1557,8 +1581,8 @@ pub fn frame_inputs(
let k = super::native_shadow::distortion(s.distance, s.resolution);
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
}),
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, also
// with HDR on, until HDR output is served for 0.2 packs.
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, which
// `NativeFrame::display` replaces on an HDR display.
hdr: 0,
headroom: 1.0,
paper_white: 80.0,
@@ -2484,7 +2508,7 @@ file = \"b.wgsl\"
/// an 8-bit step), at alpha 1.
#[test]
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
assert!(tonemap_entries(false).contains("horizon_encode(min(horizon_sanitize(out)"));
let Some((device, queue)) = device() else {
return;
};
@@ -2600,4 +2624,188 @@ file = \"b.wgsl\"
);
}
}
/// A 0.2 pack's `Frame` describes the HDR display its picture goes to (`display`), and SDR
/// without one: `hdr` 0, `headroom` 1, `paper_white` and `peak` 80, `black_level` 0.
#[test]
fn a_0_2_frame_describes_the_display_it_goes_to() {
let Some((device, _)) = device() else {
return;
};
let hud = crate::hud::HudBatch::new([4, 4]);
let frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let mut native = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
assert_eq!(
fields(&native.next_inputs(&frame, [4, 4])),
(0, 1.0, 80.0, 80.0, 0.0)
);
native.display = Some([4.0, 250.0, 1000.0, 0.05]);
assert_eq!(
fields(&native.next_inputs(&frame, [4, 4])),
(1, 4.0, 250.0, 1000.0, 0.05)
);
// Read every frame: a change of display shows on the next one.
native.display = Some([2.0, 300.0, 600.0, 0.0]);
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).1, 2.0);
native.display = None;
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).0, 0);
}
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
#[test]
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
let Some((device, queue)) = device() else {
return;
};
// Light 8 times paper white, as the world's float frame stores it, in gamma.
let light = 8.0f32;
let (paper, peak) = (203.0, 1000.0);
let headroom = peak / paper;
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
let shown = |pack_curve: bool, hdr: bool| {
let (stage, file, source) = if pack_curve {
(
"[tonemap]",
"t.wgsl",
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n",
)
} else {
(
"[sky]",
"s.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
)
};
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
{stage}\nfile = \"{file}\"\n"
);
let files = [(MANIFEST, manifest.as_str()), (file, source)]
.into_iter()
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.collect();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let tonemap = report.stages.iter().find(|s| s.kind == StageKind::Tonemap);
let format = wgpu::TextureFormat::Rgba16Float;
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let inputs = FrameInputs {
hdr: u32::from(hdr),
headroom: if hdr { headroom } else { 1.0 },
..Default::default()
};
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
let mut post = NativePost::new(
&device,
Vec::new(),
&report.manifest,
&[],
format,
&frame,
tonemap,
)
.unwrap();
assert_eq!(post.tonemaps(), pack_curve);
let mut display = super::super::hdr::Hdr::new(
&device,
&queue,
format,
super::super::hdr::Output::Pq,
true,
);
(display.paper_white, display.peak) = (paper, peak);
let screen = display.frame(&device, [4, 4]);
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage);
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage);
let [color, depth] = [&color, &depth].map(|t| t.create_view(&Default::default()));
let mut encoder = device.create_command_encoder(&Default::default());
let g = f64::from(light.powf(1.0 / 2.2));
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: g,
g,
b: g,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
let view = screen.create_view(&Default::default());
let world = (&color, &depth, &frame.surface_view);
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
// The renderer's rule, then its curve, as it runs them after the pack's chain.
if crate::gfx::horizon_tone_curve(hdr, true, post.tonemaps()) {
display.begin(&queue, true);
display.tonemap(&mut encoder, None);
}
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
half::f16::from_le_bytes([bytes[0], bytes[1]])
.to_f32()
.powf(2.2)
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.01 * want;
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
// the headroom first, or mapped twice, it would land lower.
let horizon = shown(false, true);
assert!(near(horizon, 4.82043), "{horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true);
assert!(near(pack, headroom), "{pack}");
// SDR: a clip at white, the pack's or Horizon's.
for pack_curve in [false, true] {
let sdr = shown(pack_curve, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
}
@@ -765,6 +765,8 @@ impl PackAtlas {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
}
@@ -330,8 +330,30 @@ fn sample(
},
count: None,
};
let samplers: Vec<wgpu::BindGroupLayoutEntry> = (program.interface.samplers.iter())
.flat_map(|s| {
[
wgpu::BindGroupLayoutEntry {
binding: s.binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: s.binding + 1,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
]
})
.collect();
let entries = [
vec![binding(0, false)],
[vec![binding(0, false)], samplers].concat(),
if flat {
vec![binding(0, true), binding(1, true)]
} else {
@@ -367,20 +389,54 @@ fn sample(
vec![]
},
];
// The program's samplers (water's boats' patches, `horizonWaterMask`): a 1x1 stand-in each,
// as the pack binds when there is none, its sampler beside it.
let stand_in = 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::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let nearest = device.create_sampler(&Default::default());
let samplers = &program.interface.samplers;
let groups: Vec<_> = layouts
.iter()
.zip(bindings)
.map(|(layout, buffers)| {
.enumerate()
.map(|(group, (layout, buffers))| {
let mut entries: Vec<_> = buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect();
if group == 0 {
for s in samplers {
entries.push(wgpu::BindGroupEntry {
binding: s.binding,
resource: wgpu::BindingResource::TextureView(&stand_in),
});
entries.push(wgpu::BindGroupEntry {
binding: s.binding + 1,
resource: wgpu::BindingResource::Sampler(&nearest),
});
}
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
entries: &entries,
})
})
.collect();
@@ -129,6 +129,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
far_depth: false,
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
paged_maps: true,
water_mask: false,
water_patches: false,
}
}
@@ -193,6 +195,8 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -227,6 +231,9 @@ pub(super) struct Draw {
pub slot: u32,
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
pub portal: bool,
/// A boat's water patch (`WATER_MASK_OVERLAY`): depth only, with the program's pipeline that
/// writes no colour.
pub water_mask: bool,
}
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
@@ -455,12 +462,14 @@ impl PackEntities {
casts,
overlay,
false,
false,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
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 water_patches = Vec::new();
let mut without_fluids = Vec::new();
for (span, range, material) in material_runs(entities, spans, drawn) {
let run_start = range.start as u32;
@@ -490,19 +499,27 @@ 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 = (matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
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));
let parts: [(&[Vertex], Part); 3] = if split {
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10)))
// A boat's water patch: depth only (`Part::WaterMask`).
|| whole
.chunks(4)
.any(|q| q[0].layer & 0xffff == super::entity_batch::WATER_MASK_OVERLAY);
let parts: [(&[Vertex], Part); 4] = if split {
other.clear();
portal_faces.clear();
beam_faces.clear();
water_patches.clear();
for quad in whole.chunks(4) {
match quad[0].layer & 0xffff {
9 | 10 => portal_faces.extend_from_slice(quad),
6 => beam_faces.extend_from_slice(quad),
super::entity_batch::WATER_MASK_OVERLAY => {
water_patches.extend_from_slice(quad)
}
_ => other.extend_from_slice(quad),
}
}
@@ -510,9 +527,15 @@ impl PackEntities {
(&other, Part::Plain),
(&portal_faces, Part::Portal),
(&beam_faces, Part::Beam),
(&water_patches, Part::WaterMask),
]
} else {
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
[
(whole, Part::Plain),
(&[], Part::Plain),
(&[], Part::Plain),
(&[], Part::Plain),
]
};
for (run, part) in parts {
if run.is_empty() {
@@ -521,6 +544,8 @@ impl PackEntities {
let program = match part {
Part::Portal => Some(BLOCK),
Part::Beam => Some(BEAM),
// Drawn by the entity program, whose depth-only pipeline it takes.
Part::WaterMask => material.filter(|&p| p == ENTITY),
Part::Plain => material.filter(|&p| {
plain(run)
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
@@ -529,7 +554,8 @@ impl PackEntities {
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
// draws them); beams do not.
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
let casts = !matches!(part, Part::Beam | Part::WaterMask)
&& program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
@@ -541,8 +567,13 @@ impl PackEntities {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
// A run copied whole: its vertices keep their order, for `at_velocity`.
if !split {
// A run copied whole keeps its vertices' order, for `at_velocity`; so does the
// start of a split one copied as it is (a boat's hull before its water patch).
let in_order = !split
|| (part == Part::Plain
&& bytemuck::cast_slice::<_, u8>(&whole[..run.len().min(whole.len())])
== bytemuck::cast_slice::<_, u8>(run));
if in_order {
copied.push((run_start, posed + start, run.len() as u32));
}
if program == DESTROY {
@@ -563,6 +594,7 @@ impl PackEntities {
casts,
overlay,
portal,
part == Part::WaterMask,
);
}
}
@@ -634,6 +666,7 @@ impl PackEntities {
casts: bool,
overlay: u32,
portal: bool,
water_mask: bool,
) {
let (entity_id, block_id) = match program {
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
@@ -658,6 +691,7 @@ impl PackEntities {
shadow: casts,
slot: self.draws.len() as u32,
portal,
water_mask,
});
}
}
@@ -692,6 +726,8 @@ enum Part {
Plain,
Portal,
Beam,
/// A boat's water patch: depth only (`Draw::water_mask`).
WaterMask,
}
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
@@ -1446,6 +1482,79 @@ mod gpu_span_tests {
);
}
/// A boat's water patch (`RenderTypes.waterMask`) is a draw of its own, depth only, that casts
/// no shadow; with no pack program for entities, Horizon draws it with the boat.
#[test]
fn a_boats_water_patch_draws_depth_only() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let mask = super::super::entity_batch::WATER_MASK_OVERLAY;
// One boat: a hull face, then the water patch.
let cpu: Vec<Vertex> = [0, 0, 0, 0, mask, mask, mask, mask]
.into_iter()
.map(|layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
})
.collect();
let spans = [EntitySpan {
start: 0,
id: 7,
stage: render_stage("ENTITIES"),
..Default::default()
}];
let mut rest = Vec::new();
let shadows = Casters {
entities: true,
..Default::default()
};
let mut prepare = |drawn, rest: &mut Vec<Vertex>| {
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
drawn,
([0.; 4], [1; 2]),
shadows,
rest,
None,
(&[], &[]),
);
let draws = (geometry.draws.iter())
.map(|d| (d.base, d.program, d.pack, d.shadow, d.water_mask))
.collect::<Vec<_>>();
let heights: Vec<f32> = geometry.scratch.iter().map(|v| v.pos[1]).collect();
(draws, heights)
};
assert_eq!(
prepare([true; PROGRAMS], &mut rest),
(
vec![
(0, ENTITY, true, true, false),
(4, ENTITY, true, false, true)
],
// Where they lie: the patch at the water line, not moved.
vec![0.0; 8]
)
);
assert!(rest.is_empty());
let mut no_entities = [true; PROGRAMS];
no_entities[ENTITY] = false;
let (draws, _) = prepare(no_entities, &mut rest);
assert!(draws.iter().all(|d| !d.2), "{draws:?}");
assert_eq!(rest.len(), 8, "the boat and its patch stay Horizon's");
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
@@ -53,6 +53,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -91,6 +91,8 @@ void main() {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
paged_maps: true,
water_mask: false,
water_patches: false,
..Default::default()
}),
..Default::default()
+150 -13
View File
@@ -19,6 +19,7 @@ use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
use super::pack_entities::{PROGRAMS, PackEntities};
@@ -125,6 +126,8 @@ enum Source {
ShadowDepth(usize, bool),
/// `shadowcolor0/1` read outside the shadow programs (in them they are `Color`).
ShadowColor(usize),
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
WaterMask,
}
#[derive(Clone)]
@@ -160,9 +163,11 @@ impl SamplerKind {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Depth
| Source::Depth1
| Source::Depth2
| Source::ShadowDepth(_, false)
| Source::WaterMask => Self::Nearest,
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels. Between levels, Iris 1.11.7
@@ -406,6 +411,9 @@ struct Pass {
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
/// The entity program without colour writes, for the boats' water patches
/// (`RenderTypes.waterMask`: depth only, so no water shows inside a hull).
water_mask: Option<Slot<wgpu::RenderPipeline>>,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
@@ -511,6 +519,8 @@ struct Targets {
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// The boats' water patches' depth, when translucent terrain reads it (`Source::WaterMask`).
water_mask: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -648,6 +658,11 @@ pub struct PackPost {
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// Translucent terrain reads the boats' water patches (`Targets::water_mask`).
water_mask: bool,
/// The water patches' depth holds patches (or was never cleared): cleared before the next
/// frame's are drawn.
water_mask_dirty: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -907,6 +922,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1647,9 +1663,11 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
Source::Depth
| Source::Depth1
| Source::Depth2
| Source::ShadowDepth(..)
| Source::WaterMask => wgpu::TextureSampleType::Float { filterable: false },
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
Source::Lightmap => float,
_ => float,
@@ -1933,6 +1951,7 @@ fn build_pass(
custom: &[(String, String)],
draws: &wgpu::BindGroupLayout,
pack: &Resources<'_>,
water_mask: bool,
) -> Option<Pass> {
let features = device.features();
// The frame's uniforms, then the pack's custom ones.
@@ -2147,9 +2166,32 @@ fn build_pass(
)
})
});
(layout, pack.render_pipeline(device, job), sky_parts)
// The water patches' pipeline: the same program, depth only.
let water_mask = water_mask.then(|| {
let targets = (job.targets.iter())
.map(|t| {
t.clone().map(|t| wgpu::ColorTargetState {
write_mask: wgpu::ColorWrites::empty(),
..t
})
})
.collect();
pack.render_pipeline(
device,
RenderJob {
targets,
..job.clone()
},
)
});
(
layout,
pack.render_pipeline(device, job),
sky_parts,
water_mask,
)
});
let (layout, pipeline, sky_parts) = match created {
let (layout, pipeline, sky_parts, water_mask) = match created {
Ok(created) => created,
Err(e) => {
notes.push(format!("{}: skipped: {e}", p.name));
@@ -2189,6 +2231,7 @@ fn build_pass(
group: p.group,
pipeline: Pipeline::Render(pipeline),
sky_parts,
water_mask,
layout,
samplers,
writes,
@@ -2328,6 +2371,7 @@ fn build_compute(
local,
},
sky_parts: None,
water_mask: None,
layout,
samplers,
writes: Vec::new(),
@@ -2610,6 +2654,8 @@ impl PackPost {
})
})
.transpose()?;
// The next pass built also gets a depth-only pipeline for the boats' water patches.
let water_mask_next = std::cell::Cell::new(false);
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) {
@@ -2660,6 +2706,7 @@ impl PackPost {
&custom_names,
&draws.layout,
&pack,
water_mask_next.get(),
)?
};
pass.compute.append(&mut computes);
@@ -2835,7 +2882,11 @@ impl PackPost {
pass
})
};
let mut kind = 0;
let entity_passes = programs.map(|p| {
// The entity program draws the boats, and their water patches (`Draw::water_mask`).
water_mask_next.set(kind == super::pack_entities::ENTITY);
kind += 1;
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
@@ -2858,6 +2909,7 @@ impl PackPost {
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
late[super::pack_entities::PARTICLE] = false;
}
water_mask_next.set(false);
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| paged(build(p, Some(g))))
@@ -3085,6 +3137,9 @@ impl PackPost {
let depth1 = (water.is_some() || hand.is_some())
&& (reads_depth(Source::Depth1) || (hand.is_none() && reads_depth2));
let depth2 = hand.is_some() && reads_depth2;
let water_mask = (water.iter())
.flat_map(|p| &p.samplers)
.any(|s| s.source == Source::WaterMask);
let every_pass = || {
(passes
.iter()
@@ -3340,6 +3395,8 @@ impl PackPost {
hand_water,
depth1,
depth2,
water_mask,
water_mask_dirty: true,
shadow_targets,
shadow_terrain,
portal_rect: [0.0; 4],
@@ -3671,6 +3728,7 @@ impl PackPost {
}
}
Source::Depth => &targets.depth,
Source::WaterMask => targets.water_mask.as_ref().map_or(&self.dummy, |m| &m.1),
// Before translucent terrain the two are the same.
Source::Depth1 => match &targets.depth1 {
Some(d)
@@ -3864,6 +3922,16 @@ impl PackPost {
&mut self,
encoder: &'e mut wgpu::CommandEncoder,
pass: &Pass,
) -> wgpu::RenderPass<'e> {
self.begin_with(encoder, pass, false)
}
/// `begin`, or with `water_mask` into the water patches' depth instead of the frame's.
fn begin_with<'e>(
&mut self,
encoder: &'e mut wgpu::CommandEncoder,
pass: &Pass,
water_mask: bool,
) -> wgpu::RenderPass<'e> {
let targets = self.targets.as_ref().expect("resized");
let load = wgpu::Operations {
@@ -3877,7 +3945,8 @@ impl PackPost {
_ => (super::pack_shadow::load(), [load; SHADOW_COLORS]),
};
let shadow = self.shadow_targets.as_ref().filter(|_| pass.group.shadow());
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
@@ -3898,7 +3967,10 @@ impl PackPost {
color_attachments: &attachments,
timestamp_writes,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: shadow.map_or(&targets.depth, |s| &s.depth.1),
view: match &targets.water_mask {
Some((_, view)) if water_mask => view,
_ => shadow.map_or(&targets.depth, |s| &s.depth.1),
},
depth_ops: Some(depth_ops),
stencil_ops: None,
}),
@@ -4081,8 +4153,11 @@ impl PackPost {
let mut draws = geometry
.draws
.iter()
.filter(|d| d.pack && d.program == program)
.filter(|d| d.pack && d.program == program && !d.water_mask)
.peekable();
let masks: Vec<_> = (geometry.draws.iter())
.filter(|d| d.pack && d.program == program && d.water_mask)
.collect();
if draws.peek().is_none() {
continue;
}
@@ -4124,6 +4199,35 @@ impl PackPost {
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
}
}
// The boats' water patches, depth only, into their own depth: translucent terrain
// discards what lies behind them, and the frame's depth stays the pack's.
if let Some(mask) = pass.water_mask.as_ref().and_then(|m| m.get())
&& !masks.is_empty()
&& self
.targets
.as_ref()
.is_some_and(|t| t.water_mask.is_some())
{
let mut rp = self.begin_with(encoder, pass, true);
rp.set_pipeline(mask);
rp.set_bind_group(0, &group, &[]);
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
rp.set_bind_group(1, &geometry.table_group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
for d in &masks {
if pass.draw {
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
}
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
}
drop(rp);
self.water_mask_dirty = true;
}
self.mips_valid &= !pass.written;
}
self.entities = Some(entities);
@@ -4905,6 +5009,16 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let water_mask = self.water_mask.then(|| {
let t = texture(
"Shader pack water patches",
depth32,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = t.create_view(&Default::default());
(t, view)
});
self.water_mask_dirty = true;
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -4939,6 +5053,7 @@ impl PackPost {
depth_texture,
depth1,
depth2,
water_mask,
mips,
});
self.forget_groups();
@@ -5038,7 +5153,8 @@ impl PackPost {
}
let group = self.bind(device, queue, encoder, pass, size);
let t = self.shadow_targets.as_ref().expect("shadow pass");
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
for &(location, buffer) in &pass.writes {
let l = location as usize;
if attachments.len() <= l {
@@ -5067,6 +5183,7 @@ impl PackPost {
}
rp.draw(0..6, 0..1);
}
drop(attachments);
if let Some(t) = &mut self.shadow_targets {
t.main ^= pass.flip;
}
@@ -5193,6 +5310,24 @@ impl PackPost {
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
// Last frame's water patches (or a new texture's zeros) out: depth 1 is no patch.
if self.water_mask_dirty
&& let Some((_, view)) = self.targets.as_ref().and_then(|t| t.water_mask.as_ref())
{
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack water patches clear"),
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
self.water_mask_dirty = false;
}
// A resolution scale leaves the picture smaller (or larger) than the window: the pack's
// `final` draws it at the picture's size, and one copy takes it to the window (#9291).
let final_view = if size != window && self.passes.iter().any(|p| p.group == Group::Final) {
@@ -5540,7 +5675,8 @@ impl PackPost {
.as_ref()
.map(|t| &t[usize::from(self.main & 1 << i == 0)])
};
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
SmallVec::new();
// Vertex coverage and fragment discard can leave alternate pixels untouched.
// The unused sink can still skip loading its old contents.
let ops = if !pass.sink || pass.viewport.is_some() {
@@ -6212,6 +6348,7 @@ mod tests {
&[],
&draws.layout,
&pack,
false,
)
.unwrap_or_else(|| panic!("the pass builds: {notes:?}"));
// colortex3 holds (0.75, 0.5, 0.25, 0.5) before the pass.
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -720,6 +720,8 @@ fn shadow_depth_map_reads(
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}),
..Default::default()
};
@@ -47,6 +47,8 @@ fn pack() -> Post {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
@@ -4,7 +4,7 @@
//! uniform (set 1) that the vertex inputs read. `gbuffers_skytextured` draws the frame's sun and
//! moon quads, textured from the shared atlas.
use std::ops::Range;
use std::{ops::Range, sync::Mutex};
use wgpu::util::DeviceExt;
@@ -105,6 +105,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: true,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -133,6 +135,8 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: true,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -223,6 +227,7 @@ pub(super) struct PackSky {
uniform: wgpu::Buffer,
pub layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
celestial_scratch: Mutex<Vec<Celestial>>,
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
ranges: [Range<u32>; 5],
}
@@ -302,6 +307,7 @@ impl PackSky {
uniform,
layout,
group,
celestial_scratch: Mutex::new(Vec::new()),
ranges: [sky, sunset, stars, void, end_fan],
}
}
@@ -359,15 +365,21 @@ impl PackSky {
.0
.iter()
.take(CELESTIAL_QUADS);
let triangles: Vec<Celestial> = quads
.clone()
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
.map(|v| Celestial {
pos: turn.transform_vector3(v.pos.into()).into(),
uv: v.uv,
color: v.color,
})
.collect();
let mut triangles = self
.celestial_scratch
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
triangles.clear();
triangles.extend(
quads
.clone()
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
.map(|v| Celestial {
pos: turn.transform_vector3(v.pos.into()).into(),
uv: v.uv,
color: v.color,
}),
);
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
let [sky, sunset, stars, void, fan] = self.ranges.clone();
let part = |stage, textured, range| Part {
@@ -374,6 +374,41 @@ pub const NAMES: &[&str] = &[
"horizonDynamicLight13",
"horizonDynamicLight14",
"horizonDynamicLight15",
// Horizon's own: the boats' water patches, for the shadow pass's water (`water_patches`):
// how many, then each one's corner and y range, and its two horizontal edges.
"horizonWaterPatchCount",
"horizonWaterPatch0",
"horizonWaterPatch1",
"horizonWaterPatch2",
"horizonWaterPatch3",
"horizonWaterPatch4",
"horizonWaterPatch5",
"horizonWaterPatch6",
"horizonWaterPatch7",
"horizonWaterPatch8",
"horizonWaterPatch9",
"horizonWaterPatch10",
"horizonWaterPatch11",
"horizonWaterPatch12",
"horizonWaterPatch13",
"horizonWaterPatch14",
"horizonWaterPatch15",
"horizonWaterPatch16",
"horizonWaterPatch17",
"horizonWaterPatch18",
"horizonWaterPatch19",
"horizonWaterPatch20",
"horizonWaterPatch21",
"horizonWaterPatch22",
"horizonWaterPatch23",
"horizonWaterPatch24",
"horizonWaterPatch25",
"horizonWaterPatch26",
"horizonWaterPatch27",
"horizonWaterPatch28",
"horizonWaterPatch29",
"horizonWaterPatch30",
"horizonWaterPatch31",
"isElytraFlying",
"cloudHeight",
];
@@ -870,6 +905,7 @@ impl FrameUniforms {
),
};
let patches = water_patches(frame.entities);
let value = |name: &str| -> Value {
let v3 = |v: Vec3| Value::f(&v.to_array());
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
@@ -960,6 +996,11 @@ impl FrameUniforms {
"gbufferProjectionInverse" => m4(projection_inverse),
"gbufferPreviousModelView" => m4(previous_view),
"gbufferPreviousProjection" => m4(previous_projection),
"horizonWaterPatchCount" => Value::i(&[patches.len() as i32]),
n if water_patch_index(n).is_some() => Value::f(
&(water_patch_index(n).and_then(|k| patches.get(k / 2).map(|p| p[k % 2])))
.unwrap_or([0.0; 4]),
),
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
n if dynamic_light_index(n).is_some() => Value::f(
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
@@ -1847,6 +1888,37 @@ pub(super) mod initial_history_tests {
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
}
#[test]
fn a_boats_water_patch_becomes_its_box() {
let vertex = |pos: [f32; 3], layer: u32| super::super::Vertex {
pos,
layer,
..bytemuck::Zeroable::zeroed()
};
// A patch 1.75 by 1 block, 0.2 thick, turned 30 degrees, at the hull's water line;
// a hull quad on each side of it.
let (c, s) = (30f32.to_radians().cos(), 30f32.to_radians().sin());
let at = |x: f32, y: f32, z: f32| [2.0 + x * c - z * s, y, 3.0 + x * s + z * c];
let mask = super::super::WATER_MASK_OVERLAY;
let mut quads = vec![vertex([0.0; 3], 0); 4];
for y in [-1.2, -1.0] {
quads.extend(
[(0.0, 0.0), (1.75, 0.0), (1.75, 1.0), (0.0, 1.0)]
.map(|(x, z)| vertex(at(x, y, z), mask)),
);
}
quads.extend(vec![vertex([5.0; 3], 0); 4]);
let boxes = water_patches(&quads);
assert_eq!(boxes.len(), 1);
let [[x, z, low, high], [ax, az, bx, bz]] = boxes[0];
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(close(x, 2.0) && close(z, 3.0) && close(low, -1.2) && close(high, -1.0));
// The edges: the 1-block one first (nearer), then the 1.75-block one.
assert!(close(ax, -s) && close(az, c), "{boxes:?}");
assert!(close(bx, 1.75 * c) && close(bz, 1.75 * s), "{boxes:?}");
assert!(water_patches(&quads[..4]).is_empty());
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
@@ -2642,6 +2714,67 @@ pub(super) mod initial_history_tests {
}
}
/// `horizonWaterPatch<k>`'s `k`: patch `k / 2`, its corner and y range (even) or edges (odd).
fn water_patch_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonWaterPatch")?.parse().ok()?;
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
}
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
/// `WATER_PATCHES`, each as a camera-relative box: a corner's x and z and the patch's lowest and
/// highest y, then its two horizontal edges from that corner. A boat rocks a little: its patch's
/// corners are told apart in x and z within 2 cm.
pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
let mut out = Vec::new();
let mut points: Vec<[f32; 3]> = Vec::new();
let flush = |points: &mut Vec<[f32; 3]>, out: &mut Vec<[[f32; 4]; 2]>| {
if let Some(b) = patch_box(points)
&& out.len() < horizon_shaders::WATER_PATCHES
{
out.push(b);
}
points.clear();
};
for quad in entities.as_chunks::<4>().0 {
if quad[0].layer & 0xffff == super::WATER_MASK_OVERLAY {
points.extend(quad.iter().map(|v| v.pos));
} else if !points.is_empty() {
flush(&mut points, &mut out);
}
}
flush(&mut points, &mut out);
out
}
/// A water patch's box (`water_patches`) from its corners.
fn patch_box(points: &[[f32; 3]]) -> Option<[[f32; 4]; 2]> {
let first = points.first()?;
let (low, high) = points
.iter()
.fold((f32::MAX, f32::MIN), |(l, h), p| (l.min(p[1]), h.max(p[1])));
let mut corners: Vec<[f32; 2]> = Vec::new();
for p in points {
let c = [p[0], p[2]];
if !corners
.iter()
.any(|q| (q[0] - c[0]).abs() < 0.02 && (q[1] - c[1]).abs() < 0.02)
{
corners.push(c);
}
}
let origin = [first[0], first[2]];
let mut others: Vec<[f32; 2]> = corners.into_iter().filter(|c| *c != origin).collect();
if others.len() < 2 {
return None;
}
let distance = |c: &[f32; 2]| (c[0] - origin[0]).powi(2) + (c[1] - origin[1]).powi(2);
others.sort_by(|a, b| distance(a).total_cmp(&distance(b)));
// The two nearest corners are the edges' ends; the farthest is across.
let edge = |c: [f32; 2]| [c[0] - origin[0], c[1] - origin[1]];
let (a, b) = (edge(others[0]), edge(others[1]));
Some([[origin[0], origin[1], low, high], [a[0], a[1], b[0], b[1]]])
}
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
fn dynamic_light_index(name: &str) -> Option<usize> {
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
@@ -47,6 +47,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
@@ -9,8 +9,11 @@
#[derive(Default)]
pub struct Readback {
texture: Option<wgpu::Texture>,
/// The 8-bit texture `finish` put the frame on, where the swapchain image can't be read
/// back; `present` then draws it instead of `texture`.
finished: Option<wgpu::Texture>,
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
/// A frame drew into `texture` and still has to reach the swapchain.
/// A frame drew into `texture` (or `finished`) and still has to reach the swapchain.
drawn: bool,
}
@@ -53,7 +56,47 @@ impl Readback {
self.texture.clone()
}
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
/// The frame `target` gave, finished onto 8 bits of the swapchain image `surface`'s format,
/// clipped and rounded as an 8-bit frame holds it, for what draws over it from here on
/// (`Renderer::overlay_format`): `surface` itself, or, for a screenshot it can't be copied
/// from, a texture of its own that `present` then draws there.
pub fn finish(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::Texture,
screenshot: bool,
) -> Option<wgpu::Texture> {
let frame = self.texture.clone().filter(|_| self.drawn)?;
let format = surface.format().remove_srgb_suffix();
let target = if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
self.drawn = false;
surface.clone()
} else {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon finished frame"),
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.finished = Some(texture.clone());
texture
};
let view = target.create_view(&wgpu::TextureViewDescriptor {
format: Some(format),
..Default::default()
});
self.blit(device, encoder, &frame, &view, format);
Some(target)
}
/// Draws the frame `target` (or `finish`) gave onto the swapchain image `surface`; nothing
/// when this frame drew straight into it.
pub fn present(
&mut self,
@@ -61,10 +104,11 @@ impl Readback {
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::Texture,
) {
let finished = self.finished.take();
if !std::mem::take(&mut self.drawn) {
return;
}
let Some(texture) = self.texture.clone() else {
let Some(texture) = finished.or_else(|| self.texture.clone()) else {
return;
};
self.draw_onto(device, encoder, &texture, surface);
@@ -346,4 +390,68 @@ mod tests {
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
/// `finish` puts a float frame on 8 bits before the overlays draw: on the swapchain image
/// itself, which `present` then leaves alone, or, for a screenshot of an image that can't
/// be copied from, on a texture of its own that the screenshot reads and `present` draws.
#[test]
fn a_float_frame_finishes_on_8_bits_before_the_overlays() {
let Some((device, queue)) = device() else {
return;
};
let float = wgpu::TextureFormat::Rgba16Float;
let color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let check = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let blitter = wgpu::util::TextureBlitter::new(&device, F);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
for (usage, screenshot) in [
(wgpu::TextureUsages::COPY_SRC, false),
(wgpu::TextureUsages::COPY_SRC, true),
(wgpu::TextureUsages::empty(), false),
(wgpu::TextureUsages::empty(), true),
] {
let surface = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| usage,
);
let mut readback = Readback::default();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback
.target(&device, &surface, float, screenshot)
.unwrap();
clear(&mut encoder, &frame, color);
let finished = readback
.finish(&device, &mut encoder, &surface, screenshot)
.unwrap();
assert_eq!(finished.format(), F);
let own = screenshot && usage.is_empty();
assert_eq!(finished == surface, !own, "{usage:?} {screenshot}");
assert_eq!(readback.drawn, own);
let shot = screenshot
.then(|| crate::gfx::copy_frame(&device, &mut encoder, &finished, [70, 3]));
readback.present(&device, &mut encoder, &surface);
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
let shown = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
queue.submit([encoder.finish()]);
if let Some(error) = pollster::block_on(scope.pop()) {
panic!("{usage:?} {screenshot}: {error}");
}
assert_eq!(last_pixel(&device, &shown), [0, 51, 255, 255]);
if let Some(shot) = shot {
assert_eq!(last_pixel(&device, &shot), [0, 51, 255, 255]);
}
assert!(!readback.drawn && readback.finished.is_none());
}
}
}
+192 -34
View File
@@ -19,28 +19,41 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The display's darkest black, in hundredths of a nit.
pub black: u32,
}
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak, black) =
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
REPORTED[2].store(black, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported {
paper_white,
peak,
black,
})
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
pub const PEAK_STEPS: u32 = 52;
/// Peak Brightness's value at `step` of its scale, the slider's and calibration's: 200 to
/// 2000 nits by 50, where most displays' peaks are, then up to PQ's 10 000 by 500.
pub fn peak_nits_at(step: u32) -> u32 {
match step.min(PEAK_STEPS) {
s @ 0..=36 => 200 + s * 50,
s => 2000 + (s - 36) * 500,
}
}
/// Where `nits` sits on Peak Brightness's scale ([`peak_nits_at`]), between steps for a value
/// off it.
pub fn peak_step_of(nits: f32) -> f32 {
let step = if nits <= 2000.0 {
(nits - 200.0) / 50.0
} else {
36.0 + (nits - 2000.0) / 500.0
};
step.clamp(0.0, PEAK_STEPS as f32)
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
@@ -508,6 +521,61 @@ pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static SYSTEM_HDR: AtomicU32 = AtomicU32::new(0);
/// The window's current system HDR state, independent of swapchain availability.
pub fn report_system_hdr(on: Option<bool>) {
SYSTEM_HDR.store(on.map_or(0, |on| if on { 2 } else { 1 }), Ordering::Relaxed);
}
pub fn system_hdr() -> Option<bool> {
match SYSTEM_HDR.load(Ordering::Relaxed) {
1 => Some(false),
2 => Some(true),
_ => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum HdrMode {
#[default]
Off,
On,
Auto,
}
impl HdrMode {
pub const ALL: [Self; 3] = [Self::Off, Self::On, Self::Auto];
pub fn key(self) -> &'static str {
match self {
Self::Off => "false",
Self::On => "true",
Self::Auto => "auto",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::On => "On",
Self::Auto => "Auto",
}
}
fn parse(value: &str, default: Self) -> Self {
match value {
"false" | "off" => Self::Off,
"true" | "on" => Self::On,
"auto" => Self::Auto,
_ => default,
}
}
/// On retains the old switch's intent even before a surface is opened; the renderer
/// still gates output on availability. Auto requires both availability and a live report.
fn wanted(self, offered: bool, system: Option<bool>) -> bool {
match self {
Self::Off => false,
Self::On => true,
Self::Auto => offered && system == Some(true),
}
}
}
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
@@ -783,16 +851,15 @@ pub struct Graphics {
/// Frame generation (Horizon setting), off by default.
pub frame_generation: FrameGeneration,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
pub hdr: HdrMode,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
pub hdr_paper_white: u32,
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// HDR peak brightness the world's highlights reach, in nits (200..=10 000); 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
/// metadata says it.
/// HDR black level: the display's darkest black, in hundredths of a nit; 0, the default,
/// lifts nothing. Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
@@ -870,7 +937,7 @@ impl Default for Graphics {
upscaling_sharpness: 85,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
hdr: HdrMode::Off,
hdr_paper_white: 0,
hdr_peak: 0,
hdr_black_level: 0,
@@ -902,8 +969,8 @@ impl Graphics {
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
g.hdr = false;
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
g.hdr = HdrMode::Off;
g.save(dir);
log::warn!(
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
@@ -1000,7 +1067,7 @@ impl Graphics {
.unwrap_or(1)
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdr" => g.hdr = HdrMode::parse(value, g.hdr),
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -1011,7 +1078,7 @@ impl Graphics {
"hdrPeak" => {
g.hdr_peak = match number(g.hdr_peak) {
0 => 0,
n => n.clamp(200, 2000),
n => n.clamp(200, 10_000),
}
}
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
@@ -1082,7 +1149,7 @@ impl Graphics {
self.upscaling_sharpness,
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
self.hdr.key(),
self.hdr_paper_white,
self.hdr_peak,
self.hdr_black_level,
@@ -1111,10 +1178,14 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// The single decision shared by rendering and the HDR settings page.
pub fn hdr_wanted(&self) -> bool {
self.hdr.wanted(hdr_offered(), system_hdr())
}
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
/// HDR on, with Smooth Gradients.
pub fn wide_frame(&self) -> bool {
self.hdr && self.hdr_smooth_gradients
self.hdr_wanted() && self.hdr_smooth_gradients
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
@@ -1132,13 +1203,10 @@ impl Graphics {
n => n,
}
}
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
let hundredths = match self.hdr_black_level {
0 => reported().map_or(0, |r| r.black),
n => n,
};
hundredths as f32 / 100.0
self.hdr_black_level as f32 / 100.0
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
@@ -1207,6 +1275,53 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
mod tests {
use super::*;
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
for (value, expected) in [
("true", HdrMode::On),
("false", HdrMode::Off),
("on", HdrMode::On),
("off", HdrMode::Off),
("auto", HdrMode::Auto),
("unknown", HdrMode::Off),
] {
let g = Graphics::parse(&format!("hdr:{value}\n"));
assert_eq!(g.hdr, expected, "{value}");
assert_eq!(Graphics::parse(&g.serialize()).hdr, expected);
let saved = g.serialize();
let value = saved.lines().find_map(|l| l.strip_prefix("hdr:")).unwrap();
// The old boolean parser keeps its Off default for an unknown value.
assert_eq!(value == "true", expected == HdrMode::On);
}
assert_eq!(Graphics::parse("hdr:true\nhdr:unknown\n").hdr, HdrMode::On);
}
#[test]
fn hdr_auto_needs_a_positive_system_report_and_an_output() {
for (offered, system, expected) in [
(false, None, false),
(false, Some(false), false),
(false, Some(true), false),
(true, None, false),
(true, Some(false), false),
(true, Some(true), true),
] {
assert!(!HdrMode::Off.wanted(offered, system));
assert!(HdrMode::On.wanted(offered, system));
assert_eq!(
HdrMode::Auto.wanted(offered, system),
expected,
"{offered} {system:?}"
);
}
let readings = [Some(false), Some(true), Some(false), None, Some(true)];
assert_eq!(
readings.map(|r| HdrMode::Auto.wanted(true, r)),
[false, true, false, false, true]
);
}
#[test]
fn the_api_choice_matches_the_system() {
let choices = GraphicsApi::choices();
@@ -1267,7 +1382,7 @@ mod tests {
upscaling_sharpness: 40,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
hdr: HdrMode::On,
hdr_paper_white: 240,
hdr_peak: 1400,
hdr_black_level: 12,
@@ -1352,9 +1467,36 @@ mod tests {
};
assert_eq!(Graphics::parse(&g.serialize()).gpu_culling, mode);
}
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
// 0 is Auto; anything else is a peak, up to PQ's 10 000 nits. Older files' values
// (up to 2000) read as they were saved.
for (file, peak) in [
("hdrPeak:0\n", 0),
("hdrPeak:50\n", 200),
("hdrPeak:1234\n", 1234),
("hdrPeak:2000\n", 2000),
("hdrPeak:3000\n", 3000),
("hdrPeak:10000\n", 10_000),
("hdrPeak:25000\n", 10_000),
] {
let g = Graphics::parse(file);
assert_eq!(g.hdr_peak, peak, "{file}");
assert_eq!(Graphics::parse(&g.serialize()).hdr_peak, peak, "{file}");
}
assert_eq!(Graphics::parse("hdrPeak:3000\n").peak_nits(), 3000);
// The black level is what's saved, in hundredths of a nit: 0 (Auto in older files)
// lifts nothing, and the most is a nit.
for (file, hundredths, nits) in [
("", 0, 0.0),
("hdrBlackLevel:0\n", 0, 0.0),
("hdrBlackLevel:5\n", 5, 0.05),
("hdrBlackLevel:100\n", 100, 1.0),
("hdrBlackLevel:250\n", 100, 1.0),
] {
let g = Graphics::parse(file);
assert_eq!(g.hdr_black_level, hundredths, "{file}");
assert_eq!(g.black_level_nits(), nits, "{file}");
assert_eq!(Graphics::parse(&g.serialize()), g, "{file}");
}
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
@@ -1385,6 +1527,22 @@ mod tests {
);
}
#[test]
fn peak_scale_steps_by_50_to_2000_then_by_500_to_10000() {
let scale: Vec<u32> = (0..=PEAK_STEPS).map(peak_nits_at).collect();
let fine: Vec<u32> = (200..=2000).step_by(50).collect();
let coarse: Vec<u32> = (2500..=10_000).step_by(500).collect();
assert_eq!(scale, [fine, coarse].concat());
for (step, &nits) in scale.iter().enumerate() {
assert_eq!(peak_step_of(nits as f32), step as f32);
}
// Off the scale, between its neighbours; beyond it, at its ends.
assert_eq!(peak_step_of(1225.0), 20.5);
assert_eq!(peak_step_of(3250.0), 38.5);
assert_eq!(peak_step_of(100.0), 0.0);
assert_eq!(peak_step_of(20_000.0), PEAK_STEPS as f32);
}
#[test]
fn leaf_culling_round_trips_its_stable_names() {
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
@@ -189,11 +189,23 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
pub fn button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
flags: (bool, bool, bool),
) {
button_with_room(batch, atlas, rect, label, flags, 0.0);
}
/// A button whose label leaves `room` at its left, for a controller's glyph drawn there
/// (`widgets::action_glyph_room`).
pub fn button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
(hover, live, primary): (bool, bool, bool),
room: f32,
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
@@ -209,7 +221,15 @@ pub fn button(
}
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
scrolling_label(batch, atlas, label, [x + 2.0, ty, w - 4.0], (y, h), color);
let left = x + 2.0 + room;
scrolling_label(
batch,
atlas,
label,
[left, ty, w - 4.0 - room],
(y, h),
color,
);
}
/// A widget label of `[left, y, width]` inside the widget's `(top, height)`: centred when it fits,
/// else slid (vanilla `AbstractWidget.extractScrollingStringOverContents`) and clipped to the box.
+158 -51
View File
@@ -6,7 +6,7 @@
//! and what they do live here.
use crate::{
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi,
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
},
graphics_style::Cost,
@@ -598,7 +598,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
h(
H::HdrBlackLevel,
None,
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.",
),
h(
H::HdrPeak,
@@ -608,7 +608,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
h(
H::HdrPaperWhite,
None,
"How bright white paper and menus look. Auto: your desktop's white.",
"How bright white looks in the world. Auto: your desktop's white.",
),
h(
H::HdrUiBrightness,
@@ -780,6 +780,9 @@ fn labelled_percent(caption: &str, percent: u32) -> Text {
)
}
/// Peak Brightness's slider: Auto, then each step of the peak's scale.
const PEAK_POSITIONS: u32 = crate::graphics::PEAK_STEPS + 1;
impl H {
fn slider(self, s: &Settings) -> Option<f32> {
let g = &s.horizon;
@@ -805,10 +808,10 @@ impl H {
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then 200..2000 by 50.
// Auto, then the peak's scale.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
n => (n as f32 - 150.0) / 1850.0,
n => (1.0 + crate::graphics::peak_step_of(n as f32)) / PEAK_POSITIONS as f32,
}),
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
@@ -823,8 +826,8 @@ impl H {
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite => Some(41),
H::HdrPeak => Some(37),
H::HdrPaperWhite => Some(48),
H::HdrPeak => Some(PEAK_POSITIONS),
H::HdrBlackLevel => Some(100),
H::HdrUiBrightness => Some(15),
_ => None,
@@ -924,7 +927,7 @@ impl H {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
Setup::Unknown => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
@@ -941,13 +944,7 @@ impl H {
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrBlackLevel => match g.hdr_black_level {
0 => labelled(
"Black Level",
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
),
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
},
H::HdrBlackLevel => labelled("Black Level", format!("{:.2}", g.black_level_nits())),
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
H::HdrKey => {
@@ -995,9 +992,9 @@ impl H {
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
0 => 0,
step => 150 + step * 50,
step => crate::graphics::peak_nits_at(step - 1),
}
}
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
@@ -1079,7 +1076,12 @@ impl H {
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::Hdr => {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
if g.hdr == HdrMode::On && !crate::graphics::hdr_offered() {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
}
}
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -1112,11 +1114,9 @@ impl Item {
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// Why a control can't be used now, when it can't: HDR's page works only where
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on; Translucent Batching switches only where the device can batch
/// Why a control can't be used now: HDR's On requires an offered output, while Auto
/// can also be chosen on a reporting SDR display. Brightness controls need HDR output
/// active; saved values wait for it. Translucent Batching switches where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
@@ -1126,8 +1126,7 @@ impl Item {
}
match self {
Item::Horizon(
H::Hdr
| H::HdrCalibrate
H::HdrCalibrate
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
@@ -1150,7 +1149,7 @@ impl Item {
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
) if !s.horizon.hdr_wanted() => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -1197,7 +1196,6 @@ impl Item {
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
@@ -1443,6 +1441,7 @@ impl Screen for GraphicsScreen {
use crate::graphics as g;
let offers = (
g::hdr_offered(),
g::system_hdr(),
g::smooth_gradients_offered(),
g::translucent_batching_offered(),
g::gpu_culling_offer(),
@@ -1467,6 +1466,8 @@ impl Screen for GraphicsScreen {
.collect(),
})
.collect(),
listed: true,
heading: None,
};
rows().into_iter().map(sheet).collect()
}
@@ -1514,7 +1515,7 @@ impl Screen for GraphicsScreen {
/// settings and the vanilla options on its rows. Not the other options (the narrator, or
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
/// window manager change.
fn undo(&mut self, s: &mut Settings, b: &Settings) {
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
s.horizon = b.horizon.clone();
s.render_distance = b.render_distance;
s.simulation_distance = b.simulation_distance;
@@ -1551,6 +1552,7 @@ impl Screen for GraphicsScreen {
}
}
s.file.fullscreen_resolution = b.file.fullscreen_resolution.clone();
Vec::new()
}
/// Calibration can change what Undo puts back, as a row does.
fn changes(&self, outputs: &[Output]) -> bool {
@@ -1767,14 +1769,14 @@ mod tests {
// Calibration counts as a change too.
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
s.horizon.hdr = true;
s.horizon.hdr = HdrMode::On;
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
press(&mut ui, &mut s, at(calibrate));
assert!(ui.changed());
// From the keyboard: Undo puts it back and goes away.
ui.set_focus(Some(Target::Undo));
assert_eq!(key(&mut ui, &mut s, K::Enter), [CLICK, CHANGED]);
assert!(!s.horizon.hdr && !ui.changed());
assert!(!s.horizon.hdr_wanted() && !ui.changed());
assert_eq!(ui.focused(), Some(Target::Done));
}
@@ -1789,7 +1791,7 @@ mod tests {
assert!(press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)).is_empty());
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.changes_value());
assert!(!ui.release());
assert!(ui.release().is_none());
// At the track's end: the most, then dragged to its start.
assert_eq!(press(&mut ui, &mut s, (tx + tw, r[1] + 4.0)), [DRAGGED]);
assert_eq!(H::ChunkUpdates.slider(&s), Some(1.0));
@@ -1798,8 +1800,8 @@ mod tests {
[DRAGGED]
);
assert_eq!(H::ChunkUpdates.slider(&s), Some(0.0));
assert!(ui.release());
assert!(!ui.release());
assert!(ui.release().is_some());
assert!(ui.release().is_none());
}
#[test]
@@ -1807,7 +1809,7 @@ mod tests {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
let mut s = Settings::default();
s.horizon.hdr = true;
s.horizon.hdr = HdrMode::On;
let item = Item::Horizon(H::HdrSmoothGradients);
// Where the device can't, the saved On reads Off.
crate::graphics::offer_smooth_gradients(false);
@@ -1823,39 +1825,42 @@ mod tests {
assert_eq!(item.blocked(&s), None);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
assert_eq!(item.switch(&s), Some(true));
s.horizon.hdr = false;
s.horizon.hdr = HdrMode::Off;
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Settings::default();
let mut ui = ui();
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
crate::graphics::offer_hdr(false);
// A 720p window opened at HDR shows only its greyed rows: Down still reaches the shortcut.
// Off has not asked the system yet: Auto must still be reachable to start the watch.
let short = (427.0, 240.0);
let mut at_hdr = Ui::default();
at_hdr.show(Page::Hdr);
at_hdr.key(&mut s, K::ArrowDown, None, false, short);
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::HdrKey)));
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::Hdr)));
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
assert!(press(&mut ui, &mut s, at(hdr)).is_empty());
// Not even with HDR saved on, from another computer: nor its sliders and calibration.
s.horizon.hdr = true;
for h in [H::Hdr, H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
assert_eq!(s.horizon.hdr, HdrMode::Auto);
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
assert_eq!(s.horizon.hdr, HdrMode::Off);
// Brightness still needs an output, even with On saved from another computer.
s.horizon.hdr = HdrMode::On;
for h in [H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
assert!(Item::Horizon(h).blocked(&s).is_some(), "{h:?}");
}
s.horizon.hdr = false;
s.horizon.hdr = HdrMode::Off;
crate::graphics::offer_hdr(true);
let (tx, _) = Columns::of(SIZE.0, SIZE.1).track();
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
assert!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)).is_empty());
// The switch turns it on, and the sliders below it work.
// The choice turns it on, and the sliders below it work.
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
assert!(s.horizon.hdr);
assert!(s.horizon.hdr_wanted());
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
assert_eq!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)), [DRAGGED]);
ui.release();
@@ -1873,6 +1878,33 @@ mod tests {
assert!(ui.changed());
}
#[test]
fn hdr_auto_is_a_choice_on_a_reporting_sdr_display() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(false);
crate::graphics::report_system_hdr(Some(false));
let mut s = Settings::default();
let item = Item::Horizon(H::Hdr);
assert_eq!(item.switch(&s), None);
assert_eq!(item.blocked(&s), None);
item.cycle(&mut s, false, FB);
assert_eq!(s.horizon.hdr, HdrMode::Auto); // On requires an offered output.
assert!(!s.horizon.hdr_wanted());
assert!(Item::Horizon(H::HdrPeak).blocked(&s).is_some());
item.cycle(&mut s, true, FB);
assert_eq!(s.horizon.hdr, HdrMode::Off);
crate::graphics::offer_hdr(true);
for expected in [HdrMode::On, HdrMode::Auto, HdrMode::Off] {
item.cycle(&mut s, false, FB);
assert_eq!(s.horizon.hdr, expected);
}
s.horizon.hdr = HdrMode::Auto;
crate::graphics::report_system_hdr(Some(true));
assert!(s.horizon.hdr_wanted());
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s), None);
crate::graphics::report_system_hdr(None);
}
#[test]
fn the_scrollbar_can_be_held_and_dragged() {
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
@@ -1891,7 +1923,7 @@ mod tests {
assert_eq!(ui.scroll_amount(), content - (cols.bottom - cols.top));
drag(&mut ui, &mut s, cols.top - 50.0, true);
assert_eq!(ui.scroll_amount(), 0.0);
assert!(!ui.release());
assert!(ui.release().is_none());
drag(&mut ui, &mut s, cols.bottom + 50.0, true);
assert_eq!(ui.scroll_amount(), 0.0);
}
@@ -2730,8 +2762,8 @@ mod tests {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
s1.horizon.hdr = HdrMode::On;
s2.horizon.hdr = HdrMode::On;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, true, 200.0);
@@ -2747,8 +2779,8 @@ mod tests {
let pos = i as f32 / steps as f32;
let mut s1 = Settings::default();
let mut s2 = Settings::default();
s1.horizon.hdr = true;
s2.horizon.hdr = true;
s1.horizon.hdr = HdrMode::On;
s2.horizon.hdr = HdrMode::On;
h.set_slider(&mut s1, pos);
item.step_slider(&mut s1, false, 200.0);
@@ -2762,6 +2794,81 @@ mod tests {
}
}
}
#[test]
fn hdr_sliders_step_through_every_value_they_can_be_set_to() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
let value = |h: H, s: &Settings| match h {
H::HdrPaperWhite => s.horizon.hdr_paper_white,
H::HdrPeak => s.horizon.hdr_peak,
H::HdrBlackLevel => s.horizon.hdr_black_level,
H::HdrUiBrightness => s.horizon.hdr_ui_brightness,
_ => unreachable!(),
};
let fresh = || {
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
s
};
for h in [
H::HdrPaperWhite,
H::HdrPeak,
H::HdrBlackLevel,
H::HdrUiBrightness,
] {
// Everything a drag can land on, each read back where the slider puts it.
let mut set = std::collections::BTreeSet::new();
for i in 0..=10_000 {
let mut s = fresh();
h.set_slider(&mut s, i as f32 / 10_000.0);
let v = value(h, &s);
set.insert(v);
let mut again = fresh();
h.set_slider(&mut again, h.slider(&s).unwrap());
assert_eq!(value(h, &again), v, "{h:?} round trip");
}
// Everything a key or the controller reaches, from the bottom to the top.
let mut s = fresh();
h.set_slider(&mut s, 0.0);
let mut stepped = std::collections::BTreeSet::from([value(h, &s)]);
for _ in 0..h.slider_steps().unwrap() {
Item::Horizon(h).step_slider(&mut s, true, 200.0);
stepped.insert(value(h, &s));
}
assert_eq!(stepped, set, "{h:?}");
}
// Auto, then 10..480 by 10; Auto, then 200..2000 by 50 and 2500..10 000 by 500;
// 0..1 nit by 0.01 with 0 no lift.
let mut s = fresh();
let reach = |h: H, s: &mut Settings| {
h.set_slider(s, 0.0);
let mut all = vec![value(h, s)];
for _ in 0..h.slider_steps().unwrap() {
Item::Horizon(h).step_slider(s, true, 200.0);
all.push(value(h, s));
}
all
};
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
assert_eq!(reach(H::HdrPaperWhite, &mut s), paper);
let peak: Vec<u32> = std::iter::once(0)
.chain((200..=2000).step_by(50))
.chain((2500..=10_000).step_by(500))
.collect();
assert_eq!(reach(H::HdrPeak, &mut s), peak);
assert_eq!(
reach(H::HdrBlackLevel, &mut s),
(0..=100).collect::<Vec<u32>>()
);
}
#[test]
fn black_level_reads_as_its_lift() {
let mut s = Settings::default();
let label = |s: &Settings| Item::Horizon(H::HdrBlackLevel).label(s).plain();
assert_eq!(label(&s), "Black Level: 0.00");
s.horizon.hdr_black_level = 5;
assert_eq!(label(&s), "Black Level: 0.05");
}
/// Horizon's graphics captions are English sentences that the language table keys by
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
/// English in every language. Every text the screen shows, with each setting cycled
@@ -2769,7 +2876,7 @@ mod tests {
#[test]
fn every_graphics_text_is_in_the_language_table() {
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
for hdr in [false, true] {
for hdr in HdrMode::ALL {
let mut s = Settings::default();
s.horizon.hdr = hdr;
s.horizon.taa = true;
+42 -3
View File
@@ -261,21 +261,27 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
}
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
// the HUD found it before.
// the HUD found it before. The HUD's target is 8-bit, as that frame was.
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
}
// The backdrop `fs_keep` left in the HUD's 8-bit target for world `w`: where no HUD draws, the
// target holds exactly this, so the HUD's own part below is exactly 0 there.
fn backdrop(w: vec3<f32>) -> vec3<f32> {
return round(clamp(w, vec3(0.0), vec3(1.0)) * 255.0) / 255.0;
}
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
// (1 - c) of the world, light above white included. Where the world is at or below white, at
// 100%, that is the HUD's picture as drawn.
fn layered(at: vec2<i32>) -> vec3<f32> {
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
let p = textureLoad(hud, at, 0).rgb;
let c = textureLoad(coverage, at, 0).r;
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
}
@@ -284,6 +290,39 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
}
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
// paper white in linear light. Unlike the display curve (whose knee is at least white),
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
let top = pow(max(w.r, max(w.g, w.b)), 2.2);
if top <= 0.6 {
return w;
}
let mapped = 0.6 + 0.4 * (top - 0.6) / (top - 0.2);
return w * pow(mapped / top, 1.0 / 2.2);
}
override screenshot_world_only: bool = false;
// Off where Horizon lifts nothing (HDR Lighting off, or a shader pack's picture): the world is
// clipped at white as SDR draws it.
override screenshot_shoulder: bool = true;
// The ordinary PNG, or the world's icon before the HUD: H as drawn, at 100%, over
// the SDR world's light. This never changes the targets used by HDR output or its copy.
@fragment fn fs_sdr_screenshot(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
let mapped = select(min(w, vec3(1.0)), screenshot_world(w), screenshot_shoulder);
if screenshot_world_only {
return vec4(mapped, 1.0);
}
let p = textureLoad(hud, at, 0).rgb;
let c = textureLoad(coverage, at, 0).r;
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
return vec4(h + (1.0 - c) * mapped, 1.0);
}
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if calibrating(pos) {
return calibration_picture(pos);
@@ -47,8 +47,8 @@ pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
/// Which values were Auto (peak, paper white; black has none): saved as Auto again
/// unless the player moved them.
auto: [Option<f32>; 3],
dragging: bool,
}
@@ -57,12 +57,29 @@ pub struct Calibration {
const BAND: f32 = 84.0;
impl Step {
/// Range and step of the slider, in nits: the settings' own.
fn range(self) -> (f32, f32, f32) {
/// The slider's steps past its first: the settings' own.
fn steps(self) -> u32 {
match self {
Step::Black => (0.0, 1.0, 0.01),
Step::Peak => (200.0, 2000.0, 50.0),
Step::PaperWhite => (10.0, 480.0, 10.0),
Step::Black => 100,
Step::Peak => crate::graphics::PEAK_STEPS,
Step::PaperWhite => 47,
}
}
/// The nits at `step` of the slider: black 0..1 by 0.01, the peak's scale, paper
/// white 10..480 by 10.
fn nits_at(self, step: u32) -> f32 {
match self {
Step::Black => step as f32 / 100.0,
Step::Peak => crate::graphics::peak_nits_at(step) as f32,
Step::PaperWhite => 10.0 + step as f32 * 10.0,
}
}
/// Where `nits` sits on the slider, in steps.
fn step_of(self, nits: f32) -> f32 {
match self {
Step::Black => nits * 100.0,
Step::Peak => crate::graphics::peak_step_of(nits),
Step::PaperWhite => (nits - 10.0) / 10.0,
}
}
/// The English name, which is also its key in the language table.
@@ -130,7 +147,7 @@ impl Calibration {
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
None,
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
@@ -140,9 +157,21 @@ impl Calibration {
fn current(&self) -> Step {
STEPS[self.step]
}
fn set(&mut self, v: f32) {
let (min, max, step) = self.current().range();
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
/// The slider's position, 0..=1.
fn position(&self) -> f32 {
let step = self.current();
step.step_of(self.values[self.step]) / step.steps() as f32
}
/// Puts the slider on its step nearest `position`.
fn set(&mut self, position: f32) {
let step = self.current();
let steps = step.steps() as f32;
self.values[self.step] = step.nits_at((position * steps).round().clamp(0.0, steps) as u32);
}
/// Moves the slider `by` steps from the one nearest its value.
fn step_by(&mut self, by: f32) {
let steps = self.current().steps() as f32;
self.set(((self.position() * steps).round() + by) / steps);
}
fn label(&self) -> Text {
let v = self.values[self.step];
@@ -184,7 +213,6 @@ impl Calibration {
w,
rgba(160, 160, 160, 255),
);
let (min, max, _) = step.range();
let (x, y, sw) = l.slider;
ui::slider(
batch,
@@ -193,7 +221,7 @@ impl Calibration {
y,
sw,
&self.label(),
(self.values[self.step] - min) / (max - min),
self.position(),
over(cursor, l.slider) || self.dragging,
true,
self.dragging,
@@ -238,14 +266,12 @@ impl Calibration {
if !self.dragging {
return Outcome::Nothing;
}
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
self.set(ui::slider_value(x, sw, cursor.0));
return Outcome::Changed;
}
if over(cursor, l.slider) {
self.dragging = true;
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
self.set(ui::slider_value(x, sw, cursor.0));
return Outcome::Changed;
}
if over(cursor, l.cancel) {
@@ -273,14 +299,13 @@ impl Calibration {
}
/// Left and Right step the slider, Enter goes on, Escape cancels.
pub fn key(&mut self, key: KeyCode) -> Outcome {
let (_, _, step) = self.current().range();
match key {
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
self.set(self.values[self.step] - step);
self.step_by(-1.0);
Outcome::Changed
}
KeyCode::ArrowRight | KeyCode::ArrowUp => {
self.set(self.values[self.step] + step);
self.step_by(1.0);
Outcome::Changed
}
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
@@ -328,4 +353,73 @@ mod tests {
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
}
#[test]
fn each_step_reaches_every_value_of_its_setting() {
// Black Level 0..1 nit by 0.01; Peak Brightness 200..2000 by 50, then by 500 to PQ's
// 10 000; Paper White 10..480 by 10.
let expected: [Vec<f32>; 3] = [
(0..=100).map(|n| n as f32 / 100.0).collect(),
(200..=2000)
.step_by(50)
.chain((2500..=10_000).step_by(500))
.map(|n| n as f32)
.collect(),
(10..=480).step_by(10).map(|n| n as f32).collect(),
];
let mut c = Calibration::new(&Settings::default());
for (i, expected) in expected.iter().enumerate() {
assert_eq!(c.picture(1.0).0, STEPS[i]);
for _ in 0..200 {
c.key(KeyCode::ArrowLeft);
}
let mut seen = vec![c.picture(1.0).1];
for _ in 0..200 {
c.key(KeyCode::ArrowRight);
let v = c.picture(1.0).1;
if seen.last() != Some(&v) {
seen.push(v);
}
}
assert_eq!(&seen, expected, "{:?}", STEPS[i]);
c.key(KeyCode::Enter);
}
}
#[test]
fn black_level_saves_the_lift_it_shows_and_peak_reaches_10000() {
let mut s = Settings::default();
let mut c = Calibration::new(&s);
// Untouched, no lift stays no lift.
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 0);
c.key(KeyCode::ArrowRight);
c.key(KeyCode::ArrowRight);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 2);
// Calibrated back down, 0 is no lift: what the step showed.
let mut c = Calibration::new(&s);
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::Enter);
for _ in 0..60 {
c.key(KeyCode::ArrowRight);
}
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 0);
assert_eq!(s.horizon.black_level_nits(), 0.0);
assert_eq!(s.horizon.hdr_peak, 10_000);
let mut c = Calibration::new(&s);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, 10_000.0);
// One step down from the top is 500 nits; from 2000, 50.
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 9500.0);
s.horizon.hdr_peak = 2000;
let mut c = Calibration::new(&s);
c.key(KeyCode::Enter);
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 1950.0);
}
}
+182 -54
View File
@@ -3,6 +3,7 @@
//! GUI scale is applied when drawing).
use crate::Result;
use horizon_assets::animation::AnimationMetadata;
use smallvec::SmallVec;
use std::{
collections::{HashMap, HashSet},
fs,
@@ -902,18 +903,20 @@ impl HudAtlas {
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, a native mod's own image, downloaded skins, world and pack
/// icons and the on-screen keyboard's text.
/// icons and the on-screen keyboard's text. Their pixels are already zero
/// in the destination atlas, so keep the source buffers empty and avoid
/// duplicating the atlas-sized allocation during packing.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
for i in 0..MAP_SLOTS {
let name = map_slot_sprite(i).unwrap_or_default();
images.push((name.into(), 128, 128, vec![0; 128 * 128 * 4]));
images.push((name.into(), 128, 128, Vec::new()));
}
// The minimap extra's image (game/extras.rs).
images.push((
MINIMAP_SPRITE.into(),
crate::minimap::SIZE as u32,
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
Vec::new(),
));
// A mod's own image (native `mod_image`), only when a mod is loaded.
if mod_image {
@@ -921,45 +924,29 @@ impl HudAtlas {
MOD_IMAGE_SPRITE.into(),
MOD_IMAGE_SIZE,
MOD_IMAGE_SIZE,
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
Vec::new(),
));
}
// Downloaded skins (game/scoreboard.rs).
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
images.push((format!("horizon/skin{i}"), 64, 64, Vec::new()));
}
for i in 0..TRIM_SLOTS {
images.push((
format!("horizon/trim{i}"),
trim.0,
trim.1,
vec![0; (trim.0 * trim.1 * 4) as usize],
));
images.push((format!("horizon/trim{i}"), trim.0, trim.1, Vec::new()));
}
for i in 0..WORLD_ICON_SLOTS {
images.push((
format!("horizon/world-icon{i}"),
64,
64,
vec![0; 64 * 64 * 4],
));
images.push((format!("horizon/world-icon{i}"), 64, 64, Vec::new()));
}
let side = PACK_ICON_SIZE as usize;
for i in 0..PACK_ICON_SLOTS {
images.push((
format!("horizon/pack-icon{i}"),
PACK_ICON_SIZE,
PACK_ICON_SIZE,
vec![0; side * side * 4],
Vec::new(),
));
}
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
images.push(("horizon/ui-text".into(), 2048, 1024, Vec::new()));
}
/// Only the font's metrics, for a game that draws nothing (the
/// integrated or dedicated server): text measures as with the full atlas,
@@ -1528,10 +1515,12 @@ impl HudAtlas {
{
translucent.insert(name.clone());
}
for row in 0..*h {
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
let dst = (((py + row) * width + px) * 4) as usize;
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
if !data.is_empty() {
for row in 0..*h {
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
let dst = (((py + row) * width + px) * 4) as usize;
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
}
}
let b = u32::from(bordered.contains(name));
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
@@ -1745,6 +1734,10 @@ pub enum Blend {
Additive,
/// Alpha blending with an isolated depth buffer and a pixel scissor.
Depth([u32; 4]),
/// The glint over the `Depth` run before it with the same scissor, in its
/// pass: `RenderPipelines.GLINT`'s additive blend, depth tested against the
/// model and never written.
DepthGlint([u32; 4]),
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
Vignette,
@@ -1842,12 +1835,31 @@ impl HudBatch {
}
out
}
fn runs_in(&self, vertices: std::ops::Range<u32>) -> Vec<(std::ops::Range<u32>, Blend)> {
let mut runs = self.runs();
for (range, _) in &mut runs {
*range = range.start.max(vertices.start)..range.end.min(vertices.end);
fn runs_in(
&self,
vertices: std::ops::Range<u32>,
) -> SmallVec<[(std::ops::Range<u32>, Blend); 16]> {
let mut runs = SmallVec::new();
let mut at = vertices.start;
for (range, blend) in &self.blends {
if range.start > at {
let start = at.max(vertices.start);
let end = range.start.min(vertices.end);
if start < end {
runs.push((start..end, Blend::Alpha));
}
}
let start = range.start.max(vertices.start);
let end = range.end.min(vertices.end);
if start < end {
runs.push((start..end, *blend));
}
at = at.max(range.end);
}
let end = (self.vertices.len() as u32).min(vertices.end);
if at < end {
runs.push((at.max(vertices.start)..end, Blend::Alpha));
}
runs.retain(|(range, _)| !range.is_empty());
runs
}
/// `AbstractContainerScreen.init`: center in integer GUI coordinates.
@@ -1901,8 +1913,39 @@ impl HudBatch {
});
}
}
/// `RenderTypes.armorCutoutNoCullGlint` (and the items' glint types) over a
/// [`Self::quad_3d`] face: `uv` is the face's atlas UV,
/// which the shader masks by, and `glint_uv` the transformed glint UV.
/// `armor` samples `enchanted_glint_armor` (`ARMOR_ENTITY_GLINT`), otherwise
/// `enchanted_glint_item`. Drawn by [`Self::depth_glint_since`].
pub fn quad_3d_glint(
&mut self,
p: [[f32; 3]; 4],
uv: [[f32; 2]; 4],
glint_uv: [[f32; 2]; 4],
armor: bool,
) {
let marker = if armor { 4.0 } else { 2.0 };
for i in [0, 1, 2, 0, 2, 3] {
self.vertices.push(HudVertex {
pos: [p[i][0], p[i][1]],
uv: [uv[i][0] + marker, uv[i][1]],
color: u32::MAX,
depth: p[i][2],
glint_uv: glint_uv[i],
text: 0,
});
}
}
/// Keep per-vertex depth for an orthographic GUI model, with +z toward the viewer.
pub fn depth_since(&mut self, start: usize, clip: [f32; 4]) {
self.depth_glint_since(start, self.vertices.len(), clip);
}
/// `RenderPipelines.GLINT` after a GUI model: [`Self::depth_since`] for a model whose glint faces
/// ([`Self::quad_3d_glint`]) follow its own from `glint`: both share the
/// depth buffer, and the glint is drawn over the model in the same pass.
pub fn depth_glint_since(&mut self, start: usize, glint: usize, clip: [f32; 4]) {
let glint = glint.clamp(start, self.vertices.len());
let vertices = &mut self.vertices[start..];
if vertices.is_empty() {
return;
@@ -1919,11 +1962,17 @@ impl HudBatch {
v.depth = (max - v.depth) / (max - min).max(f32::EPSILON);
}
let [x0, y0, x1, y1] = clip.map(|v| (v * self.scale).max(0.0) as u32);
let clip = [x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)];
// Separate previews clear depth independently, even if their scissors match.
self.blends.push((
start as u32..self.vertices.len() as u32,
Blend::Depth([x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)]),
));
if glint > start {
self.blends
.push((start as u32..glint as u32, Blend::Depth(clip)));
}
let end = self.vertices.len();
if end > glint {
self.blends
.push((glint as u32..end as u32, Blend::DepthGlint(clip)));
}
}
pub fn rect_uv(&mut self, x: f32, y: f32, w: f32, h: f32, r: Rect, color: u32) {
self.quad(
@@ -2537,6 +2586,8 @@ pub struct HudRenderer {
invert_pipeline: Pipeline,
additive_pipeline: Pipeline,
depth_pipeline: Pipeline,
/// `RenderPipelines.GLINT` over a GUI model (`Blend::DepthGlint`).
depth_glint_pipeline: Pipeline,
vignette_pipeline: Pipeline,
end_portal_pipeline: Pipeline,
/// The layout of `group`, which the CIT glint pipelines share.
@@ -2562,6 +2613,7 @@ impl HudRenderer {
format: wgpu::TextureFormat,
view: &wgpu::TextureView,
glint_view: &wgpu::TextureView,
glint_armor_view: &wgpu::TextureView,
glint_sampler: &wgpu::Sampler,
coverage: bool,
) -> Result<Self> {
@@ -2636,6 +2688,16 @@ impl HudRenderer {
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 6,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -2666,6 +2728,10 @@ impl HudRenderer {
binding: 5,
resource: wgpu::BindingResource::Sampler(&linear_sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(glint_armor_view),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -2675,7 +2741,8 @@ impl HudRenderer {
});
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let create = |label, blend, fragment_entry, depth: bool| {
// `depth`: the model's depth test, and whether the run writes depth.
let create = |label, blend, fragment_entry, depth: Option<bool>| {
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
@@ -2690,9 +2757,9 @@ impl HudRenderer {
})],
},
primitive: Default::default(),
depth_stencil: depth.then_some(wgpu::DepthStencilState {
depth_stencil: depth.map(|write| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
@@ -2714,12 +2781,12 @@ impl HudRenderer {
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
};
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", None);
let depth_pipeline = create(
"HUD model",
wgpu::BlendState::ALPHA_BLENDING,
"fs_main",
true,
Some(true),
);
// BlendFunction.INVERT: ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR,
// ONE, ZERO.
@@ -2738,7 +2805,7 @@ impl HudRenderer {
},
},
"fs_crosshair",
false,
None,
);
// BlendFunction.ADDITIVE: ONE, ONE.
let add = wgpu::BlendComponent {
@@ -2753,7 +2820,18 @@ impl HudRenderer {
alpha: add,
},
"fs_main",
false,
None,
);
// RenderPipelines.GLINT tests depth EQUAL and writes none; the faces are
// the model's own, so LEQUAL passes the same pixels.
let depth_glint_pipeline = create(
"HUD model glint",
wgpu::BlendState {
color: add,
alpha: add,
},
"fs_main",
Some(false),
);
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
let vignette_pipeline = create(
@@ -2771,14 +2849,14 @@ impl HudRenderer {
},
},
"fs_vignette",
false,
None,
);
// RenderPipelines.END_PORTAL: opaque.
let end_portal_pipeline = create(
"HUD end portal",
wgpu::BlendState::REPLACE,
"fs_end_portal",
false,
None,
);
let capacity = 1 << 16;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -2794,6 +2872,7 @@ impl HudRenderer {
invert_pipeline,
additive_pipeline,
depth_pipeline,
depth_glint_pipeline,
vignette_pipeline,
end_portal_pipeline,
layout,
@@ -3001,7 +3080,8 @@ impl HudRenderer {
if vertices.is_empty() || size.contains(&0) {
return;
}
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
let mut attachments = [None, None];
attachments[0] = Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
@@ -3009,29 +3089,38 @@ impl HudRenderer {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
});
let attachment_count;
if let Some(view) = coverage {
attachments.push(Some(hud_coverage::attachment(view)));
attachments[1] = Some(hud_coverage::attachment(view));
attachment_count = 2;
} else {
attachment_count = 1;
}
let runs = batch.runs_in(vertices);
let mut at = 0;
while at < runs.len() {
let clip = match runs[at].1 {
Blend::Depth(clip) => Some(clip),
Blend::Depth(clip) | Blend::DepthGlint(clip) => Some(clip),
_ => None,
};
let end = if clip.is_some() {
let end = if let Some(clip) = clip {
// The model's glint shares its pass and depth buffer.
at + 1
+ runs[at + 1..]
.iter()
.take_while(|(_, b)| *b == Blend::DepthGlint(clip))
.count()
} else {
runs[at..]
.iter()
.position(|(_, b)| matches!(b, Blend::Depth(_)))
.position(|(_, b)| matches!(b, Blend::Depth(_) | Blend::DepthGlint(_)))
.map_or(runs.len(), |n| at + n)
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
color_attachments: &attachments,
color_attachments: &attachments[..attachment_count],
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
@@ -3060,6 +3149,7 @@ impl HudRenderer {
Blend::Invert => &self.invert_pipeline,
Blend::Additive => &self.additive_pipeline,
Blend::Depth(_) => &self.depth_pipeline,
Blend::DepthGlint(_) => &self.depth_glint_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
@@ -4601,6 +4691,44 @@ texture=wide",
);
}
#[test]
fn a_gui_models_glint_follows_it_in_its_depth_pass() {
let mut batch = HudBatch::new([640, 480]);
batch.scale = 2.0;
let face = |z: f32| [[0.0, 0.0, z], [0.0, 1.0, z], [1.0, 1.0, z], [1.0, 0.0, z]];
let uv = [[0.25, 0.5]; 4];
let start = batch.vertices.len();
batch.quad_3d(face(1.0), uv, u32::MAX);
batch.quad_3d(face(3.0), uv, u32::MAX);
let glint = batch.vertices.len();
batch.quad_3d_glint(face(3.0), uv, [[7.0, 8.0]; 4], true);
batch.quad_3d_glint(face(1.0), uv, [[1.0, 2.0]; 4], false);
batch.depth_glint_since(start, glint, [1.0, 2.0, 4.0, 6.0]);
assert_eq!(
batch.runs(),
vec![
(0..12, Blend::Depth([2, 4, 6, 8])),
(12..24, Blend::DepthGlint([2, 4, 6, 8])),
]
);
// The glint shares its face's depth, normalised with the model's.
assert_eq!(batch.vertices[12].depth, batch.vertices[6].depth);
assert_eq!(batch.vertices[18].depth, batch.vertices[0].depth);
// The shader picks the armour glint by u >= 4, the item's by u >= 2,
// and masks by the atlas UV under the marker.
assert_eq!(batch.vertices[12].uv, [4.25, 0.5]);
assert_eq!(batch.vertices[12].glint_uv, [7.0, 8.0]);
assert_eq!(batch.vertices[18].uv, [2.25, 0.5]);
// A model with no glint keeps a single depth run.
let start = batch.vertices.len();
batch.quad_3d(face(1.0), uv, u32::MAX);
batch.depth_glint_since(start, batch.vertices.len(), [1.0, 2.0, 4.0, 6.0]);
assert_eq!(
batch.runs().last(),
Some(&(24..30, Blend::Depth([2, 4, 6, 8])))
);
}
#[test]
fn hud_shader_validates_both_fragment_entries() {
let module = naga::front::wgsl::parse_str(include_str!("hud.wgsl")).unwrap();
+10 -3
View File
@@ -12,6 +12,8 @@ struct Hud {
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
@group(0) @binding(4) var glint_sampler: sampler;
@group(0) @binding(5) var linear_sampler: sampler;
// enchanted_glint_armor, the armour layers' GlintSampler (entity.wgsl's glint_armor_tex).
@group(0) @binding(6) var glint_armor_tex: texture_2d<f32>;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -32,11 +34,16 @@ struct Out {
}
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
// u >= 2: the GLINT_TEXTURING sample over the item, masked by the item's
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path).
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path). u >= 4: the same
// over a GUI model's armour, from enchanted_glint_armor (ARMOR_ENTITY_GLINT).
if in.uv.x >= 2.0 {
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
let armor = in.uv.x >= 4.0;
let offset = select(2.0, 4.0, armor);
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(offset, 0.0)).a;
let item_sample = textureSample(glint_tex, glint_sampler, in.glint_uv);
let armor_sample = textureSample(glint_armor_tex, glint_sampler, in.glint_uv);
if mask < 0.1 { discard; }
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
var glint = hud.size.w * select(item_sample, armor_sample, armor);
let tint = hud.end_portal[2].yzw;
if tint.x >= 0.0 {
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
@@ -43,7 +43,8 @@ impl Gpu {
let view = atlas.create_view(&Default::default());
let hud = HudRenderer::new(
&device,
wgpu::TextureFormat::Rgba16Float,
hud_coverage::HUD_FORMAT,
&view,
&view,
&view,
&device.create_sampler(&Default::default()),
@@ -70,8 +71,16 @@ impl Gpu {
})
}
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
/// `batch` drawn on the HUD's target cleared to `backdrop`, with coverage cleared to
/// `initial`: the target's and the coverage's 8-bit texels.
fn draw(
&self,
batch: &HudBatch,
backdrop: f64,
initial: f64,
coverage: bool,
) -> (Vec<u8>, Vec<u8>) {
let frame = self.texture(hud_coverage::HUD_FORMAT);
let mask = self.texture(hud_coverage::FORMAT);
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
let [frame_view, mask_view, depth_view] =
@@ -84,7 +93,7 @@ impl Gpu {
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
);
let mut encoder = self.device.create_command_encoder(&Default::default());
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
for (view, value) in [(&frame_view, backdrop), (&mask_view, initial)] {
let mut attachment = hud_coverage::attachment(view);
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
r: value,
@@ -139,7 +148,7 @@ impl Gpu {
(
color
.chunks(256)
.flat_map(|r| r[..32].iter().copied())
.flat_map(|r| r[..16].iter().copied())
.collect(),
mask.chunks(256)
.flat_map(|r| r[..4].iter().copied())
@@ -148,8 +157,8 @@ impl Gpu {
}
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
let (plain, _) = self.draw(batch, initial, false);
let (color, mask) = self.draw(batch, initial, true);
let (plain, _) = self.draw(batch, 0.3, initial, false);
let (color, mask) = self.draw(batch, 0.3, initial, true);
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
for (got, want) in mask.iter().zip(expected.repeat(2)) {
assert!(
@@ -219,6 +228,46 @@ fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
}
/// The HUD's target is 8-bit as vanilla's frame is, so each draw clips at white before the
/// next blends over it: a text highlight added over a light backdrop, then a 50% black panel.
#[test]
fn each_hud_draw_clips_at_white_as_on_an_8_bit_frame() {
let Some(gpu) = Gpu::new() else {
return;
};
let mut batch = HudBatch::new([4, 2]);
quad(
&mut batch,
Blend::Additive,
rgba(128, 128, 128, 255),
0.0,
0.25,
);
quad(&mut batch, Blend::Alpha, rgba(0, 0, 0, 128), 0.0, 0.25);
// Vanilla's frame in integers: 204 + 128 clips at 255, then the panel keeps 127/255 of it.
let (backdrop, highlight, panel) = (204u32, 128u32, 128u32);
let clipped = (backdrop + highlight).min(255);
let want = ((clipped * (255 - panel)) as f64 / 255.0).round() as u8;
assert_eq!(want, 127);
// The HUD layer's target, and the 8-bit frame a 0.2 pack finishes on in SDR, where the
// HUD draws without coverage.
for coverage in [true, false] {
let (color, mask) = gpu.draw(&batch, f64::from(backdrop) / 255.0, 0.0, coverage);
// Unclipped, the panel would have kept 127/255 of 332: 165.
for pixel in [0, 1, 4, 5] {
assert_eq!(
color[pixel * 4..pixel * 4 + 3],
[want; 3],
"{coverage}: {color:?}"
);
}
// The highlight hides nothing; the panel hides half of what is under it.
if coverage {
assert_eq!(mask[0], 128, "{mask:?}");
}
}
}
#[test]
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
let Some(mut gpu) = Gpu::new() else {
@@ -4,8 +4,26 @@
//!
//! - `room`: what of a view an overlay may use, clear of the boss bars.
//! - `plate`: the box behind a notice's text.
//! - `tabs`: a collapsed page strip, the name between arrows with a pip per page.
//!
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
//! exactly vanilla's.
pub mod plate;
pub mod room;
pub mod tabs;
/// `0xRRGGBB` and an alpha as the batch's colour (`hud::rgba`), for named colour constants.
pub const fn colour(rgb: u32, alpha: u8) -> u32 {
(alpha as u32) << 24 | (rgb & 0xFF) << 16 | (rgb >> 8 & 0xFF) << 8 | (rgb >> 16 & 0xFF)
}
#[cfg(test)]
mod tests {
#[test]
fn a_named_colour_is_the_batch_s_rgba() {
assert_eq!(
super::colour(0x7656FA, 140),
crate::hud::rgba(0x76, 0x56, 0xFA, 140)
);
}
}
@@ -0,0 +1,217 @@
//! A collapsed page strip: the selected page's name between `‹ ›` arrows, with a pip per page
//! under it. A page list with more pages than room (the settings shell's, `settings_shell`)
//! shows them this way instead of leaving some out.
//!
//! [`collapsed`] only lays out (pure, testable); [`draw`] paints a laid-out strip. It knows
//! nothing of clicks or bumpers: the screen using it decides what each part does.
//!
//! Sizes are GUI px. The name is cut to the room between the arrows, and when the pips don't all
//! fit across the area, the ones shown are a window around the selected page's.
//!
//! ```
//! use horizon_client_render::hud::HudAtlas;
//! use horizon_client_render::hud_kit::tabs;
//!
//! let atlas = HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
//! let names = ["General", "Camera", "Build"].map(String::from);
//! let strip = tabs::collapsed(&atlas, &names, 0, [8.0, 100.0, 112.0, 16.0]);
//! assert_eq!(strip.label, "General");
//! assert!(!strip.has_prev && strip.has_next);
//! assert_eq!(strip.pips.len(), 3);
//! ```
use crate::hud::{HudAtlas, HudBatch};
use crate::hud_kit::colour;
/// A pip under the strip: its size and the step from one to the next, in GUI px.
pub const PIP: [f32; 2] = [3.0, 2.0];
pub const PIP_STEP: f32 = 5.0;
/// How far, in GUI px, the arrows sit from the name.
pub const ARROW_GAP: f32 = 4.0;
/// An arrow with a page that way, and one at an end, with none.
pub const ARROW: u32 = colour(0x9FA3AE, 255);
pub const ARROW_OFF: u32 = colour(0x9FA3AE, 77);
/// The pips (the selected one is white).
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
/// The name's colour.
pub const NAME: u32 = colour(0xFFFFFF, 255);
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
/// A laid-out strip.
#[derive(Clone, Debug, PartialEq)]
pub struct Strip {
/// The area it was laid out in, `[x, y, w, h]` GUI px.
pub area: [f32; 4],
/// The selected page's index in the names, and its name, cut to fit.
pub selected: usize,
pub label: String,
/// Where the name is drawn, `[x, y, w, h]`.
pub label_area: [f32; 4],
/// The arrows' text positions `[x, y]` (before, after).
pub arrows: [[f32; 2]; 2],
/// Whether there is a page before and after the selected one: an arrow without is dimmed.
/// A screen may change these (a search skipping pages without matches).
pub has_prev: bool,
pub has_next: bool,
/// The pips `[x, y, w, h]` under the area, and whether each is the selected page's. At most
/// as many as fit across the area.
pub pips: Vec<([f32; 4], bool)>,
}
/// The strip for `names` with `selected` in `area` (`[x, y, w, h]`, GUI px): the selected name
/// between `‹ ›` arrows, cut to fit, with pips under the area. Past the end, the last page.
pub fn collapsed(atlas: &HudAtlas, names: &[String], selected: usize, area: [f32; 4]) -> Strip {
let [x, y, w, h] = area;
let n = names.len();
let selected = selected.min(n.saturating_sub(1));
let text_y = y + ((h - LINE) / 2.0).floor();
let centre = x + w / 2.0;
let arrow_w = atlas.font.width("‹").max(atlas.font.width("›"));
let room = (w - 2.0 * (arrow_w + ARROW_GAP)).max(0.0);
let name = names.get(selected).map_or("", String::as_str);
let label = crate::graphics_style::fit(atlas, name, room);
let label_w = atlas.font.width(&label).ceil();
let left = (centre - label_w / 2.0).floor();
let arrows = [
[left - ARROW_GAP - arrow_w, text_y],
[left + label_w + ARROW_GAP, text_y],
];
// As many pips as fit across the area, a window round the selected one.
let fit = (((w - PIP[0]) / PIP_STEP).floor() as usize + 1).min(n);
let first = selected.saturating_sub(fit / 2).min(n - fit.min(n));
let pips_x = (centre - (fit as f32 * PIP_STEP - (PIP_STEP - PIP[0])) / 2.0).floor();
let pips = (0..fit)
.map(|i| {
let px = pips_x + i as f32 * PIP_STEP;
([px, y + h + 1.0, PIP[0], PIP[1]], first + i == selected)
})
.collect();
Strip {
area,
selected,
label,
label_area: [left, y, label_w, h],
arrows,
has_prev: selected > 0,
has_next: selected + 1 < n,
pips,
}
}
/// Paints `strip`: the name, its arrows (dimmed at an end) and the pips.
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
let [lx, ly, ..] = strip.label_area;
let text_y = ly + ((strip.area[3] - LINE) / 2.0).floor();
batch.text(atlas, &strip.label, lx, text_y, NAME, true);
let lit = [strip.has_prev, strip.has_next];
for (([ax, ay], arrow), on) in strip.arrows.into_iter().zip(["‹", "›"]).zip(lit) {
batch.text(
atlas,
arrow,
ax,
ay,
if on { ARROW } else { ARROW_OFF },
true,
);
}
for (pip, on) in &strip.pips {
let [px, py, pw, ph] = *pip;
batch.fill(atlas, px, py, pw, ph, if *on { PIP_ON } else { PIP_OFF });
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
const AREA: [f32; 4] = [8.0, 100.0, 112.0, 16.0];
#[test]
fn the_name_sits_centred_between_its_arrows_with_a_pip_per_page() {
let atlas = atlas();
let pages = names(&["General", "Sky", "Water", "Fog"]);
let strip = collapsed(&atlas, &pages, 1, AREA);
assert_eq!(strip.label, "Sky");
let [nx, _, nw, _] = strip.label_area;
assert!((nx + nw / 2.0 - 64.0).abs() <= 1.0, "{nx} {nw}");
let [[before, _], [after, _]] = strip.arrows;
assert!(before < nx && after >= nx + nw);
let on: Vec<bool> = strip.pips.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, false, false]);
// The pips sit under the row, centred.
let (first, last) = (strip.pips[0].0, strip.pips[3].0);
assert!(first[1] >= AREA[1] + AREA[3]);
assert!(((first[0] + last[0] + last[2]) / 2.0 - 64.0).abs() <= 1.0);
// Past the end, the last page; no pages, nothing to the arrows' sides.
assert_eq!(collapsed(&atlas, &pages, 9, AREA).selected, 3);
let none = collapsed(&atlas, &[], 0, AREA);
assert!(none.label.is_empty() && none.pips.is_empty());
assert!(!none.has_prev && !none.has_next);
}
#[test]
fn a_long_name_is_cut_to_stay_between_its_arrows() {
let atlas = atlas();
let long = names(&["A page with a very long name", "B"]);
let strip = collapsed(&atlas, &long, 0, [0.0, 0.0, 80.0, 12.0]);
let [_, [after, _]] = strip.arrows;
assert!(after + atlas.font.width("›") <= 80.0, "{after}");
assert!(strip.label.ends_with("..."));
}
#[test]
fn the_ends_have_no_page_beyond_and_draw_their_arrow_dimmed() {
let atlas = atlas();
let pages = names(&["A", "B", "C"]);
let ends = [(0, false, true), (1, true, true), (2, true, false)];
for (selected, prev, next) in ends {
let strip = collapsed(&atlas, &pages, selected, AREA);
assert_eq!((strip.has_prev, strip.has_next), (prev, next));
}
// The first page: its `‹` is drawn in the dimmed colour, its `›` in the arrow's.
let alpha = |strip: &Strip, i: usize| {
let mut batch = HudBatch::new([854, 480]);
draw(&mut batch, &atlas, strip);
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
})
.map(|v| v.color >> 24)
.max()
.unwrap_or(0)
};
let first = collapsed(&atlas, &pages, 0, AREA);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
}
#[test]
fn many_pages_show_as_many_pips_as_fit_round_the_selected_one() {
let atlas = atlas();
let pages: Vec<String> = (0..60).map(|i| format!("Page {i}")).collect();
let [x, _, w, _] = AREA;
for selected in [0, 1, 30, 58, 59] {
let strip = collapsed(&atlas, &pages, selected, AREA);
assert!(strip.pips.len() < pages.len());
for ([px, _, pw, _], _) in &strip.pips {
assert!(*px >= x && px + pw <= x + w, "{selected}: {px}");
}
assert_eq!(strip.pips.iter().filter(|p| p.1).count(), 1, "{selected}");
}
// At the ends, the lit pip is at the window's end too.
let first = collapsed(&atlas, &pages, 0, AREA);
assert!(first.pips[0].1);
let last = collapsed(&atlas, &pages, 59, AREA);
assert!(last.pips.last().unwrap().1);
}
}
+2
View File
@@ -82,6 +82,7 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_options_ui;
pub mod pack_screen;
pub mod packs;
pub mod pad_cursor;
@@ -90,6 +91,7 @@ pub mod paged_maps;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod polytone_post;
pub mod post_chain;
pub mod post_glsl;
pub mod potent_sulfur;
+238 -1
View File
@@ -9,6 +9,7 @@ use crate::{
};
pub mod bake;
pub mod cache;
mod kept;
mod mesh;
pub mod packed;
mod samples;
@@ -202,6 +203,15 @@ pub const MAX_CHUNKS: u32 = 1 << 22;
/// structures) rather than sampled: cells of up to 4 blocks, out to about
/// 1024 blocks at Full far detail.
const DETAIL_LEVEL: u8 = 2;
/// Kept tiles come back a level at a time (`Lod::bring_back`) while at most
/// this many drawn tiles wait for their meshes.
const RETURN_BACKLOG: usize = 32;
/// Kept tiles of cells a chunk wide or less (levels up to this) come back once
/// the near terrain is in and the seam with it sampled, or this long after the
/// return: their thousands of meshes held up the near chunks' and the seam's,
/// showing caves through unmeshed ground meanwhile.
const RETURN_FINE_LEVEL: u8 = 4;
const RETURN_SEAM_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
/// The most chunks one detail request asks for, one request each 250 ms:
/// generated ones arrive at the decoration threads' pace, cached ones at once.
const DETAIL_REQUEST: usize = 512;
@@ -1321,6 +1331,12 @@ pub struct Lod {
saved: Saved,
reduced: Reduced,
saved_order: VecDeque<(i32, i32)>,
/// Tiles the plan let go, taken back on a return instead of sampled again.
kept: kept::KeptTiles,
/// Kept tiles the plan wants again, not taken back yet (`bring_back`).
returning: Vec<TileKey>,
/// When the plan last asked kept tiles back (`RETURN_SEAM_WAIT`).
returned_at: std::time::Instant,
/// `LodSource::keeps_observed`: tiles sampled without an observed chunk
/// are sampled again (`resample_over`).
keeps_observed: bool,
@@ -1688,6 +1704,9 @@ impl Lod {
saved: FxHashMap::default(),
reduced: FxHashMap::default(),
saved_order: VecDeque::new(),
kept: kept::KeptTiles::new(kept::KEPT_FOR, kept::KEPT_BYTES),
returning: Vec::new(),
returned_at: std::time::Instant::now(),
keeps_observed,
}
}
@@ -1713,6 +1732,10 @@ impl Lod {
pub fn stats(&self) -> (usize, usize) {
(self.active.len(), self.ready.len())
}
/// The tiles kept for a return, and about how many bytes they hold.
pub fn kept(&self) -> (usize, usize) {
(self.kept.len(), self.kept.bytes())
}
pub fn progress(&self) -> (usize, usize) {
let mut desired = 0;
let mut sampled = 0;
@@ -2030,6 +2053,8 @@ impl Lod {
if self.retry.len() != retrying {
self.wanted_stale = true;
}
// Kept tiles there are as old: sampled again if the plan wants them.
self.kept.forget(|key, _| key.refreshed_by(area, widths));
// A first sample in flight read the old data too.
let keys: Vec<_> = self
.ready
@@ -2137,6 +2162,21 @@ impl Lod {
for key in resampled_over(&self.ready, &self.pending, chunk) {
self.mark_stale(key);
}
// Kept tiles with the chunk's hole: sampled again if the plan wants them.
let (cx, cz) = chunk;
self.kept.forget(|key, samples| {
let size = key.size();
let stride = size / CELLS as i32;
let [ox, _, oz] = key.origin();
stride > 16
&& (ox..ox + size).contains(&(cx * 16))
&& (oz..oz + size).contains(&(cz * 16))
&& samples
.get(
((cz * 16 - oz) / stride * CELLS as i32 + (cx * 16 - ox) / stride) as usize,
)
.is_some_and(|c| *c == Column::default())
});
}
/// A chunk's own columns as its level-0 tile; the same columns again
/// (asked again after a `refresh`) mesh nothing.
@@ -2464,6 +2504,8 @@ impl Lod {
self.area = Some(area);
self.replan(camera, detail, distance, elevation, aim);
});
} else if self.kept.due() {
self.kept.trim();
}
let mut meshes = Vec::new();
crate::spans::time("lod_results", || self.receive(&mut meshes));
@@ -2473,6 +2515,7 @@ impl Lod {
// and the nearest first, where far terrain meets the near chunks.
meshes.sort_by_key(|(key, _)| (key.level, key.distance(camera)));
crate::spans::time("lod_covered", || self.cover(covered));
crate::spans::time("lod_return", || self.bring_back());
if std::mem::take(&mut self.reselect) {
self.rebuild_selection();
}
@@ -2513,7 +2556,41 @@ impl Lod {
.copied()
.collect();
self.plan = plan;
self.ready.retain(|key, _| self.plan.nodes.contains(key));
self.kept.fit_to_view(self.plan.nodes.len());
// Tiles leaving the plan are kept for a return, with their detail. Not
// stale ones (their source has more), nor ones of cells a chunk wide or
// less over the full-detail columns: never drawn there, they came back
// as flat grey ground around the near terrain until it loaded.
let leaving: Vec<_> = self
.ready
.keys()
.filter(|key| !self.plan.nodes.contains(key))
.copied()
.collect();
for key in leaving {
let columns = self.ready.remove(&key).unwrap();
if !self.stale.contains(&key) && !self.over_covered(key) {
let mask = self.detail_masks.get(&key).copied();
self.kept.keep(key, columns, mask);
}
}
self.kept.trim();
// Those the plan wants again come back (`bring_back`) instead of being
// sampled: a level at a time, coarsest first. Not at the seam with the
// near chunks (`Plan::stands_in`), sampled first and fresh there: kept
// ones drew grey ground and the near chunks' caves until they loaded.
let (returning, seam): (Vec<_>, Vec<_>) = self
.plan
.nodes
.iter()
.filter(|key| self.kept.contains(key) && !self.ready.contains_key(key))
.copied()
.partition(|key| self.plan.stands_in(*key));
for key in seam {
self.kept.take(&key);
}
self.returning = returning;
self.returned_at = std::time::Instant::now();
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
@@ -2542,6 +2619,81 @@ impl Lod {
self.mark_side_neighbors_of(key);
}
}
/// Whether a tile of cells a chunk wide or less lies over a covered column.
fn over_covered(&self, key: TileKey) -> bool {
if key.size() > 16 * CELLS as i32 {
return false;
}
let [ox, _, oz] = key.origin();
let size = key.size();
if let Some([x0, z0, x1, z1]) = self.covered_bounds
&& (ox >= x1 || oz >= z1 || ox + size <= x0 || oz + size <= z0)
{
return false;
}
let chunks = (size / 16).max(1);
(0..chunks * chunks).any(|i| {
self.covered.contains(&(
ox.div_euclid(16) + i % chunks,
oz.div_euclid(16) + i / chunks,
))
})
}
/// Whether a tile at the seam with the near chunks (`Plan::stands_in`)
/// still waits for its samples.
fn seam_open(&self) -> bool {
self.plan.nodes.iter().any(|key| {
!self.plan.stands_in(*key)
&& !self.plan.split.contains(key)
&& !self.ready.contains_key(key)
})
}
/// Takes kept tiles back into the plan, the coarsest level first, the next
/// once the meshes are in: all of them at once left the sky showing
/// through every tile not meshed yet, where sampling fills a view coarse
/// first and refines it.
fn bring_back(&mut self) {
if self.returning.is_empty() || self.unmeshed.len() > RETURN_BACKLOG {
return;
}
let level = self
.returning
.iter()
.map(|key| key.level)
.max()
.unwrap_or(0);
if level <= RETURN_FINE_LEVEL
&& self.returned_at.elapsed() < RETURN_SEAM_WAIT
&& (self.near_loading || self.seam_open())
{
return;
}
let (now, later): (Vec<_>, Vec<_>) =
self.returning.drain(..).partition(|key| key.level == level);
self.returning = later;
// Tiles forgotten meanwhile (`refresh`) are sampled: the queue skipped them.
self.wanted_stale = true;
let mut returned = Vec::new();
for key in now {
if self.ready.contains_key(&key) || self.pending.contains(&key) {
continue;
}
if let Some((columns, mask)) = self.kept.take(&key) {
self.ready.insert(key, columns);
if let Some(mask) = mask {
self.detail_masks.insert(key, mask);
}
returned.push(key);
}
}
if returned.is_empty() {
return;
}
self.rebuild_selection();
for key in returned {
self.refresh_path(key);
}
}
/// Recomputes every active tile after a plan change, then marks only the
/// tiles whose selection changed (plus their neighbours).
fn rebuild_selection(&mut self) {
@@ -3020,6 +3172,7 @@ impl Lod {
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|| !self.plan.nodes.contains(&key)
|| self.retry.contains_key(&key)
|| self.kept.contains(&key)
{
continue;
}
@@ -3947,6 +4100,90 @@ mod tests {
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
/// Out and home again: the tiles the trip let go come back, the coarsest
/// level first and a level once the meshes are in, without a sample job.
#[test]
fn a_return_takes_kept_tiles_back_coarse_first_instead_of_sampling() {
let mut lod = Lod::scheduler_for_test();
let filled = Arc::new(vec![
Column::from_surface(Some(Surface {
height: 70,
state: BlockStateId(1),
water: None,
biome: 0,
}));
CELLS * CELLS
]);
lod.replan([0, 0], 2, 64, 0, None);
let home: Vec<_> = lod.plan.nodes.iter().copied().collect();
for &key in &home {
lod.ready.insert(key, filled.clone());
}
lod.replan([200_000, 0], 2, 64, 0, None);
let left: Vec<_> = home
.iter()
.copied()
.filter(|key| !lod.plan.nodes.contains(key))
.collect();
assert!(!left.is_empty() && left.iter().all(|key| !lod.ready.contains_key(key)));
lod.replan([0, 0], 2, 64, 0, None);
// Those at the seam with the near chunks are sampled fresh instead.
let (wanted, seam): (Vec<_>, Vec<_>) = left
.iter()
.copied()
.filter(|key| lod.plan.nodes.contains(key))
.partition(|key| lod.plan.stands_in(*key));
assert!(!wanted.is_empty() && !seam.is_empty());
assert!(seam.iter().all(|key| !lod.kept.contains(key)));
// Nothing else to sample: they are on their way back.
lod.request_samples([0, 0]);
assert!(wanted.iter().all(|key| !lod.pending.contains(key)));
assert!(seam.iter().any(|key| lod.pending.contains(key)));
let coarsest = wanted.iter().map(|key| key.level).max().unwrap();
// Meshes outstanding: nothing comes back yet.
lod.unmeshed
.extend((0..=RETURN_BACKLOG as i32).map(|x| TileKey {
x,
z: 9_999,
level: 0,
}));
lod.bring_back();
assert!(wanted.iter().all(|key| !lod.ready.contains_key(key)));
lod.unmeshed.clear();
lod.bring_back();
for key in &wanted {
assert_eq!(
lod.ready.contains_key(key),
key.level == coarsest,
"{key:?}"
);
}
// Finer ones wait for the seam's samples.
for _ in 0..64 {
lod.bring_back();
}
assert!(
!lod.returning.is_empty()
&& lod
.returning
.iter()
.all(|key| key.level <= RETURN_FINE_LEVEL)
);
for key in &seam {
lod.ready.insert(*key, filled.clone());
}
assert!(!lod.seam_open());
while !lod.returning.is_empty() {
lod.unmeshed.clear();
lod.bring_back();
}
assert!(
wanted
.iter()
.all(|key| lod.ready.get(key).is_some_and(|s| Arc::ptr_eq(s, &filled)))
);
assert!(lod.pending.iter().all(|key| seam.contains(key)));
}
#[test]
fn only_detail_a_later_refresh_covers_is_dropped() {
let mut lod = Lod::scheduler_for_test();
@@ -0,0 +1,218 @@
//! Far tiles the plan let go, kept a while for a return: a teleport back, a
//! trip out and home again, the far distance lowered and raised. Taken back
//! (`Lod::replan`) instead of sampled again, which costs every far tile its
//! noise, biomes and surface rules a second time.
//!
//! Bounded in time (`KEPT_FOR`) and in memory (`KEPT_BYTES`); past the bytes
//! the finest go first, so the coarse shape of every place nearby stays
//! longest. Nothing is written to disk: the tiles leave with the `Lod`, that
//! is with the world.
use super::{Column, Span, TileKey};
use crate::fast_hash::FxHashMap;
use std::{
sync::Arc,
time::{Duration, Instant},
};
/// How long a tile is kept after the plan let it go.
pub(super) const KEPT_FOR: Duration = Duration::from_secs(600);
/// About as many bytes as a 512-chunk view's tiles hold, so one whole place
/// is kept; a far tile is some 20 to 60 KB. The most kept at any far distance.
pub(super) const KEPT_BYTES: usize = 512 << 20;
/// The tiles a 512-chunk view plans: a smaller view keeps a share of
/// `KEPT_BYTES` as large as its share of these (`KeptTiles::fit_to_view`), so
/// travelling with a short far distance doesn't fill the whole budget.
const VIEW_512_TILES: usize = 4700;
struct Kept {
columns: Arc<Vec<Column>>,
/// Its detail (`Lod::detail_masks`), so refined cells are not asked again.
mask: Option<[u64; 4]>,
at: Instant,
bytes: usize,
}
pub(super) struct KeptTiles {
tiles: FxHashMap<TileKey, Kept>,
bytes: usize,
checked: Instant,
time: Duration,
budget: usize,
}
impl KeptTiles {
pub(super) fn new(time: Duration, budget: usize) -> Self {
Self {
tiles: FxHashMap::default(),
bytes: 0,
checked: Instant::now(),
time,
budget,
}
}
/// The budget for a view that plans `tiles`: `KEPT_BYTES` for a 512-chunk
/// view, a share of it for a smaller one.
pub(super) fn fit_to_view(&mut self, tiles: usize) {
self.budget = (KEPT_BYTES / VIEW_512_TILES)
.saturating_mul(tiles)
.min(KEPT_BYTES);
}
pub(super) fn len(&self) -> usize {
self.tiles.len()
}
pub(super) fn bytes(&self) -> usize {
self.bytes
}
pub(super) fn contains(&self, key: &TileKey) -> bool {
self.tiles.contains_key(key)
}
pub(super) fn keep(&mut self, key: TileKey, columns: Arc<Vec<Column>>, mask: Option<[u64; 4]>) {
// Spans past the inline four live on the heap.
let bytes = std::mem::size_of_val(columns.as_slice())
+ columns
.iter()
.filter(|column| column.spans.len() > 4)
.map(|column| column.spans.len() * std::mem::size_of::<Span>())
.sum::<usize>();
let kept = Kept {
columns,
mask,
at: Instant::now(),
bytes,
};
self.bytes += bytes;
if let Some(old) = self.tiles.insert(key, kept) {
self.bytes -= old.bytes;
}
}
pub(super) fn take(&mut self, key: &TileKey) -> Option<(Arc<Vec<Column>>, Option<[u64; 4]>)> {
let kept = self.tiles.remove(key)?;
self.bytes -= kept.bytes;
Some((kept.columns, kept.mask))
}
/// Forgets the tiles `stale` picks: their source has more than they hold.
pub(super) fn forget(&mut self, mut stale: impl FnMut(&TileKey, &[Column]) -> bool) {
let bytes = &mut self.bytes;
self.tiles.retain(|key, kept| {
let keep = !stale(key, &kept.columns);
if !keep {
*bytes -= kept.bytes;
}
keep
});
}
/// Whether a minute went by since the last `trim`.
pub(super) fn due(&self) -> bool {
!self.tiles.is_empty() && self.checked.elapsed() >= Duration::from_secs(60)
}
/// Drops the tiles kept past their time, then, past the budget, the
/// finest and the oldest first.
pub(super) fn trim(&mut self) {
self.checked = Instant::now();
let (bytes, time) = (&mut self.bytes, self.time);
self.tiles.retain(|_, kept| {
let keep = kept.at.elapsed() < time;
if !keep {
*bytes -= kept.bytes;
}
keep
});
if self.bytes <= self.budget {
return;
}
let mut order: Vec<_> = self
.tiles
.iter()
.map(|(key, kept)| (key.level, kept.at, *key))
.collect();
order.sort_unstable();
for (_, _, key) in order {
if self.bytes <= self.budget {
break;
}
self.take(&key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lod::{CELLS, Surface};
use horizon_world::registry::BlockStateId;
fn tile(height: i32) -> Arc<Vec<Column>> {
Arc::new(vec![
Column::from_surface(Some(Surface {
height,
state: BlockStateId(1),
water: None,
biome: 0,
}));
CELLS * CELLS
])
}
fn key(level: u8) -> TileKey {
TileKey { x: 3, z: -2, level }
}
#[test]
fn a_kept_tile_comes_back_with_its_detail_once() {
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
kept.keep(key(0), tile(64), Some([u64::MAX; 4]));
let (columns, mask) = kept.take(&key(0)).unwrap();
assert_eq!(columns[0].surface.unwrap().height, 64);
assert_eq!(mask, Some([u64::MAX; 4]));
assert!(kept.take(&key(0)).is_none());
assert_eq!(kept.bytes(), 0);
}
#[test]
fn a_smaller_view_keeps_a_share_of_the_budget() {
let mut kept = KeptTiles::new(KEPT_FOR, KEPT_BYTES);
kept.fit_to_view(VIEW_512_TILES);
assert_eq!(kept.budget, KEPT_BYTES / VIEW_512_TILES * VIEW_512_TILES);
// A 128-chunk view plans about a sixteenth of the tiles.
kept.fit_to_view(VIEW_512_TILES / 16);
assert!(kept.budget <= KEPT_BYTES / 16);
kept.fit_to_view(VIEW_512_TILES * 4);
assert_eq!(kept.budget, KEPT_BYTES);
}
#[test]
fn past_the_budget_the_finest_go_first_and_past_the_time_all() {
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
kept.keep(key(4), tile(80), None);
let one = kept.bytes();
kept.keep(key(0), tile(64), None);
kept.keep(key(1), tile(70), None);
kept.budget = one * 2;
kept.trim();
assert!(kept.contains(&key(4)) && kept.contains(&key(1)) && !kept.contains(&key(0)));
assert_eq!(kept.bytes(), one * 2);
kept.time = Duration::ZERO;
kept.trim();
assert_eq!((kept.len(), kept.bytes()), (0, 0));
}
#[test]
fn forgotten_tiles_are_not_taken_back() {
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
kept.keep(key(2), tile(64), None);
kept.keep(key(3), tile(64), None);
kept.forget(|key, _| key.level == 2);
assert!(kept.take(&key(2)).is_none());
assert!(kept.take(&key(3)).is_some());
assert_eq!(kept.bytes(), 0);
}
}
+103 -71
View File
@@ -1334,6 +1334,8 @@ pub enum Page {
ShaderPacks,
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
ShaderOptions,
/// The selected resource packs' Polytone options (`pack_options_ui`), from the pack screen.
PackOptions,
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
@@ -1486,6 +1488,12 @@ fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
/// What the game must do after an interaction.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
/// The Pack Options page closed with changes: the player's choices of its entries, the
/// ones that differ from their defaults (the others went back to theirs).
PackOptions {
entries: Vec<String>,
chosen: std::collections::BTreeMap<String, horizon_polytone::pack::ConfigValue>,
},
/// An accepted button press or the release of a slider.
ButtonClick,
/// Horizon's own: download the Modrinth hit at this index of the pack
@@ -1662,7 +1670,12 @@ pub struct OptionsUi {
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,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
/// fills them beside `pack_infos`).
pub pack_options: crate::pack_options_ui::PackOptionsUi,
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
pub calibration: Option<crate::hdr_calibration::Calibration>,
@@ -1741,6 +1754,8 @@ impl Default for OptionsUi {
controller: Default::default(),
extras: Default::default(),
shader: Default::default(),
pack_options: Default::default(),
pack_configs: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
@@ -2098,7 +2113,6 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
@@ -2209,12 +2223,12 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if let Some(at) = Self::shader_page(page) {
self.shader.open(at);
}
if let Some(shell) = self.shell_mut() {
shell.open();
}
if let Some(at) = Self::shader_page(page) {
self.shader.show(at);
}
if let Some(at) = Self::graphics_page(page) {
self.graphics.show(at);
}
@@ -2239,6 +2253,7 @@ impl OptionsUi {
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),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
@@ -2247,6 +2262,7 @@ impl OptionsUi {
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),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
@@ -2291,38 +2307,6 @@ impl OptionsUi {
}
events
}
/// What the shader screen's actions do to these options.
fn shader_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::shader_ui::Action>,
) -> Vec<Event> {
use crate::shader_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::Picked => {
// The last pack's changed options are kept before the next one's are read.
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
events.push(Event::ShaderOptions);
}
events.push(Event::ShaderPack);
events.push(Event::OpenShaderOptions);
}
Action::OpenFolder => events.push(Event::OpenShaderPackFolder),
Action::Apply => {
events.push(Event::ShaderOptions);
events.push(Event::OpenShaderOptions);
}
Action::NativeChanged(pack) => events.push(Event::NativePackOptions(pack)),
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
@@ -2378,6 +2362,7 @@ impl OptionsUi {
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::PackOptions => "horizon.pack.options.title",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -3680,8 +3665,8 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if Self::shader_page(self.page()).is_some() {
self.shader.draw(s, batch, atlas, cursor, self.in_world);
if self.page() == Page::PackOptions {
self.pack_options.draw(batch, atlas, cursor, self.in_world);
return;
}
let waiting = self.waiting();
@@ -4059,9 +4044,19 @@ impl OptionsUi {
});
return self.shell_events(s, outputs);
}
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);
if self.page() == Page::PackOptions {
let mut events = Vec::new();
for action in self
.pack_options
.click((x, y), size, drag, right || self.shift)
{
match action {
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
crate::pack_options_ui::Action::SliderDrag => {}
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
}
}
return events;
}
if self.page() == Page::Packs {
let mut events = Vec::new();
@@ -4091,6 +4086,14 @@ impl OptionsUi {
crate::pack_screen::Action::BrowsePick(i) => {
events.push(Event::DownloadPack(i));
}
crate::pack_screen::Action::Options(id) => {
let mut configs = horizon_polytone::pack::Configs::default();
if let Some(own) = self.pack_configs.get(&id) {
configs.merge(own);
}
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
self.push(Page::PackOptions);
}
crate::pack_screen::Action::None => {}
}
return events;
@@ -4329,15 +4332,19 @@ impl OptionsUi {
// AbstractSliderButton suppresses the press sound and plays once on
// release, even if the pointer has left the widget or its value stayed.
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 = shader_slider | shell_slider;
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
self.pack_options.release();
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 {
if slider || shell_slider.is_some() || calibration_slider {
let mut events = vec![Event::ButtonClick, Event::Changed];
// A pack's option is saved, and the pack rebuilt, once per drag.
events.extend(shader_pack.map(Event::NativePackOptions));
// What the screen does once a drag ends: a shader pack's option is saved, and the
// pack rebuilt, once per drag.
let done = shell_slider.into_iter().flatten();
events.extend(done.filter_map(|output| match output {
shell::Output::Event(event) => Some(event),
_ => None,
}));
events
} else if split_drop {
vec![Event::ButtonClick]
@@ -4420,8 +4427,8 @@ impl OptionsUi {
self.studio.wheel(amount);
return Vec::new();
}
if Self::shader_page(self.page()).is_some() {
self.shader.scroll(s, size, amount);
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return Vec::new();
}
let plain = self.resource_error.is_none()
@@ -4507,8 +4514,8 @@ impl OptionsUi {
self.studio.wheel(amount);
return;
}
if Self::shader_page(self.page()).is_some() {
self.shader.scroll(s, size, amount);
if self.page() == Page::PackOptions {
self.pack_options.scroll(size, amount);
return;
}
match &mut self.pack_screen {
@@ -4576,7 +4583,19 @@ impl OptionsUi {
self.pack_screen = Some(screen);
}
}
self.pack_screen.as_mut().expect("made above")
// The cards of packs with Polytone options carry their button (data packs have none).
let with_options = if self.data_pack_dir.is_none() {
self.pack_configs
.iter()
.filter(|(_, c)| !c.entries().is_empty())
.map(|(id, _)| id.clone())
.collect()
} else {
Default::default()
};
let screen = self.pack_screen.as_mut().expect("made above");
screen.with_options = with_options;
screen
}
/// Escape or Done: leave the current page, applying page results.
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
@@ -4633,6 +4652,16 @@ impl OptionsUi {
self.language_search.focused = false;
self.can_change_value = false;
match page {
Page::PackOptions => {
let ui = std::mem::take(&mut self.pack_options);
if ui.changed {
let entries = ui.configs.entries().keys().cloned().collect();
events.push(Event::PackOptions {
entries,
chosen: ui.configs.chosen(),
});
}
}
Page::ShaderOptions | Page::ShaderPacks => {
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
events.push(Event::ShaderOptions);
@@ -4695,8 +4724,6 @@ impl OptionsUi {
if shell.typing() {
shell.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() {
self.settings_search.insert_text(text);
self.scroll = 0.0;
@@ -4731,9 +4758,6 @@ impl OptionsUi {
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
if Self::shader_page(self.page()).is_some() {
return self.shader.shortcut(shortcut, clipboard);
}
if self.settings_search_active() {
if self.settings_search.shortcut(shortcut, clipboard) {
self.scroll = 0.0;
@@ -4855,16 +4879,6 @@ impl OptionsUi {
let outputs = shell.key(s, key, text, shift, gui);
return self.shell_events(s, outputs);
}
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);
let mut events = self.shader_events(s, actions);
// Escape leaves a screen a link opened first, then the shader screen.
if key == K::Escape && !handled {
events.extend(self.back(s));
}
return events;
}
if key == K::Escape {
return self.back(s);
@@ -5525,8 +5539,20 @@ mod tests {
/// read, and leaving the shader screen applies them.
#[test]
fn the_shader_screen_saves_changed_options_before_reading_the_next_packs() {
use crate::shader_ui::Action;
let mut s = Settings::default();
use crate::shader_pack_list::{Entry, Kind};
let entry = |file: &str| Entry {
file: file.into(),
key: file.into(),
title: file.into(),
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
};
let mut s = Settings {
shader_packs: vec![entry("A.zip"), entry("B.zip")],
..Settings::default()
};
s.horizon.shader_pack = "A.zip".into();
let mut options = crate::shader_options::ShaderOptions::new(
"A.zip".into(),
@@ -5540,15 +5566,21 @@ mod tests {
let mut ui = OptionsUi::default();
ui.open(false);
ui.push(Page::ShaderPacks);
let events = ui.shader_events(&mut s, vec![Action::Picked]);
let size = (854.0, 480.0);
let (x, y) = ui
.shader
.point_at(&s, size, crate::shader_ui::Item::Pack(1));
let events = ui.click(&mut s, &atlas(), x, y, size, [1708, 960], false, false);
assert!(matches!(
events[..],
[
Event::ButtonClick,
Event::ShaderOptions,
Event::ShaderPack,
Event::OpenShaderOptions
]
));
assert_eq!(s.horizon.shader_pack, "B.zip");
let events = ui.back(&mut s);
assert!(matches!(events[0], Event::ShaderOptions));
}
@@ -0,0 +1,558 @@
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
//! and the packs load again with it.
use crate::widgets::Action as Declared;
use crate::{
graphics_style as style,
hud::{HudAtlas, HudBatch},
lang,
settings_shell::{self as shell, Columns, Line, Lines},
text::Text,
ui,
};
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
/// What a click asks of the options screen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
ButtonClick,
SliderDrag,
/// Done: leave, keeping what changed.
Done,
}
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
#[derive(Default)]
pub struct PackOptionsUi {
/// The selected packs' entries and the values being picked.
pub configs: Configs,
/// Entry ids, by the row index the list uses.
ids: Vec<String>,
/// The sections in order: the `section` id (none for entries without one) and its rows.
sections: Vec<(Option<String>, Vec<u16>)>,
scroll: ui::Scroll,
dragging: Option<u16>,
/// A value changed since the page opened.
pub changed: bool,
}
/// The presets' row (and its section): past every entry's index.
const PRESETS: u16 = u16::MAX;
/// A translation, or `fallback` where no language has the key.
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
if lang::has(key) {
lang::get(key)
} else {
fallback()
}
}
/// `namespace:path` split, `minecraft` when there is no namespace.
fn split(id: &str) -> (&str, &str) {
id.split_once(':').unwrap_or(("minecraft", id))
}
/// A name for an id without a translation: `fog_density` is "Fog density".
fn readable(path: &str) -> String {
let words = path.rsplit('/').next().unwrap_or(path).replace('_', " ");
let mut chars = words.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect())
.unwrap_or_default()
}
impl PackOptionsUi {
/// The page opens on `configs`, the selected packs' entries with the player's values.
pub fn open(configs: Configs) -> Self {
let mut ids: Vec<(String, Option<String>, i64)> = configs
.entries()
.iter()
.map(|(id, e)| (id.clone(), e.section.clone(), e.display_order))
.collect();
ids.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
// A section's place: the smallest `section_order` among its entries, else its name.
let mut sections: Vec<(Option<String>, Option<i64>, Vec<u16>)> = Vec::new();
for (row, (id, section, _)) in ids.iter().enumerate() {
let order = configs.entries()[id].section_order;
match sections.iter_mut().find(|s| &s.0 == section) {
Some(s) => {
s.1 = match (s.1, order) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
s.2.push(row as u16);
}
None => sections.push((section.clone(), order, vec![row as u16])),
}
}
sections.sort_by(|a, b| match (a.1, b.1) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.0.cmp(&b.0),
});
Self {
configs,
ids: ids.into_iter().map(|(id, ..)| id).collect(),
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
..Self::default()
}
}
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
let cols = Columns::of(w, h);
let mut lines = Lines::new();
// The pack-wide presets first, when the entries have some.
if !self.configs.presets().is_empty() {
lines.titled(PRESETS, vec![PRESETS]);
}
for (i, (_, rows)) in self.sections.iter().enumerate() {
lines.titled(i as u16, rows.clone());
}
let (lines, content) = lines.finish();
(cols, lines, content)
}
/// The namespace the presets' translations are under: of the first entry with presets.
fn presets_namespace(&self) -> &str {
self.configs
.entries()
.iter()
.find(|(_, e)| !e.presets.is_empty())
.map_or("minecraft", |(id, _)| split(id).0)
}
/// The pack-wide preset every entry's value matches, if one does.
fn current_preset(&self) -> Option<String> {
self.configs.presets().into_iter().find(|preset| {
self.configs
.entries()
.iter()
.all(|(id, e)| match e.presets.get(preset) {
Some(value) => e.accept(value).as_ref() == self.configs.get(id),
None => true,
})
})
}
/// A preset's label (`<namespace>.presets.<name>`), or the label of none (`.none`).
fn preset_label(&self, preset: Option<&str>) -> String {
let namespace = self.presets_namespace();
match preset {
Some(name) => translated(&format!("{namespace}.presets.{name}"), || readable(name)),
None => translated(&format!("{namespace}.presets.none"), || {
lang::get("horizon.pack.options.presets.custom")
}),
}
}
/// The next pack-wide preset after the current one (`back`: before it), applied.
fn cycle_preset(&mut self, back: bool) {
let presets = self.configs.presets();
if presets.is_empty() {
return;
}
let n = presets.len();
let next = match self
.current_preset()
.and_then(|c| presets.iter().position(|p| *p == c))
{
Some(i) if back => (i + n - 1) % n,
Some(i) => (i + 1) % n,
None if back => n - 1,
None => 0,
};
let before = self.configs.clone();
self.configs.apply_preset(&presets[next]);
if self.configs != before {
self.changed = true;
}
}
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
fn section_name(&self, section: u16) -> String {
if section == PRESETS {
let key = format!("{}.presets", self.presets_namespace());
return translated(&key, || lang::get("horizon.pack.options.presets"));
}
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
return String::new();
};
let Some(id) = id else {
return lang::get("horizon.pack.options.general");
};
let namespace = rows
.first()
.and_then(|&r| self.ids.get(usize::from(r)))
.map_or("minecraft", |entry| split(entry).0);
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
}
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
fn parts(&self, row: u16) -> (String, Option<String>) {
if row == PRESETS {
let label = self.preset_label(self.current_preset().as_deref());
return (self.section_name(PRESETS), Some(label));
}
let Some(id) = self.ids.get(usize::from(row)) else {
return (String::new(), None);
};
let (namespace, path) = split(id);
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
let entry = &self.configs.entries()[id];
let value = match self.configs.get(id) {
Some(ConfigValue::Number(n)) => {
let step = match entry.kind {
ConfigKind::Number { step, .. } => step,
_ => 1.0,
};
let text = if step >= 1.0 && step.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
};
Some(text)
}
Some(ConfigValue::Text(t)) => Some(t.clone()),
Some(ConfigValue::Bool(_)) | None => None,
};
let value = value.map(|v| match &entry.value_translation {
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
_ => v,
});
(name, value)
}
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
fn description(&self, row: u16) -> String {
let Some(id) = self.ids.get(usize::from(row)) else {
return String::new();
};
let (namespace, path) = split(id);
let key = format!("config.{namespace}.{path}.tooltip");
let mut text = if lang::has(&key) {
lang::get(&key)
} else {
String::new()
};
if let Some(impact) = &self.configs.entries()[id].performance_impact {
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
let line = lang::tr("horizon.pack.options.impact", &[&level]);
text = if text.is_empty() {
line
} else {
format!("{text} {line}")
};
}
text
}
/// A slider's place (0 to 1) for a number entry.
fn slider(&self, row: u16) -> Option<f32> {
let id = self.ids.get(usize::from(row))?;
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
return None;
};
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
return None;
};
Some(if max > min {
((n - min) / (max - min)) as f32
} else {
0.0
})
}
fn switch(&self, row: u16) -> Option<bool> {
match self.configs.get(self.ids.get(usize::from(row))?)? {
ConfigValue::Bool(b) => Some(*b),
_ => None,
}
}
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
let (cols, lines, content) = self.layout(w, h);
self.scroll.clamp(cols.top, cols.bottom, content);
shell::backdrop(batch, atlas, in_world);
shell::mark(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
shell::plate(
batch,
atlas,
&cols,
&lang::get("horizon.pack.options.title"),
);
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
let mut described = None;
shell::list(
batch,
atlas,
&cols,
&lines,
self.scroll.amount,
|section| self.section_name(section),
|batch, row: u16, y| {
let [rx, ry, rw, rh] = cols.row(y);
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
if hover || self.dragging == Some(row) {
described = Some(row);
}
let (name, value) = self.parts(row);
let label = match &value {
Some(v) => format!("{name}: {v}"),
None => name.clone(),
};
let view = shell::RowView {
label: &label,
name: &name,
value: value.as_deref(),
switch: self.switch(row),
slider: self.slider(row),
live: true,
hover,
focused: false,
held: self.dragging == Some(row),
key: None,
};
shell::row(batch, atlas, &cols, y, &view);
},
);
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
// The strip: the pointed-at entry's tooltip.
let [x, y, ..] = cols.dock;
style::panel(batch, atlas, cols.dock);
if let Some(row) = described {
let name = Text::literal(self.parts(row).0);
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
}
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
if self
.configs
.chosen()
.iter()
.any(|(id, _)| self.ids.contains(id))
{
foot.push((cols.button(1), "controls.reset", None));
}
for (rect, key, declared) in foot {
if let Some(declared) = declared {
crate::widgets::declare_action(declared, rect, true);
}
let label = Text::literal(lang::get(key));
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
}
}
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
pub fn click(
&mut self,
(x, y): (f32, f32),
(w, h): (f32, f32),
drag: bool,
back: bool,
) -> Vec<Action> {
let (cols, lines, content) = self.layout(w, h);
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
if drag {
if self.scroll.held() {
self.scroll.drag(y, cols.top, cols.bottom, content);
} else if let Some(row) = self.dragging {
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
return Vec::new();
}
if cols.scrolls()
&& self
.scroll
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
{
return Vec::new();
}
if at(cols.button(0)) {
return vec![Action::ButtonClick, Action::Done];
}
if at(cols.button(1)) {
self.reset();
return vec![Action::ButtonClick];
}
if y < cols.top || y >= cols.bottom {
return Vec::new();
}
let Some(row) = lines.iter().find_map(|line| {
let row = line.setting?;
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
}) else {
return Vec::new();
};
if row == PRESETS {
self.cycle_preset(back);
return vec![Action::ButtonClick];
}
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return Vec::new();
};
let entry = self.configs.entries()[&id].clone();
match (&entry.kind, self.configs.get(&id).cloned()) {
(ConfigKind::Number { .. }, _) => {
self.dragging = Some(row);
self.set_slider(row, cols.slider_at(x));
return vec![Action::SliderDrag];
}
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
self.set(&id, ConfigValue::Bool(!b));
}
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
let n = allowed.len();
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
self.set(&id, ConfigValue::Text(allowed[next].clone()));
}
_ => return Vec::new(),
}
vec![Action::ButtonClick]
}
fn set(&mut self, id: &str, value: ConfigValue) {
let before = self.configs.get(id).cloned();
self.configs.set(id, value);
if self.configs.get(id).cloned() != before {
self.changed = true;
}
}
/// Number entry `row` at slider place `at` (0 to 1).
fn set_slider(&mut self, row: u16, at: f32) {
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
return;
};
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
}
}
/// Every entry back to its default.
fn reset(&mut self) {
let defaults: Vec<_> = self
.configs
.entries()
.iter()
.map(|(id, e)| (id.clone(), e.default.clone()))
.collect();
for (id, value) in defaults {
self.set(&id, value);
}
}
/// The pointer lets go.
pub fn release(&mut self) {
self.scroll.release();
self.dragging = None;
}
/// The mouse wheel scrolls the list.
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
let (cols, _, content) = self.layout(w, h);
self.scroll
.wheel(amount, 18.0, cols.top, cols.bottom, content);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn configs() -> Configs {
let mut c = Configs::default();
c.add_entry(
"p:fog".into(),
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
);
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
c.add_entry(
"p:birds".into(),
&serde_json::json!({"default_value": true}),
);
c
}
#[test]
fn sections_sort_by_their_order_and_the_unsectioned_last() {
let ui = PackOptionsUi::open(configs());
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
assert_eq!(order, [Some("weather"), Some("sky"), None]);
}
#[test]
fn rows_switch_cycle_and_slide() {
let mut ui = PackOptionsUi::open(configs());
let size = (480.0, 270.0);
let (cols, lines, _) = ui.layout(size.0, size.1);
let ids = ui.ids.clone();
let point = |id: &str| {
let row = ids.iter().position(|i| i == id).unwrap() as u16;
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
let [x, y, w, h] = cols.row(cols.top + line.y);
(x + w - 4.0, y + h / 2.0)
};
let (birds, rain) = (point("p:birds"), point("p:rain"));
ui.click(birds, size, false, false);
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
ui.click(rain, size, false, false);
assert_eq!(
ui.configs.get("p:rain"),
Some(&ConfigValue::Text("streaks".into()))
);
// The slider's far right end is its maximum.
let fog = point("p:fog");
let (tx, tw) = cols.track();
ui.click((tx + tw, fog.1), size, false, false);
ui.release();
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
assert!(ui.changed);
assert_eq!(ui.configs.chosen().len(), 3);
ui.reset();
assert!(ui.configs.chosen().is_empty());
}
#[test]
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
let mut c = Configs::default();
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
let mut ui = PackOptionsUi::open(c);
// The defaults are no preset.
assert_eq!(ui.current_preset(), None);
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
ui.cycle_preset(false);
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
ui.cycle_preset(true);
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
// One value off the preset: custom again.
ui.set("p:dust", ConfigValue::Number(0.3));
assert_eq!(ui.current_preset(), None);
assert!(ui.changed);
}
}
+105 -1
View File
@@ -320,6 +320,8 @@ pub enum Action {
OpenFolder,
/// The Done button (`onClose`: commit).
Done,
/// Horizon's own: the options button on the card of this pack, which has Polytone options.
Options(String),
/// Horizon's own: a Modrinth hit the player picked, to download into the
/// packs folder. The screen does not know where that is.
BrowsePick(usize),
@@ -359,6 +361,8 @@ pub struct PackScreen {
browser_icons: Vec<String>,
/// Whether that button is drawn at all.
pub browsing_allowed: bool,
/// The packs with Polytone options (#11486): their cards carry an options button.
pub with_options: std::collections::BTreeSet<String>,
/// The browser's own search box, separate from the one that filters the
/// player's installed packs.
browser_search: TextField,
@@ -374,6 +378,8 @@ fn tr(key: &str) -> Text {
/// height of 20; drawn shorter, neither it nor its label sits right, so the
/// button keeps that height and is centred on the shorter search box.
const ADD_SIZE: i32 = 20;
/// The options button on a pack's card.
const OPTIONS_SIZE: i32 = 16;
/// A card in the grid: its icon beside its name, author and description.
const ROW_H: i32 = 74;
/// The air around a card's contents.
@@ -440,6 +446,7 @@ impl PackScreen {
confirm: None,
browser: None,
browsing_allowed: false,
with_options: Default::default(),
backdrop: Backdrop::None,
browser_icons: Vec::new(),
browser_search: {
@@ -632,6 +639,12 @@ impl PackScreen {
}
let l = &self.lists[side];
let hit = l.entry_at(x as i32, y as i32);
if let Some(i) = hit
&& let Some((id, bx, by)) = self.options_button(side, i)
&& d::inside((x as i32, y as i32), bx, by, OPTIONS_SIZE, OPTIONS_SIZE)
{
return Action::Options(id);
}
let mut took = false;
if let Some(i) = hit {
took = true;
@@ -1362,6 +1375,45 @@ impl PackScreen {
);
}
}
/// The options button on the card of row `i` of list `side`, when its pack has
/// Polytone options: the pack and the button's corner, at the card's right edge.
fn options_button(&self, side: usize, i: usize) -> Option<(String, i32, i32)> {
let l = &self.lists[side];
let e = l.entries.get(i)?;
let Row::Pack(id) = &e.row else { return None };
if !self.with_options.contains(id) {
return None;
}
let x = l.row_left() + l.entry_width() - OPTIONS_SIZE - 2;
let y = e.y + (e.height - OPTIONS_SIZE) / 2;
Some((id.clone(), x, y))
}
/// The options button of a card: a small button with a sliders glyph.
fn draw_options_button(out: &mut Vec<Draw>, x: i32, y: i32, hover: bool) {
crate::widgets::narratable(|| {
crate::widgets::Narratable::button(
tr("horizon.pack.options").plain(),
[x as f32, y as f32, OPTIONS_SIZE as f32, OPTIONS_SIZE as f32],
true,
)
});
let sprite = if hover {
"widget/button_highlighted"
} else {
"widget/button"
};
d::sprite(out, sprite, x, y, OPTIONS_SIZE, OPTIONS_SIZE);
let (line, knob) = if hover {
(0xFF_C6_C6_C6u32 as i32, -1)
} else {
(0xFF_A0_A0_A0u32 as i32, 0xFF_E0_E0_E0u32 as i32)
};
// Three sliders, their knobs at different places.
for (row, at) in [(4, 8), (8, 4), (12, 9)] {
d::fill(out, x + 3, y + row, x + 13, y + row + 1, line);
d::fill(out, x + at, y + row - 1, x + at + 2, y + row + 2, knob);
}
}
/// The Add button, just right of the search box.
fn add_button(&self, m: &Measure) -> (i32, i32) {
let (sx, sy) = self.header(m)[2];
@@ -1470,6 +1522,7 @@ impl PackScreen {
});
let (left, w) = (l.row_left(), l.entry_width());
let narrow = if l.scrollable() { 6 } else { 0 };
let mut tooltip = None;
for (i, e) in l.entries.iter().enumerate() {
if e.y + e.height < l.y || e.y > l.bottom() {
continue;
@@ -1560,7 +1613,16 @@ impl PackScreen {
}
}
}
let max = 157 - narrow;
let mut max = 157 - narrow;
if let Some((_, bx, by)) = self.options_button(side, i) {
let hover = hovered == Some(i)
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
Self::draw_options_button(out, bx, by, hover);
if hover {
tooltip = Some(tr("horizon.pack.optionsInfo"));
}
max -= OPTIONS_SIZE + 2;
}
d::string_widget(out, m, &name, cx + 34, cy + 1, max);
let lines = d::label_lines(m, &desc, max, 2, &desc_style);
d::label(out, &lines, cx + 34, cy + 12, max, false);
@@ -1568,6 +1630,9 @@ impl PackScreen {
}
}
out.push(Draw::NoScissor);
if let Some(t) = tooltip {
d::tooltip(out, m, &t, mouse);
}
d::blit(
out,
&format!("textures/gui/{prefix}header_separator.png"),
@@ -1652,6 +1717,45 @@ mod icon_tests {
assert!(load_icon(b"not a png").is_none());
}
/// A pack with Polytone options carries an options button on its card, which opens
/// that pack's options and nothing else; a pack without options has none.
#[test]
fn a_card_with_options_opens_its_own_pack_options() {
let atlas =
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let packs = vec![pack("file/Configurable", None), pack("file/Plain", None)];
let mut screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
screen.with_options.insert("file/Configurable".into());
let row = |screen: &PackScreen, id: &str| {
screen.lists[0]
.entries
.iter()
.position(|e| e.row == Row::Pack(id.into()))
.unwrap()
};
assert!(
screen
.options_button(0, row(&screen, "file/Plain"))
.is_none()
);
let (id, bx, by) = screen
.options_button(0, row(&screen, "file/Configurable"))
.unwrap();
assert_eq!(id, "file/Configurable");
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
// Hovered, it explains itself.
let draws = screen.draw(&m, (cx, cy), 0, 0);
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
assert_eq!(
screen.click(&m, cx as f64, cy as f64),
Action::Options("file/Configurable".into())
);
// The press opened the options, it did not move the pack.
assert!(screen.commit().is_empty());
}
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
/// without one keeps `unknown_pack`.
#[test]
+77
View File
@@ -522,8 +522,85 @@ pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
}
}
}
for (id, value) in polytone_choices() {
if configs.entries().contains_key(&id) {
configs.set(&id, value);
}
}
configs
}
/// A pack's Polytone config entries and one's value, for the game's Pack Options.
pub use horizon_polytone::pack::{ConfigValue, Configs};
/// The player's Polytone config choices (#11486): what they changed from the packs' defaults,
/// by entry id, for every pack. `polytone_configs` lays them over each pack's defaults.
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, horizon_polytone::pack::ConfigValue>> =
std::sync::RwLock::new(BTreeMap::new());
pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
POLYTONE_CHOICES
.read()
.map(|c| c.clone())
.unwrap_or_default()
}
/// Replaces the player's Polytone config choices (packs read them when they load again).
pub fn set_polytone_choices(choices: BTreeMap<String, horizon_polytone::pack::ConfigValue>) {
if let Ok(mut c) = POLYTONE_CHOICES.write() {
*c = choices;
}
}
/// Where the choices are kept: `polytone-configs.json` beside `options.txt`.
pub fn polytone_choices_file() -> std::path::PathBuf {
crate::logging::player_dir().join("polytone-configs.json")
}
/// Reads the choices from `path` (none when it is missing or broken).
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
use horizon_polytone::pack::ConfigValue;
let Ok(bytes) = fs::read(path) else {
return BTreeMap::new();
};
let Ok(serde_json::Value::Object(map)) = serde_json::from_slice(&bytes) else {
log::warn!("{}: not a JSON object", path.display());
return BTreeMap::new();
};
map.into_iter()
.filter_map(|(id, v)| {
let value = match v {
serde_json::Value::Number(n) => ConfigValue::Number(n.as_f64()?),
serde_json::Value::String(s) => ConfigValue::Text(s),
serde_json::Value::Bool(b) => ConfigValue::Bool(b),
_ => return None,
};
Some((id, value))
})
.collect()
}
/// Writes the choices to `path`.
pub fn save_polytone_choices(
path: &Path,
choices: &BTreeMap<String, horizon_polytone::pack::ConfigValue>,
) -> std::io::Result<()> {
use horizon_polytone::pack::ConfigValue;
let map: serde_json::Map<String, serde_json::Value> = choices
.iter()
.map(|(id, v)| {
let json = match v {
ConfigValue::Number(n) => serde_json::json!(n),
ConfigValue::Text(s) => serde_json::json!(s),
ConfigValue::Bool(b) => serde_json::json!(b),
};
(id.clone(), json)
})
.collect();
let text = serde_json::to_string_pretty(&serde_json::Value::Object(map))
.map_err(std::io::Error::other)?;
fs::write(path, text)
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
}
+169 -32
View File
@@ -30,6 +30,7 @@ pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, Firewo
mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -226,6 +227,8 @@ enum Light {
AgeEmission4,
/// `FireflyParticle.getLightCoords`: block light fading in and out, no sky.
Firefly,
/// A Polytone particle's `light_level`: block light at least this level.
MinBlock(u8),
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
@@ -527,8 +530,10 @@ pub struct Particle {
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
/// A resource pack's Polytone custom particle type, for its `limit`.
/// A resource pack's Polytone custom particle type, for its `limit` and `exclusion_radius`.
polytone_type: Option<std::sync::Arc<str>>,
/// A Polytone model particle's faces, drawn instead of its sprite quad.
model: Option<std::sync::Arc<polytone_model::ParticleModel>>,
/// How a Polytone custom particle's quad turns (`rotation_mode`).
orientation: polytone_gpu::Orientation,
/// Blended additively (a Polytone `additive_translucent` particle).
@@ -895,6 +900,7 @@ impl Particle {
random: LegacyRandom::new(0),
controller: None,
polytone_type: None,
model: None,
orientation: polytone_gpu::Orientation::Camera,
additive: false,
}
@@ -1098,6 +1104,7 @@ impl Particle {
let b = (b * b) * (b * b);
(sky, (block + (b.clamp(0.0, 1.0) * 240.0) as u32).min(240))
}
Light::MinBlock(level) => (sky, block.max(u32::from(level) * 16)),
Light::Firefly => (
0,
(255.0 * fade_amount(self.lifetime_progress(self.age as f32 + partial), 0.1, 0.3))
@@ -1191,6 +1198,27 @@ pub struct QuadSnapshot {
pub light: u32,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TrackingTarget {
LocalPlayer,
Entity(i32),
}
/// The entity position and current dimensions that `getX/Y/Z(double)` use.
#[derive(Clone, Copy)]
pub struct TrackingBody {
pub feet: [f64; 3],
pub width: f64,
pub height: f64,
}
struct TrackingEmitter {
options: ParticleOptions,
target: Option<TrackingTarget>,
body: TrackingBody,
remaining: u32,
}
/// World access for particle physics.
pub struct ParticleLevel<'a> {
pub world: &'a World,
@@ -1265,6 +1293,9 @@ impl ParticleLevel<'_> {
pub struct ParticleEngine {
particles: Vec<Particle>,
pending: Vec<Particle>,
/// While the live particles tick (out of `particles`), what their emitters spawn waits
/// here, so a spawn's `limit` and `exclusion_radius` see every live particle.
spawned_in_tick: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
/// `ParticleEngine.random`, handed to every `ParticleProvider`.
random: LegacyRandom,
/// `ClientLevel.random` as particle constructors use it (`level.getRandom()`).
@@ -1293,13 +1324,15 @@ pub struct ParticleEngine {
set_sprites: Vec<Vec<u32>>,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// Vanilla particle types a pack replaces with a custom one of the same id
/// (`assets/minecraft/polytone/custom_particles/rain.json` is `minecraft:rain`).
polytone_overrides: HashMap<ParticleType, String>,
/// 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)>,
emitters: Vec<TrackingEmitter>,
/// Camera position for the 32-block spawn limiter.
pub camera: [f64; 3],
/// The Particles option (`ParticleStatus`): 0 all, 1 decreased, 2 minimal.
@@ -1477,6 +1510,7 @@ impl ParticleEngine {
let mut engine = Self {
particles: Vec::new(),
pending: Vec::new(),
spawned_in_tick: None,
random: LegacyRandom::new(0x7061_7274),
level_random: LegacyRandom::new(0x6c65_7665),
spell_random: LegacyRandom::new(0x7370_656c),
@@ -1491,6 +1525,7 @@ impl ParticleEngine {
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
polytone: None,
polytone_overrides: HashMap::new(),
polytone_gpu: Vec::new(),
polytone_ticks: 0,
additive: std::sync::Mutex::default(),
@@ -1724,6 +1759,15 @@ impl ParticleEngine {
};
if !same {
self.polytone_gpu.clear();
self.polytone_overrides = registry
.iter()
.flat_map(|r| {
ParticleType::ALL.iter().filter_map(|&ty| {
let id = format!("minecraft:{}", ty.name());
r.get(&id).is_some().then_some((ty, id))
})
})
.collect();
}
self.polytone = registry;
}
@@ -1910,27 +1954,51 @@ impl ParticleEngine {
pub fn tracking_emitter(
&mut self,
options: ParticleOptions,
feet: [f64; 3],
width: f64,
height: f64,
target: TrackingTarget,
body: TrackingBody,
) {
self.tracking_emitter_for(options, feet, width, height, 3);
self.tracking_emitter_for(options, target, body, 3);
}
/// `createTrackingEmitter(entity, type, lifetime)`.
pub fn tracking_emitter_for(
&mut self,
options: ParticleOptions,
feet: [f64; 3],
width: f64,
height: f64,
target: TrackingTarget,
body: TrackingBody,
life: u32,
) {
self.emit(&options, feet, width, height);
self.emit(&options, body);
if life > 1 {
self.emitters.push((options, feet, width, height, life - 1));
self.emitters.push(TrackingEmitter {
options,
target: Some(target),
body,
remaining: life - 1,
});
}
}
fn emit(&mut self, options: &ParticleOptions, feet: [f64; 3], width: f64, height: f64) {
/// Vanilla keeps the old entity reference until the emitter expires. Keep
/// its final body if it leaves the level, even when the id is paired again.
pub fn detach_tracking_target(
&mut self,
target: TrackingTarget,
mut last_body: impl FnMut() -> Option<TrackingBody>,
) {
for emitter in &mut self.emitters {
if emitter.target == Some(target) {
emitter.body = last_body().unwrap_or(emitter.body);
emitter.target = None;
}
}
}
/// `TrackingEmitter.tick`: the spawn attempts use the tracked entity's
/// current feet and dimensions.
fn emit(&mut self, options: &ParticleOptions, body: TrackingBody) {
let TrackingBody {
feet,
width,
height,
} = body;
for _ in 0..16 {
let r = &mut self.random;
let xa = f64::from(r.next_float() * 2.0 - 1.0);
@@ -1938,9 +2006,9 @@ impl ParticleEngine {
let za = f64::from(r.next_float() * 2.0 - 1.0);
if xa * xa + ya * ya + za * za <= 1.0 {
let p = [
feet[0] + width * xa / 4.0,
feet[0] + width * (xa / 4.0),
feet[1] + height * (0.5 + ya / 4.0),
feet[2] + width * za / 4.0,
feet[2] + width * (za / 4.0),
];
self.add(options, p, [xa, ya + 0.2, za]);
}
@@ -1993,8 +2061,18 @@ impl ParticleEngine {
return;
}
let seed = self.seeds.next_long();
if let Some(particle) = self.make(options, p, v, seed) {
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
@@ -2139,12 +2217,17 @@ impl ParticleEngine {
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle
// A pack's custom particle: a new type, or one replacing the vanilla type of its id.
let custom = match &o.mod_particle {
Some(name) => Some(name.as_str()),
None => self.polytone_overrides.get(&ty).map(String::as_str),
};
if let Some(name) = custom
&& let Some(registry) = &self.polytone
&& let Some(def) = registry.get(name)
{
let (registry, def) = (registry.clone(), def.clone());
let particle = polytone::make(self, &registry, name, &def, o, p, v, seed)?;
let (registry, def, name) = (registry.clone(), def.clone(), name.to_owned());
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;
@@ -3793,7 +3876,16 @@ impl ParticleEngine {
}
}
}
/// A tick where no emitter follows an entity's live body (tests, fixtures, benches).
pub fn tick(&mut self, level: &ParticleLevel) {
self.tick_tracking(level, |_| None);
}
/// A tick whose tracking emitters read each followed entity's live body.
pub fn tick_tracking(
&mut self,
level: &ParticleLevel,
mut tracking_body: impl FnMut(TrackingTarget) -> Option<TrackingBody>,
) {
self.polytone_ticks += 1;
for store in &mut self.polytone_gpu {
store.expire(self.polytone_ticks);
@@ -3804,18 +3896,29 @@ impl ParticleEngine {
}
self.tick_starters();
self.tick_explosions(level);
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
self.emit(&options, feet, width, height);
if life > 1 {
self.emitters.push((options, feet, width, height, life - 1));
let mut emitters = std::mem::take(&mut self.emitters);
emitters.retain_mut(|emitter| {
if let Some(target) = emitter.target {
match tracking_body(target) {
Some(body) => emitter.body = body,
None => emitter.target = None,
}
}
}
self.emit(&emitter.options, emitter.body);
emitter.remaining -= 1;
emitter.remaining > 0
});
self.emitters = emitters;
let mut particles = std::mem::take(&mut self.particles);
self.spawned_in_tick = Some(Vec::new());
for p in &mut particles {
self.tick_particle(p, level);
}
particles.retain(|p| !p.removed);
self.particles = particles;
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
self.add(&options, pos, velocity);
}
let pending = std::mem::take(&mut self.pending);
for mut p in pending {
if p.pre_tick {
@@ -3923,8 +4026,12 @@ impl ParticleEngine {
if let Some(frame) = update.frame {
self.set_sprite_frame(p, frame);
}
for (options, pos, velocity) in update.spawned {
self.add(&options, pos, velocity);
if let Some(later) = &mut self.spawned_in_tick {
later.extend(update.spawned);
} else {
for (options, pos, velocity) in update.spawned {
self.add(&options, pos, velocity);
}
}
} else {
p.remove();
@@ -4868,7 +4975,10 @@ impl ParticleEngine {
(pos.as_vec3(), p.quad_size(partial))
});
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
let velocity = FVec3::new(
// A custom particle's `movement_aligned` quad has its texture's top against
// its motion (measured in Java: a light ray rising toward the noon sun hangs
// below the leaves it starts at); a GPU particle's has it along its motion.
let velocity = -FVec3::new(
p.velocity[0] as f32,
p.velocity[1] as f32,
p.velocity[2] as f32,
@@ -4958,6 +5068,35 @@ impl ParticleEngine {
r.v0 + dv * p.uv[3],
);
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
let overlay = if p.additive {
crate::gfx::ADDITIVE_PARTICLE_OVERLAY
} else if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
};
// A Polytone model particle: its model's faces, scaled by its size and turned as
// its quad would be.
if let Some(model) = &p.model {
for quad in &model.quads {
let Some(&(r, _)) = self.atlas.get(&quad.sprite) else {
continue;
};
let (du, dv) = (r.u1 - r.u0, r.v1 - r.v0);
for (corner, uv) in quad.corners.iter().zip(quad.uv) {
target.push(Vertex {
normal: [0; 4],
pos: (center + rotation * (*corner * size)).to_array(),
uv: [r.u0 + du * uv[0], r.v0 + dv * uv[1]],
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
continue;
}
for rotation in rotations.into_iter().flatten() {
for (n, uv) in [
((1.0, -1.0), [u1, v1]),
@@ -4970,11 +5109,7 @@ impl ParticleEngine {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: if layer {
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
} else {
0
} | u32::from(hdr_tier(p.ty)) << 16,
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
color,
light,
@@ -5062,6 +5197,8 @@ const CREEPER: [[f64; 2]; 12] = [
[0.0, -0.4],
];
#[cfg(test)]
mod tick_bench;
#[cfg(test)]
mod tests {
use super::*;
@@ -11,8 +11,8 @@ use crate::environment::Frame;
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,
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, ParticleModifier,
RenderType, SoundEmitter,
};
use horizon_world::registry::BlockRegistry;
use serde_json::Value as Json;
@@ -37,10 +37,15 @@ pub struct Registry {
blocks: &'static BlockRegistry,
/// Each custom particle type's colormap (`colormap`), by type id.
tints: HashMap<String, Arc<Tint>>,
/// Each model particle type's faces (`model`), by type id.
models: HashMap<String, Arc<super::polytone_model::ParticleModel>>,
/// 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>>,
/// The particle modifiers with their colormaps, and by particle type the ones aimed at it.
particle_modifiers: Vec<(ParticleModifier, Option<Tint>)>,
modifiers_by_type: Mutex<HashMap<String, Arc<[usize]>>>,
}
/// A sound for the engine's queue: id, position, volume, pitch, delayed by the distance.
@@ -151,6 +156,7 @@ impl Registry {
let mut types = HashMap::new();
let mut modifiers = Vec::new();
let mut entity_modifiers = Vec::new();
let mut particle_modifiers = Vec::new();
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
@@ -176,6 +182,28 @@ impl Registry {
Err(e) => log::warn!("{relative}: {e}"),
}
}
for (relative, json) in json_files(pack_root, &polytone.join("particle_modifiers")) {
let implicit = horizon_polytone::implicit_id(&relative, "particle_modifiers");
match ParticleModifier::parse(&json, implicit.as_deref()) {
// A filter needs the block or item a particle carries, which particles here
// only keep as a sprite: such a modifier would hit the wrong particles.
Ok(Some(m)) if m.filter.is_some() => {
log::warn!("{relative}: particle modifier filters aren't supported yet");
}
Ok(Some(m)) => {
let tint = m.colormap.as_ref().and_then(|json| {
let tint = Tint::read(pack_root, json);
if tint.is_none() {
log::warn!("{relative}: colormap {json} not found");
}
tint
});
particle_modifiers.push((m, tint));
}
Ok(None) => {}
Err(e) => log::warn!("{relative}: {e}"),
}
}
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()) {
@@ -212,6 +240,12 @@ impl Registry {
.clone()
})
.collect();
let models = super::polytone_model::read_models(
pack_root,
types
.iter()
.filter_map(|(id, def)| Some((id, def.render.get("model")?))),
);
let tints = types
.iter()
.filter_map(|(id, def)| {
@@ -235,8 +269,11 @@ impl Registry {
configs,
blocks,
tints,
models,
sound_names: Mutex::default(),
sounds: Mutex::default(),
particle_modifiers,
modifiers_by_type: Mutex::default(),
}
}
@@ -244,6 +281,29 @@ impl Registry {
self.types.is_empty()
&& self.block_emitters.iter().all(Option::is_none)
&& self.entity_modifiers.is_empty()
&& self.particle_modifiers.is_empty()
}
/// Whether any pack modifies vanilla or mod particles (`particle_modifiers`).
pub(super) fn modifies_particles(&self) -> bool {
!self.particle_modifiers.is_empty()
}
/// The particle modifiers aimed at a particle type (`minecraft:flame`), in the order read;
/// particle tags aren't resolved here.
fn modifiers_for(&self, id: &str) -> Arc<[usize]> {
let mut cache = self
.modifiers_by_type
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(found) = cache.get(id) {
return found.clone();
}
let found: Arc<[usize]> = (0..self.particle_modifiers.len())
.filter(|&i| self.particle_modifiers[i].0.targets.matches(id, |_| false))
.collect();
cache.insert(id.to_owned(), found.clone());
found
}
/// A sound id as the engine's sound queue holds it.
@@ -661,7 +721,7 @@ impl Host for Ctx<'_> {
(CAMERA, "x" | "y" | "z") => num(frame.camera[axis(name)]),
(CAMERA, "viewDistance") => num(f64::from(frame.render_distance * 16)),
(CAMERA, _) => self.positional(ParticleLevel::block_at(frame.camera), name, args),
(GLOBAL, "rain") => num(f64::from(frame.rain)),
(GLOBAL, "rain") => num(frame.global_rain()),
(GLOBAL, "time") => num(frame.game_time as f64),
(GLOBAL, "dayTime") => num(frame.day_time as f64),
(GLOBAL, "skyType") => num(f64::from(frame.sky_type)),
@@ -1044,11 +1104,11 @@ impl super::ParticleController for Controller {
}
// Moves with collision when it has physics, else freely; then the friction.
if def.has_physics {
let before = p.position;
p.move_by(level, p.velocity);
let still = (0..3).all(|i| (p.position[i] - before[i]).abs() < 1.0e-7);
// `kill_when_still`: a particle a collision stopped (one that landed), not one that
// was never moving: in Java a resting emitter with physics lives out its life.
if (def.kill_on_contact && (p.on_ground || p.stopped_by_collision))
|| (def.kill_when_still && still)
|| (def.kill_when_still && p.stopped_by_collision)
{
p.remove();
}
@@ -1105,19 +1165,41 @@ pub(super) fn make(
return None;
}
}
// `exclusion_radius`: none spawns within that distance of a live one of its type (rain
// splashes, one per patch of ground).
if def.exclusion_radius > 0.0 && def.gpu.is_none() {
let r2 = def.exclusion_radius * def.exclusion_radius;
let crowded = engine.particles.iter().chain(&engine.pending).any(|p| {
!p.removed
&& p.polytone_type.as_deref() == Some(name)
&& (0..3)
.map(|i| (p.position[i] - position[i]).powi(2))
.sum::<f64>()
< r2
});
if crowded {
return None;
}
}
let mut random = LegacyRandom::new(seed);
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
particle.velocity = velocity;
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. A GPU particle is
// drawn from its record instead (`polytone_gpu`).
// A model particle draws its model's faces; one whose model is missing still ticks and
// emits. A GPU particle is drawn from its record instead (`polytone_gpu`).
let model = registry.models.get(name).cloned();
let hidden = def.render_type == RenderType::Invisible
|| def.render.contains_key("model")
|| (def.render.contains_key("model") && model.is_none())
|| def.gpu.is_some();
match engine.mod_sets.get(name) {
Some(&(set, _)) => {
// A mod's sprite set, or for a type replacing a vanilla one, that type's.
let set = engine.mod_sets.get(name).map(|&(set, _)| set).or_else(|| {
let vanilla = name.strip_prefix("minecraft:")?;
engine.sets.get(&ParticleType::from_name(vanilla)?).copied()
});
match set {
Some(set) => {
let sprites = engine.set_sprites.get(set as usize)?;
frames = sprites.len();
particle.set = Some(set);
@@ -1129,11 +1211,22 @@ pub(super) fn make(
particle.sprite = Sprite::Index(*sprites.get(pick).or(sprites.first())?);
}
None if hidden => {}
None if model.is_some() => {}
None => return None,
}
particle.no_render = hidden;
if let Some(model) = model {
// The sprite lookup that admits a particle to drawing: its first face's.
particle.sprite = Sprite::Named(model.quads[0].sprite.clone());
particle.model = Some(model);
}
particle.orientation = super::polytone_gpu::Orientation::parse(&def.rotation_mode);
particle.additive = def.render_type == RenderType::AdditiveTranslucent;
// `light_level`: the block light the particle gives itself (wiki "Custom Particle Types"),
// a floor under the light where it is, as GPU particles have.
if def.light_level > 0 {
particle.light = super::Light::MinBlock(def.light_level);
}
particle.behaviour = Behaviour::Controlled {
translucent: def.render_type != RenderType::Opaque,
};
@@ -1170,7 +1263,8 @@ pub(super) fn make(
customs.extend(seed.customs.iter().map(|(k, v)| (k.clone(), *v)));
}
particle.random = random;
particle.polytone_type = def.limit.map(|_| Arc::from(name));
particle.polytone_type =
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
particle.controller = Some(Box::new(Controller {
def: def.clone(),
@@ -1185,6 +1279,70 @@ pub(super) fn make(
Some(particle)
}
/// Runs the packs' particle modifiers aimed at `id` on a particle that was just made: each
/// expression once, against the particle as its constructor left it (`size` scales it, `speed`
/// sets its power, as vanilla's `Particle.scale` and `setPower`).
pub(super) fn modify(registry: &Registry, id: &str, p: &mut Particle) {
let modifiers = registry.modifiers_for(id);
if modifiers.is_empty() {
return;
}
let Ok(frame) = registry.frame.read() else {
return;
};
let mut random = std::mem::replace(&mut p.random, LegacyRandom::new(0));
let (mut custom, mut customs) = (0.0, BTreeMap::new());
for &i in modifiers.iter() {
let (modifier, tint) = &registry.particle_modifiers[i];
let mut ctx = Ctx {
subject: Subject::Particle(p),
random: &mut random,
custom: &mut custom,
customs: &mut customs,
frame: &frame,
registry,
level: None,
blocks: Vec::new(),
};
// Every expression reads the particle before this modifier changes it.
let value =
|ctx: &mut Ctx, key: &str| modifier.fields.get(key).and_then(|prog| eval(prog, ctx));
let values = MODIFIED.map(|key| value(&mut ctx, key));
let tinted = tint.as_ref().and_then(|t| t.sample(&mut ctx));
drop(ctx);
let [color, red, green, blue, alpha, life, size, speed] = values;
if let Some(rgb) = tinted {
p.rgba[..3].copy_from_slice(&rgb);
}
if let Some(c) = color {
let c = c as i64;
for (channel, shift) in [16, 8, 0].into_iter().enumerate() {
p.rgba[channel] = ((c >> shift) & 0xff) as f32 / 255.0;
}
}
for (channel, v) in [red, green, blue, alpha].into_iter().enumerate() {
if let Some(v) = v {
p.rgba[channel] = v as f32;
}
}
if let Some(life) = life {
p.lifetime = life as i32;
}
if let Some(size) = size {
p.scale(size as f32);
}
if let Some(speed) = speed {
p.set_power(speed as f32);
}
}
p.random = random;
}
/// The fields a particle modifier sets, in the order `modify` applies them.
const MODIFIED: [&str; 8] = [
"color", "red", "green", "blue", "alpha", "life", "size", "speed",
];
impl ParticleEngine {
/// The emitters of the packs' entity modifiers aimed at `entity` (its type, as
/// `minecraft:player`) standing at `position`, run once a client tick.
@@ -1420,6 +1578,39 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// `light_level` is a floor under the block light a custom particle is drawn with (as the
/// real mod draws a lit `light_level` 8 particle at night, measured beside it): the world's
/// light where it is brighter.
#[test]
fn light_level_is_a_floor_under_the_block_light() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/spark.json",
serde_json::json!({"has_physics": false, "light_level": 8, "initializer": {"lifetime": "10"}}),
),
(
"assets/test/polytone/block_modifiers/torch.json",
serde_json::json!({"targets": ["minecraft:torch"], "particle_emitters": [{"particle": "test:spark", "chance": "1"}]}),
),
]);
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));
engine.polytone_animate(&level, [1, 64, 1]);
let p = engine.pending.pop().unwrap();
// Dark: the floor, 8 (light coordinates count 16 a level). The sky light is the world's.
assert_eq!(p.light_coords(0.0, 240, 0), (240, 128));
// A brighter block light is kept.
assert_eq!(p.light_coords(0.0, 0, 208), (0, 208));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn the_ticker_moves_the_particle_and_removes_it() {
let blocks = blocks();
@@ -1497,6 +1688,199 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn no_particle_spawns_within_the_exclusion_radius_of_its_type() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/splash.json",
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible"}),
),
(
"assets/test/polytone/custom_particles/drop.json",
serde_json::json!({"has_physics": false, "render_type": "invisible"}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [
{"particle": "test:splash", "x": "0", "chance": "1"},
// 0.5 from the first: excluded.
{"particle": "test:splash", "x": "0.5", "chance": "1"},
// 3 away: spawns.
{"particle": "test:splash", "x": "3", "chance": "1"},
// Another type is not excluded by it.
{"particle": "test:drop", "x": "0.5", "chance": "1"}
]}),
),
]);
let scene = scene(blocks);
let level = ParticleLevel {
world: &scene.world,
facts: &scene.facts,
collision: &scene.collision,
player: None,
};
let registry = Registry::read(&root, Default::default(), blocks);
let mut engine = engine(registry);
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
let mut xs: Vec<f64> = engine.pending.iter().map(|p| p.position[0]).collect();
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [1.0, 1.5, 4.0]);
std::fs::remove_dir_all(root).unwrap();
}
/// Measured in Java: a custom particle's `movement_aligned` quad has its texture's top
/// against its motion, so the freepack's light rays, rising toward the noon sun, hang
/// below the leaves they start at instead of over them.
#[test]
fn a_rising_movement_aligned_particle_hangs_its_texture_below() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/spark.json",
serde_json::json!({"has_physics": false, "rotation_mode": "movement_aligned",
"initializer": {"lifetime": "100", "size": "1"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1", "dy": "0.01"}]}),
),
]);
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.rects = vec![Some(rect); 13];
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
engine.tick(&level);
let p = engine.particles.last().unwrap();
// The camera 5 blocks south, at the particle's height.
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
let mut out = Vec::new();
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
assert_eq!(out.len(), 4);
for v in &out {
// The texture's top row (v 0) under the centre, its bottom row over it.
if v.uv[1] == 0.0 {
assert!(v.pos[1] < 0.0, "top row at {:?}", v.pos);
} else {
assert!(v.pos[1] > 0.0, "bottom row at {:?}", v.pos);
}
}
std::fs::remove_dir_all(root).unwrap();
}
/// An `additive_translucent` custom particle takes the fog in Java, as vanilla's particle
/// shader gives it: its quads say so to the additive pass.
#[test]
fn an_additive_custom_particle_takes_the_particle_shader_overlay() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/spark.json",
serde_json::json!({"has_physics": false, "render_type": "additive_translucent",
"initializer": {"lifetime": "100", "size": "1"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1"}]}),
),
]);
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.rects = vec![Some(rect); 13];
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
engine.tick(&level);
let mut out = Vec::new();
engine.render(
DVec3::new(1.0, 64.0, 6.0),
0.0,
0.0,
0.0,
&|_| 0xF0,
&mut out,
);
assert!(out.is_empty());
let additive = engine.take_additive();
assert_eq!(additive.len(), 4);
assert!(
additive
.iter()
.all(|v| v.layer & 0xffff == crate::gfx::ADDITIVE_PARTICLE_OVERLAY)
);
std::fs::remove_dir_all(root).unwrap();
}
/// A particle's emitter spawns while the live particles tick: its children's
/// `exclusion_radius` sees the ones alive, so a ray emitted every tick at one spot stays one.
#[test]
fn a_particles_emitter_respects_the_exclusion_radius_of_its_children() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/holder.json",
serde_json::json!({"has_physics": false, "render_type": "invisible",
"initializer": {"lifetime": "100"},
"particle_emitters": [{"particle": "test:ray", "chance": "1"}]}),
),
(
"assets/test/polytone/custom_particles/ray.json",
serde_json::json!({"has_physics": false, "render_type": "invisible",
"exclusion_radius": 1, "initializer": {"lifetime": "100"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:holder", "chance": "1"}]}),
),
]);
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));
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
for _ in 0..10 {
engine.tick(&level);
}
let rays = engine
.particles
.iter()
.chain(&engine.pending)
.filter(|p| p.polytone_type.as_deref() == Some("test:ray"))
.count();
assert_eq!(rays, 1);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_gpu_particle_is_a_record_drawn_until_its_life_ends() {
let blocks = blocks();
@@ -1627,4 +2011,113 @@ mod tests {
);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn particle_modifiers_scale_power_and_recolour_their_particles_once() {
let root = pack(&[
(
"assets/minecraft/polytone/particle_modifiers/flame.json",
serde_json::json!({"size": "2", "life": "LIFETIME + 5", "red": "0.5", "speed": "2"}),
),
(
"assets/pack/polytone/particle_modifiers/smoky.json",
serde_json::json!({"targets": ["minecraft:smoke"], "color": "0x336699"}),
),
]);
let registry = Registry::read(&root, Default::default(), blocks());
let flame = || {
let mut p = Particle::blank(ParticleType::Flame, [0.0; 3]);
p.size = 0.1;
p.lifetime = 10;
p.velocity = [0.25, f64::from(0.1f32), -0.5];
p
};
let mut p = flame();
modify(&registry, "minecraft:flame", &mut p);
// `size` multiplies, `speed` is vanilla's power: x and z doubled, a y of 0.1 kept.
assert_eq!((p.size, p.lifetime, p.rgba[0]), (0.2, 15, 0.5));
assert_eq!(p.velocity, [0.5, f64::from(0.1f32), -1.0]);
// Another type's modifier, by its targets: the packed colour.
let mut p = flame();
modify(&registry, "minecraft:smoke", &mut p);
assert_eq!(
&p.rgba[..3],
&[
0x33 as f32 / 255.0,
0x66 as f32 / 255.0,
0x99 as f32 / 255.0
]
);
assert_eq!((p.size, p.lifetime), (0.1, 10));
// No modifier aimed at it: untouched.
let mut p = flame();
modify(&registry, "minecraft:soul_fire_flame", &mut p);
assert_eq!(
(p.size, p.lifetime, p.velocity),
(0.1, 10, [0.25, f64::from(0.1f32), -0.5])
);
}
/// The freepack's light rays come from an emitter with physics that rests beside a leaf and
/// emits every tick of its life: resting is not being still, only a stopped fall is.
#[test]
fn a_resting_particle_with_physics_lives_out_its_life() {
let blocks = blocks();
let root = pack(&[
(
"assets/test/polytone/custom_particles/rest.json",
serde_json::json!({"has_physics": true, "render_type": "invisible",
"initializer": {"lifetime": "20"}}),
),
(
"assets/minecraft/polytone/entity_modifiers/player.json",
serde_json::json!({"emitters": [{"particle": "test:rest", "chance": "1"}]}),
),
]);
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));
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
let mut p = engine.pending.pop().unwrap();
for _ in 0..10 {
engine.tick_particle(&mut p, &level);
assert!(!p.removed);
}
// Stopped by a collision, it goes.
p.stopped_by_collision = true;
engine.tick_particle(&mut p, &level);
assert!(p.removed);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
let root = pack(&[(
"assets/minecraft/polytone/custom_particles/flame.json",
serde_json::json!({"has_physics": false, "initializer": {"lifetime": "33", "size": "0.75"}}),
)]);
let mut engine = engine(Registry::read(&root, Default::default(), blocks()));
// The vanilla flame's sprite set (what the pack's particles/flame.json lists).
engine.sets.insert(ParticleType::Flame, 1);
engine.set_sprites.push(vec![20, 21]);
engine.create(
&ParticleOptions::simple(ParticleType::Flame),
[1.0, 65.0, 1.0],
[0.0; 3],
);
assert_eq!(engine.pending.len(), 1);
let p = &engine.pending[0];
assert!(
p.controller.is_some(),
"the pack's particle, not vanilla's flame"
);
assert_eq!((p.lifetime, p.size), (33, 0.75));
assert!(matches!(p.sprite, Sprite::Index(20)));
std::fs::remove_dir_all(root).unwrap();
}
}
@@ -23,6 +23,8 @@ pub(super) enum Orientation {
Flat,
/// `movement_aligned`: stands along its velocity, facing the camera around it.
Movement,
/// `none`: fixed in the world's axes (its +Z facing south).
Fixed,
}
impl Orientation {
@@ -31,6 +33,7 @@ impl Orientation {
"look_at_y" => Self::AroundY,
"look_up" => Self::Flat,
"movement_aligned" => Self::Movement,
"none" => Self::Fixed,
_ => Self::Camera,
}
}
@@ -50,6 +53,7 @@ pub(super) fn orient(
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::Fixed => rolled(Quat::IDENTITY),
Orientation::Movement => {
let up = velocity.normalize_or_zero();
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
@@ -61,6 +65,24 @@ pub(super) fn orient(
}
}
/// The alpha a particle keeps at `life` (0 to 1 of its lifetime) with `fade_in` and `fade_out`
/// fractions of it spent fading: each ramp eases in and out (smoothstep), as Polytone's are
/// measured to (a linear ramp leaves young rain drops twice as bright).
fn fade(life: f32, fade_in: f32, fade_out: f32) -> f32 {
let ease = |x: f32| {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
};
let mut alpha = 1.0;
if fade_in > 0.0 {
alpha *= ease(life / fade_in);
}
if fade_out > 0.0 {
alpha *= ease((1.0 - life) / fade_out);
}
alpha
}
/// One GPU particle spawn.
#[derive(Clone, Copy, Debug)]
struct Record {
@@ -239,12 +261,7 @@ impl Store {
// 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);
}
alpha *= fade(life, fade_in, fade_out);
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;
@@ -368,6 +385,20 @@ mod tests {
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
}
/// Measured in Java on a line of particles whose age grows with x: a quarter of the way
/// through its fade-in a particle has about a sixth of its alpha, not a quarter; half at half.
#[test]
fn fades_ease_in_and_out() {
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(close(fade(0.0, 0.4, 0.2), 0.0));
assert!(close(fade(0.1, 0.4, 0.2), 0.15625));
assert!(close(fade(0.2, 0.4, 0.2), 0.5));
assert!(close(fade(0.5, 0.4, 0.2), 1.0));
assert!(close(fade(0.9, 0.4, 0.2), 0.5));
assert!(close(fade(1.0, 0.4, 0.2), 0.0));
assert!(close(fade(0.3, 0.0, 0.0), 1.0));
}
#[test]
fn orientations_turn_the_quad_normal() {
let normal = |q: Quat| q * FVec3::Z;
@@ -0,0 +1,331 @@
//! Polytone model particles (`"model"` on a custom particle type, #11486): a particle drawn as
//! the faces of a block model (`assets/<ns>/models/<path>.json`) instead of one sprite quad.
//! Visual Effects+ draws its block glows this way: a glow texture on a plane in front of the
//! block, turned to the camera and blended additively.
//!
//! The model is read the vanilla way (elements, faces, `#texture` references, parents, element
//! rotations) into quads around the model's centre, in blocks; the particle's size scales them
//! and its rotation mode turns them as it turns a sprite quad (`+Z` facing the camera for
//! `look_at_xyz`, world axes for `none`).
use glam::{Quat, Vec3};
use serde_json::{Map, Value as Json};
use std::collections::HashMap;
use std::path::Path;
/// One face of a model particle: corners in blocks around the model's centre, in drawing order,
/// with their texture coordinates inside the sprite (0 to 1).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ModelQuad {
pub(crate) corners: [Vec3; 4],
pub(crate) uv: [[f32; 2]; 4],
/// The sprite's atlas name (`davidparticles:block/glow_test_1`, `block/stone`).
pub(crate) sprite: String,
}
/// A model particle's faces.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct ParticleModel {
pub(crate) quads: Vec<ModelQuad>,
}
/// `ns:path` with `minecraft` when the namespace is left out.
fn split_id(id: &str) -> (&str, &str) {
id.split_once(':').unwrap_or(("minecraft", id))
}
/// The atlas name of a texture id: `minecraft:block/stone` is `block/stone`.
fn sprite_name(texture: &str) -> String {
match split_id(texture) {
("minecraft", path) => path.to_owned(),
(namespace, path) => format!("{namespace}:{path}"),
}
}
/// A model JSON and its parents' `textures` and `elements`, the child's winning.
fn read_model(pack_root: &Path, id: &str, depth: u32) -> Option<(Map<String, Json>, Vec<Json>)> {
if depth > 8 {
return None;
}
let (namespace, path) = split_id(id);
let file = pack_root
.join("assets")
.join(namespace)
.join("models")
.join(format!("{path}.json"));
let json: Json = horizon_world::json_file::read(&file).ok()?;
let (mut textures, mut elements) = match json.get("parent").and_then(Json::as_str) {
Some(parent) => read_model(pack_root, parent, depth + 1).unwrap_or_default(),
None => Default::default(),
};
if let Some(own) = json.get("textures").and_then(Json::as_object) {
textures.extend(own.iter().map(|(k, v)| (k.clone(), v.clone())));
}
if let Some(own) = json.get("elements").and_then(Json::as_array) {
elements.clone_from(own);
}
Some((textures, elements))
}
/// A `#name` texture reference followed through the model's textures to an id.
fn resolve_texture(textures: &Map<String, Json>, reference: &str) -> Option<String> {
let mut name = reference;
for _ in 0..8 {
let Some(key) = name.strip_prefix('#') else {
return Some(name.to_owned());
};
name = textures.get(key)?.as_str()?;
}
None
}
fn vec3(v: &Json) -> Option<Vec3> {
let a = v.as_array()?;
Some(Vec3::new(
a.first()?.as_f64()? as f32,
a.get(1)?.as_f64()? as f32,
a.get(2)?.as_f64()? as f32,
))
}
/// A face's corners (in model pixels) in vanilla's order: top left, bottom left, bottom right,
/// top right, as seen from outside the face.
fn face_corners(face: &str, f: Vec3, t: Vec3) -> Option<[Vec3; 4]> {
let v = Vec3::new;
Some(match face {
"south" => [
v(f.x, t.y, t.z),
v(f.x, f.y, t.z),
v(t.x, f.y, t.z),
v(t.x, t.y, t.z),
],
"north" => [
v(t.x, t.y, f.z),
v(t.x, f.y, f.z),
v(f.x, f.y, f.z),
v(f.x, t.y, f.z),
],
"east" => [
v(t.x, t.y, t.z),
v(t.x, f.y, t.z),
v(t.x, f.y, f.z),
v(t.x, t.y, f.z),
],
"west" => [
v(f.x, t.y, f.z),
v(f.x, f.y, f.z),
v(f.x, f.y, t.z),
v(f.x, t.y, t.z),
],
"up" => [
v(f.x, t.y, f.z),
v(f.x, t.y, t.z),
v(t.x, t.y, t.z),
v(t.x, t.y, f.z),
],
"down" => [
v(f.x, f.y, t.z),
v(f.x, f.y, f.z),
v(t.x, f.y, f.z),
v(t.x, f.y, t.z),
],
_ => return None,
})
}
/// The `uv` a face has when its JSON gives none: the element's extent on that face.
fn default_uv(face: &str, f: Vec3, t: Vec3) -> [f32; 4] {
match face {
"north" => [16.0 - t.x, 16.0 - t.y, 16.0 - f.x, 16.0 - f.y],
"south" => [f.x, 16.0 - t.y, t.x, 16.0 - f.y],
"east" => [16.0 - t.z, 16.0 - t.y, 16.0 - f.z, 16.0 - f.y],
"west" => [f.z, 16.0 - t.y, t.z, 16.0 - f.y],
"up" => [f.x, f.z, t.x, t.z],
_ => [f.x, 16.0 - t.z, t.x, 16.0 - f.z],
}
}
/// An element's `rotation` (`angle` degrees around `axis` through `origin`, in pixels).
fn element_rotation(element: &Json) -> Option<(Quat, Vec3)> {
let r = element.get("rotation")?;
let angle = (r.get("angle")?.as_f64()? as f32).to_radians();
if angle == 0.0 {
return None;
}
let axis = match r.get("axis")?.as_str()? {
"x" => Vec3::X,
"y" => Vec3::Y,
_ => Vec3::Z,
};
let origin = r.get("origin").and_then(vec3).unwrap_or(Vec3::splat(8.0));
Some((Quat::from_axis_angle(axis, angle), origin))
}
impl ParticleModel {
/// The model `id` (`davidparticles:block/fullblock_glow`) under a pack overlay root; `None`
/// when it's missing or has no face to draw.
pub(crate) fn read(pack_root: &Path, id: &str) -> Option<Self> {
let (textures, elements) = read_model(pack_root, id, 0)?;
let mut quads = Vec::new();
for element in &elements {
let (Some(f), Some(t)) = (
element.get("from").and_then(vec3),
element.get("to").and_then(vec3),
) else {
continue;
};
let rotation = element_rotation(element);
let faces = element.get("faces").and_then(Json::as_object);
for (face, spec) in faces.into_iter().flatten() {
let Some(corners) = face_corners(face, f, t) else {
continue;
};
// A face with no area (a plane's edges) draws nothing.
let span = (corners[0] - corners[2]).abs();
if [span.x, span.y, span.z]
.iter()
.filter(|&&s| s > 1.0e-4)
.count()
< 2
{
continue;
}
let Some(sprite) = spec
.get("texture")
.and_then(Json::as_str)
.and_then(|r| resolve_texture(&textures, r))
else {
continue;
};
let uv = match spec.get("uv").and_then(Json::as_array) {
Some(a) if a.len() == 4 => {
let n = |i: usize| a[i].as_f64().unwrap_or(0.0) as f32;
[n(0), n(1), n(2), n(3)]
}
_ => default_uv(face, f, t),
};
if uv[0] == uv[2] || uv[1] == uv[3] {
continue;
}
let corners = corners.map(|c| {
let c = match rotation {
Some((q, origin)) => q * (c - origin) + origin,
None => c,
};
c / 16.0 - Vec3::splat(0.5)
});
let [u0, v0, u1, v1] = uv.map(|x| x / 16.0);
quads.push(ModelQuad {
corners,
uv: [[u0, v0], [u0, v1], [u1, v1], [u1, v0]],
sprite: sprite_name(&sprite),
});
}
}
(!quads.is_empty()).then_some(Self { quads })
}
}
/// The packs' model particles: each custom particle type with a `model`, its faces.
pub(crate) fn read_models<'a>(
pack_root: &Path,
types: impl Iterator<Item = (&'a String, &'a Json)>,
) -> HashMap<String, std::sync::Arc<ParticleModel>> {
types
.filter_map(|(id, model)| {
let model_id = model.as_str()?;
match ParticleModel::read(pack_root, model_id) {
Some(m) => Some((id.clone(), std::sync::Arc::new(m))),
None => {
log::warn!("custom particle {id}: model {model_id} not found or empty");
None
}
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, path: &str, json: serde_json::Value) {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, json.to_string()).unwrap();
}
fn root() -> 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);
std::env::temp_dir().join(format!("horizon-particle-model-{}-{n}", std::process::id()))
}
#[test]
fn a_glow_plane_keeps_its_one_face_in_front_of_the_centre() {
let root = root();
// Visual Effects+'s glow, as Blockbench writes it: a plane at z 18.25 whose edges have
// no area, and faces without a texture.
write(
&root,
"assets/fx/models/block/glow.json",
serde_json::json!({
"textures": {"2": "fx:block/glow", "particle": "fx:block/glow"},
"elements": [{
"from": [1.25, 1.25, 18.25], "to": [14.75, 14.75, 18.25],
"faces": {
"north": {"uv": [0, 0, 0, 0], "texture": "#2"},
"east": {"uv": [0, 0, 0, 4], "texture": "#missing"},
"south": {"uv": [0, 0, 16, 16], "texture": "#2"}
}
}]
}),
);
let model = ParticleModel::read(&root, "fx:block/glow").unwrap();
assert_eq!(model.quads.len(), 1);
let quad = &model.quads[0];
assert_eq!(quad.sprite, "fx:block/glow");
// 18.25 px is 0.640625 blocks in front of the centre; the plane spans ±0.421875.
let z = 18.25 / 16.0 - 0.5;
let e = 6.75 / 16.0;
assert_eq!(
quad.corners,
[
Vec3::new(-e, e, z),
Vec3::new(-e, -e, z),
Vec3::new(e, -e, z),
Vec3::new(e, e, z)
]
);
assert_eq!(quad.uv, [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn parents_textures_and_default_uvs_resolve_as_vanilla() {
let root = root();
write(
&root,
"assets/minecraft/models/block/base.json",
serde_json::json!({
"textures": {"all": "block/stone"},
"elements": [{"from": [0, 0, 0], "to": [16, 8, 16], "faces": {"up": {"texture": "#all"}}}]
}),
);
write(
&root,
"assets/fx/models/slab.json",
serde_json::json!({
"parent": "block/base", "textures": {"all": "fx:block/sand"}
}),
);
let model = ParticleModel::read(&root, "fx:slab").unwrap();
assert_eq!(model.quads.len(), 1);
// The child's texture wins; `up` at y 8 px, seen from above with north on top.
assert_eq!(model.quads[0].sprite, "fx:block/sand");
assert_eq!(model.quads[0].corners[0], Vec3::new(-0.5, 0.0, -0.5));
assert_eq!(model.quads[0].corners[2], Vec3::new(0.5, 0.0, 0.5));
assert_eq!(model.quads[0].uv[2], [1.0, 1.0]);
assert!(ParticleModel::read(&root, "fx:missing").is_none());
std::fs::remove_dir_all(root).unwrap();
}
}
@@ -0,0 +1,155 @@
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
use super::*;
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
use std::sync::Arc;
use std::time::{Duration, Instant};
const FLOOR_Y: i32 = 289;
/// A cheap, repeatable stream of numbers in [0, 1).
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
}
fn signed(&mut self) -> f64 {
self.next() * 2.0 - 1.0
}
}
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
for _ in 0..rockets {
// `summon firework_rocket` with a large ball of three colours and a trail, bursting 18 ticks up.
let explosion = FireworkExplosion {
shape: FireworkShape::LargeBall,
colors: vec![0xFF0000, 0x00FF00, 0x0000FF],
fade: Vec::new(),
trail: true,
twinkle: false,
};
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
}
for &(ty, count, center, delta, speed) in types {
let options = ParticleOptions::simple(ty);
for _ in 0..count {
let p = [
center[0] + rng.signed() * delta[0],
center[1] + rng.signed() * delta[1],
center[2] + rng.signed() * delta[2],
];
let v = [
rng.signed() * speed,
rng.signed() * speed,
rng.signed() * speed,
];
engine.add(&options, p, v);
}
}
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("cached-collision.nbt").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = collisions::load(&root, assets.registry).unwrap();
let mut world = World::new(facts.air, 0);
let floor = assets
.registry
.default_state("minecraft:smooth_stone")
.unwrap();
for cz in -1..=3 {
for cx in -2..=2 {
world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
for z in 2..=40 {
for x in -24..=24 {
world.set(x, FLOOR_Y, z, floor).unwrap();
}
}
let level = ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
let cosy: Emitter = (
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
);
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
let cases: [(&str, Vec<Emitter>, usize); 7] = [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
for (name, types, rockets) in cases {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, &types, rockets);
engine.tick(&level);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, &types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(&level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
}
}
@@ -0,0 +1,260 @@
//! Resource packs' Polytone post chains (#11486, wiki "Shaders"): vanilla `post_effect` chains a
//! pack runs over the world while their `activation_condition` holds, with uniform blocks from
//! expressions (`expression_uniforms`), the `PolyGlobals` block, and targets of their own
//! (`post_targets`) kept between frames. `post_chain` runs them; this reads what they add.
use crate::environment::Frame;
use crate::post_chain::{Extras, InternalTarget};
use horizon_polytone::expr::Program;
use serde_json::Value as Json;
use std::path::Path;
/// A `polytone/post_chains/<path>.json`.
struct Chain {
/// The `post_effect` chain it runs.
effect: String,
activation: Option<Program>,
/// Each expression uniform: its block's name and expression.
uniforms: Vec<(String, Program)>,
}
/// The packs' post chains and targets.
pub struct PolytonePost {
chains: Vec<Chain>,
targets: std::collections::BTreeMap<String, InternalTarget>,
configs: horizon_polytone::pack::Configs,
}
/// What `PolyGlobals` holds (wiki "Extra Shader Uniforms").
#[derive(Clone, Copy, Debug, Default)]
pub struct PolyGlobals {
/// The world's projection (reverse-Z, as 26.3's and Horizon's are), and its model-view (the
/// camera's turn).
pub projection: glam::Mat4,
pub model_view: glam::Mat4,
/// The sun's angle in radians, 0 at the horizon.
pub sun_angle: f32,
/// The time of day in ticks (0 to 24000), and the game ticks this frame advanced.
pub day_time: f32,
pub delta_time: f32,
/// The player's feet: `PolyPlayerBlockPos`, and `PolyPlayerOffset` as vanilla's
/// `CameraOffset` is (the block position less the position, so `polyPlayerPos()` is the
/// block position less the offset).
pub player: [f64; 3],
}
impl PolyGlobals {
/// The std140 block: `mat4, mat4, float, float, float, ivec3, vec3`.
pub fn bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(176);
for m in [self.projection, self.model_view] {
out.extend(m.to_cols_array().iter().flat_map(|v| v.to_le_bytes()));
}
for v in [self.sun_angle, self.day_time, self.delta_time, 0.0] {
out.extend(v.to_le_bytes());
}
let block = self.player.map(f64::floor);
for v in block {
out.extend((v as i32).to_le_bytes());
}
out.extend(0i32.to_le_bytes());
for i in 0..3 {
out.extend(((block[i] - self.player[i]) as f32).to_le_bytes());
}
out.extend(0f32.to_le_bytes());
out
}
}
/// Every `.json` under `assets/<namespace>/polytone/<kind>`, by its implicit id.
fn files(root: &Path, kind: &str) -> Vec<(String, Json)> {
let mut out = Vec::new();
let namespaces = std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten();
for namespace in namespaces {
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 relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let Some(id) = horizon_polytone::implicit_id(&relative, kind) else {
continue;
};
match horizon_world::json_file::read::<Json>(&path) {
Ok(json) => out.push((id, json)),
Err(e) => log::warn!("{relative}: {e}"),
}
}
}
}
out
}
fn compile(where_: &str, src: &Json) -> Option<Program> {
let text = match src {
Json::String(s) => s.clone(),
Json::Number(n) => n.to_string(),
Json::Bool(b) => b.to_string(),
_ => return None,
};
match Program::compile(&text) {
Ok(p) => Some(p),
Err(e) => {
log::warn!("{where_}: {e}");
None
}
}
}
impl PolytonePost {
/// The post chains and targets of the packs merged at `root`; `None` without chains.
pub fn read(root: &Path, configs: horizon_polytone::pack::Configs) -> Option<Self> {
let chains: Vec<Chain> = files(root, "post_chains")
.into_iter()
.filter_map(|(id, json)| {
let effect = json["post_chain"].as_str()?;
let effect = if effect.contains(':') {
effect.to_owned()
} else {
format!("minecraft:{effect}")
};
let activation = json
.get("activation_condition")
.and_then(|c| compile(&id, c));
let uniforms = json["expression_uniforms"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(name, src)| Some((name.clone(), compile(&id, src)?)))
.collect();
Some(Chain {
effect,
activation,
uniforms,
})
})
.collect();
if chains.is_empty() {
return None;
}
let targets = files(root, "post_targets")
.into_iter()
.map(|(id, json)| {
let size = |key: &str| json[key].as_u64().filter(|n| *n > 0).map(|n| n as u32);
let target = InternalTarget {
width: size("width"),
height: size("height"),
persistent: true,
clear: [0.0; 4],
};
(id, target)
})
.collect();
Some(Self {
chains,
targets,
configs,
})
}
/// The chains to run this frame (those whose condition holds), and what they add.
pub fn frame(&self, frame: &Frame, globals: &PolyGlobals) -> (Vec<String>, Extras) {
let mut effects = Vec::new();
let mut extras = Extras {
targets: self.targets.clone(),
blocks: Default::default(),
};
extras
.blocks
.insert("PolyGlobals".to_owned(), globals.bytes());
for chain in &self.chains {
let on = chain.activation.as_ref().is_none_or(|c| {
crate::environment::eval_frame(c, frame, &self.configs).is_some_and(|v| v.truthy())
});
if !on {
continue;
}
for (name, program) in &chain.uniforms {
let value = crate::environment::eval_frame(program, frame, &self.configs)
.map_or(0.0, |v| v.num()) as f32;
let mut block = value.to_le_bytes().to_vec();
block.resize(16, 0);
extras.blocks.insert(name.clone(), block);
}
effects.push(chain.effect.clone());
}
(effects, extras)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn poly_globals_follow_std140() {
let g = PolyGlobals {
projection: glam::Mat4::from_diagonal(glam::Vec4::splat(2.0)),
sun_angle: 1.5,
day_time: 6000.0,
delta_time: 0.5,
player: [10.25, -3.5, 7.0],
..Default::default()
};
let b = g.bytes();
let f = |at: usize| f32::from_le_bytes(b[at..at + 4].try_into().unwrap());
let i = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
assert_eq!(b.len(), 176);
assert_eq!(f(0), 2.0);
assert_eq!((f(128), f(132), f(136)), (1.5, 6000.0, 0.5));
// ivec3 at 144, vec3 at 160 (std140 aligns both to 16). The offset is the block position
// less the position, as vanilla's CameraOffset: the block position less it is the position.
assert_eq!((i(144), i(148), i(152)), (10, -4, 7));
assert_eq!((f(160), f(164), f(168)), (-0.25, -0.5, 0.0));
}
#[test]
fn chains_run_while_their_condition_holds_with_their_uniforms() {
let root =
std::env::temp_dir().join(format!("horizon-polytone-post-{}", std::process::id()));
let write = |path: &str, text: &str| {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
};
write(
"assets/p/polytone/post_chains/glare.json",
r#"{"activation_condition": "g.rain() < 0.5", "post_chain": "p:glare",
"expression_uniforms": {"Weather": "1 - g.rain()"}}"#,
);
write(
"assets/p/polytone/post_targets/history.json",
r#"{"width": 1, "height": 1}"#,
);
let post = PolytonePost::read(&root, Default::default()).unwrap();
let mut frame = Frame {
rain: 0.25,
..Frame::default()
};
let (effects, extras) = post.frame(&frame, &PolyGlobals::default());
assert_eq!(effects, ["p:glare"]);
// `g.rain` is the mean of rain (0.25) and thunder (0): 0.125.
assert_eq!(
f32::from_le_bytes(extras.blocks["Weather"][..4].try_into().unwrap()),
0.875
);
assert!(extras.targets["p:history"].persistent);
frame.rain = 1.0;
assert!(post.frame(&frame, &PolyGlobals::default()).0.is_empty());
std::fs::remove_dir_all(root).unwrap();
}
}
+309 -17
View File
@@ -385,6 +385,8 @@ enum Block {
Globals,
/// One of the pass's `uniforms` groups.
Custom(wgpu::Buffer),
/// A block the request sets every frame (`Extras::blocks`), by name.
Dynamic(String, wgpu::Buffer),
}
/// A pass running the resources' own GLSL, translated by `post_glsl`.
@@ -474,6 +476,17 @@ pub struct Request<'a> {
pub menu_blur_radius: f32,
/// What the `Globals` block holds besides the screen size and the blur radius.
pub globals: Globals,
/// What resource packs' Polytone post chains add, when a pack has some.
pub extras: Option<&'a Extras>,
}
/// What resource packs' Polytone post chains add to every chain (#11486, wiki "Shaders"): their
/// declared targets (`post_targets`), kept between frames, and uniform blocks set every frame.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Extras {
pub targets: BTreeMap<String, InternalTarget>,
/// Each block's std140 bytes, by its name (`PolyGlobals`, an expression uniform's).
pub blocks: BTreeMap<String, Vec<u8>>,
}
/// `GlobalSettingsUniform.update`'s other values.
@@ -516,6 +529,7 @@ impl<'a> Request<'a> {
root: Path::new(""),
menu_blur_radius: 0.0,
globals: Globals::default(),
extras: None,
}
}
}
@@ -544,8 +558,31 @@ pub struct PostEffects {
/// root they load from; `pending` until they arrive.
preloading: Option<(PathBuf, mpsc::Receiver<Preloaded>)>,
pending: HashSet<String>,
/// This frame's `Extras::blocks`.
extra_blocks: BTreeMap<String, Vec<u8>>,
/// The frame's depth with GL's rows, for the passes that read `minecraft:main`'s: the
/// pipeline, and the texture at the frame's size.
gl_depth: Option<(wgpu::BindGroupLayout, wgpu::RenderPipeline)>,
gl_depth_target: Option<(wgpu::Texture, wgpu::TextureView)>,
}
/// The frame's depth with its rows flipped as the chains' colour is (row 0 at the bottom). Its
/// values stay: 26.3 keeps reverse-Z depth as Horizon does (the sky is 0), and packs' post
/// shaders read it so.
const GL_DEPTH_WGSL: &str = r#"
@group(0) @binding(0) var depth: texture_depth_2d;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
let size = textureDimensions(depth);
let x = min(u32(at.x), size.x - 1u);
let y = size.y - 1u - min(u32(at.y), size.y - 1u);
return vec4<f32>(textureLoad(depth, vec2<u32>(x, y), 0), 0.0, 0.0, 1.0);
}
"#;
/// A chain [`PostEffects::preload`] loaded, by id.
type Preloaded = (String, Result<Option<Chain>, String>);
@@ -628,6 +665,9 @@ impl PostEffects {
failed: HashSet::new(),
preloading: None,
pending: HashSet::new(),
extra_blocks: BTreeMap::new(),
gl_depth: None,
gl_depth_target: None,
root: PathBuf::new(),
work: None,
staging: None,
@@ -685,7 +725,7 @@ impl PostEffects {
let ids = ids.clone();
move || {
for id in ids {
let mut chain = Self::load(&root, &device, &queue, &id);
let mut chain = Self::load(&root, &device, &queue, &id, &Extras::default());
if let Ok(Some(chain)) = &mut chain {
for pass in &mut chain.passes {
if let Some(translated) = &mut pass.translated {
@@ -784,11 +824,18 @@ impl PostEffects {
device: &wgpu::Device,
queue: &wgpu::Queue,
id: &str,
extras: &Extras,
) -> Result<Option<Chain>, String> {
let Ok(text) = std::fs::read_to_string(resource_path(root, id)) else {
return Ok(None);
};
let config = Config::parse(&text)?;
let mut config = Config::parse(&text)?;
for (target, t) in &extras.targets {
config
.targets
.entry(target.clone())
.or_insert_with(|| t.clone());
}
// isPostEffectValid / LevelTargetBundle.MAIN_TARGETS: the frame is the one
// external target Horizon has.
if let Some(bad) = config.external_targets().into_iter().find(|t| *t != MAIN) {
@@ -812,18 +859,26 @@ impl PostEffects {
let mut passes = Vec::new();
for (n, p) in config.passes.iter().enumerate() {
let output = index(&p.output).ok_or("a target is missing")?;
let pass =
match Self::translated_pass(root, device, queue, n, p, output, &index, &mut images)
{
Ok(pass) => pass,
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
let pass = match Self::translated_pass(
root,
device,
queue,
n,
p,
output,
&index,
&mut images,
extras,
) {
Ok(pass) => pass,
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
Ok(pass) => {
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
pass
}
Err(_) => return Err(e),
},
};
passes.push(pass);
}
Ok(Some(Chain {
@@ -847,6 +902,7 @@ impl PostEffects {
output: usize,
index: &dyn Fn(&str) -> Option<usize>,
images: &mut Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
extras: &Extras,
) -> Result<Pass, String> {
let program = crate::post_glsl::translate(root, &p.vertex, &p.fragment)?;
let mut refs = Vec::new();
@@ -879,6 +935,15 @@ impl PostEffects {
let block = match name.as_str() {
"SamplerInfo" => Block::Info,
"Globals" => Block::Globals,
other if !p.uniforms.contains_key(other) && extras.blocks.contains_key(other) => {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Post effect pack uniforms"),
size: UNIFORM_BYTES,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Block::Dynamic(other.to_owned(), buffer)
}
other => {
let values = p
.uniforms
@@ -1130,6 +1195,7 @@ impl PostEffects {
) -> bool {
self.use_root(req.root);
self.collect_preloaded();
self.extra_blocks = req.extras.map(|e| e.blocks.clone()).unwrap_or_default();
let mut applied = Vec::new();
for effect in req.effects {
let id = normalise(effect);
@@ -1138,7 +1204,9 @@ impl PostEffects {
continue;
}
if !self.chains.contains_key(&id) {
let loaded = match Self::load(&self.root, device, queue, &id) {
let empty = Extras::default();
let extras = req.extras.unwrap_or(&empty);
let loaded = match Self::load(&self.root, device, queue, &id, extras) {
Ok(chain) => chain,
Err(e) => {
log::warn!("Failed to load post effect {id}: {e}");
@@ -1207,6 +1275,11 @@ impl PostEffects {
timing.as_deref_mut(),
);
self.staging = Some(staging);
if let Some(view) = depth {
self.gl_depth(device, encoder, view, size);
}
let gl_depth = self.gl_depth_target.take();
let depth = depth.and(gl_depth.as_ref().map(|(_, v)| v));
for id in &applied {
let mut chain = self.chains.remove(id).flatten().expect("loaded above");
self.run_chain(
@@ -1237,9 +1310,117 @@ impl PostEffects {
);
self.blit = Some(blit);
self.work = Some(work);
self.gl_depth_target = gl_depth;
true
}
/// Copies `depth` (the frame's) into `gl_depth_target` with GL's rows, at `size`.
fn gl_depth(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
) {
let (layout, pipeline) = self.gl_depth.get_or_insert_with(|| {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Post effect GL depth"),
source: wgpu::ShaderSource::Wgsl(GL_DEPTH_WGSL.into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Post effect GL depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Post effect GL depth"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Post effect GL depth"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::R32Float.into())],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
(layout, pipeline)
});
if self
.gl_depth_target
.as_ref()
.is_none_or(|(t, _)| t.size().width != size[0] || t.size().height != size[1])
{
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Post effect GL depth"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.gl_depth_target = Some((texture, view));
}
let (_, target) = self.gl_depth_target.as_ref().expect("made above");
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Post effect GL depth"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(depth),
}],
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Post effect GL depth"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
#[allow(clippy::too_many_arguments)]
fn run_chain(
&mut self,
@@ -1438,6 +1619,13 @@ impl PostEffects {
Block::Info => &pass.info,
Block::Globals => &self.globals,
Block::Custom(buffer) => buffer,
Block::Dynamic(name, buffer) => {
if let Some(bytes) = self.extra_blocks.get(name) {
let n = bytes.len().min(UNIFORM_BYTES as usize) & !3;
queue.write_buffer(buffer, 0, &bytes[..n]);
}
buffer
}
};
entries.push(wgpu::BindGroupEntry {
binding: *binding,
@@ -1831,6 +2019,7 @@ mod tests {
root,
menu_blur_radius: 5.0,
globals: Globals::default(),
extras: None,
},
None,
);
@@ -1962,6 +2151,7 @@ mod tests {
root,
menu_blur_radius: 0.0,
globals: Globals::default(),
extras: None,
},
None,
));
@@ -2050,6 +2240,99 @@ mod tests {
std::fs::remove_dir_all(root).unwrap();
}
/// The frame's depth reaches a pass as 26.3 keeps it, reverse-Z (the sky 0, a near pixel
/// close to 1), not as GL's window depth.
#[test]
fn a_pass_reads_the_frame_depth_as_it_is() {
let Some((device, queue)) = device() else {
return;
};
let root = scratch(
"depth",
r#"{"passes":[{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/depth",
"inputs":[{"sampler_name":"Depth","target":"minecraft:main","use_depth_buffer":true}],
"output":"minecraft:main"}]}"#,
);
own_glsl(&root);
std::fs::write(
root.join("assets/minecraft/shaders/post/depth.fsh"),
"#version 330\nuniform sampler2D DepthSampler;\nlayout(location = 0) in vec2 texCoord;\n\
layout(location = 0) out vec4 fragColor;\n\
void main() { fragColor = vec4(texture(DepthSampler, texCoord).r, 0.0, 0.0, 1.0); }\n",
)
.unwrap();
let size = wgpu::Extent3d {
width: 8,
height: 4,
depth_or_array_layers: 1,
};
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let frame = texture(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
let (view, depth_view) = (
frame.create_view(&Default::default()),
depth.create_view(&Default::default()),
);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.25),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
let mut effects = PostEffects::new(&device);
let ran = effects.apply(
&device,
&queue,
&mut encoder,
&frame,
&view,
[8, 4],
Some(&depth_view),
&Request {
effects: &["minecraft:depth".to_owned()],
root: &root,
menu_blur_radius: 0.0,
globals: Globals::default(),
extras: None,
},
None,
);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [8, 4]);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let red = buffer.get_mapped_range(..).unwrap()[0];
assert!(ran);
// 0.25 of 255, not 0.75.
assert!(red.abs_diff(64) <= 1, "{red}");
std::fs::remove_dir_all(root).unwrap();
}
/// `core/quad.vsh`, `post/mine.fsh` and the `tint` image of `a_chain_runs_its_own_glsl`.
fn own_glsl(root: &Path) {
let shaders = root.join("assets/minecraft/shaders");
@@ -2189,13 +2472,22 @@ mod tests {
for name in ["blur", "creeper", "invert", "spider"] {
let id = format!("minecraft:{name}");
assert!(
PostEffects::load(&root, &device, &queue, &id)
PostEffects::load(&root, &device, &queue, &id, &Extras::default())
.unwrap()
.is_some(),
"{id}"
);
}
// entity_outline needs targets and shaders the world pass has of its own.
assert!(PostEffects::load(&root, &device, &queue, "minecraft:entity_outline").is_err());
assert!(
PostEffects::load(
&root,
&device,
&queue,
"minecraft:entity_outline",
&Extras::default()
)
.is_err()
);
}
}
+4 -5
View File
@@ -165,7 +165,7 @@ pub struct Context {
pub spectator: bool,
/// `isSteppingCarefully` (sneaking).
pub stepping_carefully: bool,
/// `dampensVibrations` (a warden).
/// `dampensVibrations` (a warden or a wool item).
pub dampens: bool,
/// The source is a living entity wardens may target.
pub targetable: bool,
@@ -187,11 +187,10 @@ pub fn valid_vibration(listens: bool, event: &str, ctx: &Context) -> bool {
if ctx.stepping_carefully && ignored_when_sneaking(event) {
return false;
}
if ctx.dampens {
return false;
}
}
!ctx.dampening_state
// Nonliving sources have no Who handle, but ItemEntity still carries its
// stack's dampensVibrations flag (including after Entity.kill removes it).
!ctx.dampens && !ctx.dampening_state
}
impl Selector {
File diff suppressed because it is too large. Load diff
@@ -19,8 +19,11 @@ pub struct ShaderOptions {
pub chosen: BTreeMap<String, String>,
/// The screens opened from the main one, innermost last.
pub path: Vec<String>,
/// Something changed since the page opened.
/// The values differ from those the pack's file holds (`saved`): saving them loads the pack
/// again.
pub changed: bool,
/// The values the pack's file holds, as read or as last saved.
saved: BTreeMap<String, String>,
}
impl ShaderOptions {
@@ -39,14 +42,28 @@ impl ShaderOptions {
chosen: BTreeMap::new(),
path: Vec::new(),
changed: false,
saved: BTreeMap::new(),
};
for (name, value) in chosen {
out.set(&name, value);
}
out.saved = out.chosen.clone();
out.changed = false;
out
}
/// The values back to `chosen` (Undo): changed while they differ from the file's.
pub fn restore(&mut self, chosen: BTreeMap<String, String>) {
self.chosen = chosen;
self.changed = self.chosen != self.saved;
}
/// The pack's file holds `saved` now, saved by another way than this page's.
pub fn saved_as(&mut self, saved: BTreeMap<String, String>) {
self.saved = saved;
self.changed = self.chosen != self.saved;
}
/// The screen shown: the innermost opened, else the main one.
pub fn screen(&self) -> &str {
self.path.last().map_or("", String::as_str)
@@ -74,7 +91,7 @@ impl ShaderOptions {
} else {
self.chosen.insert(name.to_owned(), value);
}
self.changed = true;
self.changed = self.chosen != self.saved;
}
/// The option's next value (the previous one with `back`), wrapping around.
@@ -123,9 +140,8 @@ impl ShaderOptions {
/// Option `name` back at the pack's default.
pub fn reset_option(&mut self, name: &str) {
if self.chosen.remove(name).is_some() {
self.changed = true;
}
self.chosen.remove(name);
self.changed = self.chosen != self.saved;
}
/// The profile after the current one (the first when none matches) with its values.
@@ -150,10 +166,8 @@ impl ShaderOptions {
}
pub fn reset(&mut self) {
if !self.chosen.is_empty() {
self.chosen.clear();
self.changed = true;
}
self.chosen.clear();
self.changed = self.chosen != self.saved;
}
fn named(&self, key: &str, or: &str) -> Text {
@@ -73,29 +73,6 @@ impl Entry {
changed
}
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
/// value changed.
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let dir = if back { -1.0 } else { 1.0 };
let new = match &o.kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let n = ((*v - min) / step).round() + dir;
on_f32((min + n * step).clamp(min, max))
}
OptionKind::Int { min, max, .. } => {
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
}
_ => return false,
};
let changed = new != *v;
*v = new;
changed
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
@@ -353,15 +330,36 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
}
}
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
/// that are buttons.
/// A float option's stops past its minimum: its steps, the last one cut short at the maximum
/// when the range isn't a whole number of them.
pub fn float_steps(min: f32, max: f32, step: f32) -> f64 {
let (min, max, step) = (f64::from(min), f64::from(max), f64::from(step));
((max - min) / step - STEP_SLACK).ceil()
}
/// What a range a hair over a whole number of steps loses to rounding, short of another stop.
const STEP_SLACK: f64 = 1e-6;
/// Where `v` sits between the option's minimum and maximum, 0 to 1, as `slider_value` reads
/// it back: a float at its stop (the maximum the last one), an int along its range. `None` for
/// the options that are buttons.
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
let (min, max) = match kind {
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
let position = match kind {
OptionKind::Float { min, max, step, .. } => {
let steps = float_steps(*min, *max, *step);
let stop = ((v - f64::from(*min)) / f64::from(*step)).round();
if v >= f64::from(*max) || steps <= 0.0 {
1.0
} else {
stop / steps
}
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
(v - min) / (max - min)
}
_ => return None,
};
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
Some(position.clamp(0.0, 1.0) as f32)
}
/// The option's value nearest the slider position `at`: a float on its steps from the
@@ -371,8 +369,8 @@ fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
let at = f64::from(at.clamp(0.0, 1.0));
match kind {
OptionKind::Float { min, max, step, .. } => {
let steps = float_steps(*min, *max, *step);
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let steps = ((max - min) / step - 1e-6).ceil();
Some(on_f32((min + (at * steps).round() * step).min(max)))
}
OptionKind::Int { min, max, .. } => {
@@ -649,17 +647,47 @@ mod tests {
assert_eq!(entry.values[3], 1.0);
entry.set_slider_position(3, 0.6);
assert_eq!(entry.values[3], f64::from(0.6_f32));
}
// The arrow keys: a step at a time, not round past the ends.
entry.values[0] = 2.0;
assert!(!entry.nudge_option(0, false));
assert_eq!(entry.values[0], 2.0);
assert!(entry.nudge_option(0, true));
assert_eq!(entry.values[0], f64::from(1.95_f32));
entry.values[1] = 0.0;
assert!(!entry.nudge_option(1, true));
assert!(entry.nudge_option(1, false));
assert_eq!(entry.values[1], 1.0);
assert!(!entry.nudge_option(2, false));
/// The arrow keys step a slider through its stops (the shell's `slider_step`, by the stops
/// `float_steps` counts): a float whose range isn't a whole number of steps walks every one
/// both ways, its maximum included, and stops at the ends.
#[test]
fn the_arrow_keys_walk_a_floats_stops_both_ways() {
let kind = OptionKind::Float {
default: 0.0,
min: 0.0,
max: 1.0,
step: 0.3,
};
let mut entry = Entry {
file: "p".into(),
key: "horizon:p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![horizon_shader_api::PackOption {
id: "d".into(),
name: "D".into(),
description: String::new(),
kind,
}],
values: vec![0.0],
pages: Vec::new(),
};
let steps = float_steps(0.0, 1.0, 0.3) as u32;
let mut walk = |forward: bool| {
let mut seen = Vec::new();
for _ in 0..steps + 2 {
let at = entry.slider_position(0).unwrap();
let next = crate::settings_shell::slider_step(at, Some(steps), 100.0, forward);
entry.set_slider_position(0, next);
seen.push(entry.values[0]);
}
seen
};
// The `k`th stop, as the pack's f32 `override` gets it.
let stop = |k: u8| f64::from((f64::from(k) * f64::from(0.3_f32)) as f32);
assert_eq!(walk(true), [stop(1), stop(2), stop(3), 1.0, 1.0, 1.0]);
assert_eq!(walk(false), [stop(3), stop(2), stop(1), 0.0, 0.0, 0.0]);
}
}
File diff suppressed because it is too large. Load diff
+22 -2
View File
@@ -5,6 +5,13 @@ pub use crate::chunk::block_entity::{SavedLine, SignData, SignText};
use crate::{scoreboard, text::Text};
use horizon_world::nbt::Tag;
/// `getBooleanOr(key, false)` and `Codec.BOOL`: any numeric tag's nonzero byte.
fn nbt_flag(tag: &Tag, key: &str) -> bool {
tag.get(key)
.and_then(horizon_client_core::nbt_path::int_value)
.is_some_and(|value| value as i8 != 0)
}
/// `DyeColor` names and `getTextColor` values by id.
pub const DYE_TEXT: [(&str, u32); 16] = [
("white", 0xFFFFFF),
@@ -201,12 +208,12 @@ impl SignData {
out.color = i as u8;
}
}
out.glowing = matches!(t.get("has_glowing_text"), Some(Tag::Byte(b)) if *b != 0);
out.glowing = nbt_flag(t, "has_glowing_text");
out
};
self.front = side(tag.get("front_text"));
self.back = side(tag.get("back_text"));
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
self.waxed = nbt_flag(tag, "is_waxed");
self.allow_op_features =
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
}
@@ -301,6 +308,19 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
mod tests {
use super::*;
#[test]
fn waxed_and_glowing_text_take_any_number() {
let tag = Tag::compound([
("is_waxed", Tag::Int(1)),
(
"front_text",
Tag::compound([("has_glowing_text", Tag::Short(1))]),
),
]);
let mut decoded = SignData::default();
decoded.decode(&tag);
assert!(decoded.waxed && decoded.front.glowing);
}
#[test]
fn round_trips_nbt() {
let mut data = SignData {
hanging: false,
@@ -978,6 +978,10 @@ pub struct SoundEngine {
instance_by_source: Vec<(usize, InstanceId)>,
gain_by_source: [f32; 11],
ticking: Vec<InstanceId>,
live_scratch: Vec<(u64, ChannelId)>,
mapped_scratch: Vec<u64>,
removed_scratch: Vec<InstanceId>,
ticking_scratch: Vec<InstanceId>,
/// `queuedSounds` (insertion order).
queued: Vec<(InstanceId, i32)>,
delete_time: HashMap<InstanceId, i32>,
@@ -1015,6 +1019,10 @@ impl SoundEngine {
instance_by_source: Vec::new(),
gain_by_source: [1.0; 11],
ticking: Vec::new(),
live_scratch: Vec::new(),
mapped_scratch: Vec::new(),
removed_scratch: Vec::new(),
ticking_scratch: Vec::new(),
queued: Vec::new(),
delete_time: HashMap::new(),
queued_tickable: Vec::new(),
@@ -1479,20 +1487,26 @@ impl SoundEngine {
self.schedule_tick(backend);
}
fn schedule_tick(&mut self, backend: &mut impl Backend) {
let live: Vec<(u64, ChannelId)> = self
.handles
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c)))
.collect();
for (h, c) in live {
let mut live = std::mem::take(&mut self.live_scratch);
live.clear();
live.extend(
self.handles
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
);
for (h, c) in live.drain(..) {
if backend.stopped(c) {
self.release_handle(h, backend);
}
}
// Released handles nobody maps any more.
let mapped: Vec<u64> = self.instance_to_channel.iter().map(|(_, h)| *h).collect();
let mut mapped = std::mem::take(&mut self.mapped_scratch);
mapped.clear();
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
self.handles
.retain(|h, handle| handle.channel.is_some() || mapped.contains(h));
self.live_scratch = live;
self.mapped_scratch = mapped;
}
fn handle_stopped(&self, handle: u64) -> bool {
self.handles.get(&handle).is_none_or(|h| h.stopped)
@@ -1502,7 +1516,10 @@ impl SoundEngine {
for id in std::mem::take(&mut self.queued_tickable) {
self.play(id, backend);
}
for id in self.ticking.clone() {
let mut ticking = std::mem::take(&mut self.ticking_scratch);
ticking.clear();
ticking.extend(self.ticking.iter().copied());
for id in ticking.drain(..) {
let Some(instance) = self.instances.get(&id) else {
continue;
};
@@ -1549,7 +1566,8 @@ impl SoundEngine {
self.notify(id, subtitle, distance);
}
}
let mut removed = Vec::new();
let mut removed = std::mem::take(&mut self.removed_scratch);
removed.clear();
let mut index = 0;
while index < self.instance_to_channel.len() {
let (id, handle) = self.instance_to_channel[index];
@@ -1596,9 +1614,11 @@ impl SoundEngine {
index += 1;
}
}
for id in removed {
for id in removed.drain(..) {
self.collect(id);
}
self.ticking_scratch = ticking;
self.removed_scratch = removed;
}
fn tick_music_when_paused(&mut self) {
let mut index = 0;
+3 -5
View File
@@ -1654,11 +1654,9 @@ mod tests {
"L'écran partagé est désactivé."
);
assert!(labels(&ui).contains(&"Écran partagé : Désactivé".to_owned()));
// German translates it now; a key a language lacks still falls back to the bundled
// English (lang's `layers_selected_language_over_english`).
lang::set(lang::Language::load(&[], "de_de"));
assert_eq!(
ui.status_lines().unwrap()[0],
"Splitscreen is off.",
"untranslated new keys use bundled English"
);
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen ist aus.");
}
}
+34 -11
View File
@@ -530,6 +530,12 @@ pub struct Streaming {
saves: Arc<dyn Saves>,
/// Results received while `save` waited, for the next `update`.
deferred: Vec<Done>,
/// Per-update collection scratch, retained to avoid allocator churn while
/// the view is moving or many columns are dirty.
light_scratch: Vec<(i64, i32, i32)>,
gone_scratch: Vec<(i32, i32)>,
mesh_candidates_scratch: Vec<(i64, i32, i32, SectionMask)>,
mesh_columns_scratch: Vec<((i32, i32), SectionMask)>,
sequence: u64,
pub center: (i32, i32),
/// The view distance around `center`; below 0, no area there (a dedicated
@@ -1130,6 +1136,10 @@ impl Streaming {
written: Default::default(),
saves,
deferred: Vec::new(),
light_scratch: Vec::new(),
gone_scratch: Vec::new(),
mesh_candidates_scratch: Vec::new(),
mesh_columns_scratch: Vec::new(),
sequence: 0,
center: (0, 0),
radius: 8,
@@ -1664,6 +1674,15 @@ impl Streaming {
if self.paused {
return (uploads, Vec::new());
}
let mut stale = Vec::new();
let mut light = std::mem::take(&mut self.light_scratch);
light.clear();
let mut gone = std::mem::take(&mut self.gone_scratch);
gone.clear();
let mut candidates = std::mem::take(&mut self.mesh_candidates_scratch);
candidates.clear();
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
columns.clear();
ExtraCover::refresh(&mut self.cover, &self.extra);
self.cover_current = true;
// The sweeps below walk every loaded column and proto chunk. Run them
@@ -1678,7 +1697,6 @@ impl Streaming {
let deferred = std::mem::take(&mut self.deferred);
let received = self.shared.done.take();
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
let mut stale = Vec::new();
for done in deferred.into_iter().chain(received) {
match done {
Done::Proto(x, z, proto, decorated) => {
@@ -1831,8 +1849,8 @@ impl Streaming {
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
});
// Finalize chunks whose neighbourhood is decorated.
let candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
for (x, z) in candidates {
let finalize_candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
for (x, z) in finalize_candidates {
if world.chunks.contains_key(&(x, z))
|| self.finishing.contains(&(x, z))
|| !self.in_range(x, z, 1)
@@ -1990,8 +2008,6 @@ impl Streaming {
// grew with the render distance and outlived the columns they
// were queued for (#3629). What stays in `dirty_light` is asked
// for again, from the current centre, as capacity frees.
let mut light: Vec<(i64, i32, i32)> = Vec::new();
let mut gone: Vec<(i32, i32)> = Vec::new();
for &(x, z) in world.dirty_light.iter() {
if !world.chunks.contains_key(&(x, z)) {
gone.push((x, z));
@@ -1999,12 +2015,12 @@ impl Streaming {
light.push((self.distance(x, z), x, z));
}
}
for key in gone {
for key in gone.drain(..) {
world.dirty_light.remove(&key);
}
let room = (self.workers * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
for (_, x, z) in light.drain(..) {
let Some(region) = self.light_region(world, x, z) else {
continue;
};
@@ -2023,6 +2039,10 @@ impl Streaming {
if !self.mesh {
world.dirty_sections.clear();
self.cover_current = false;
self.light_scratch = light;
self.gone_scratch = gone;
self.mesh_candidates_scratch = candidates;
self.mesh_columns_scratch = columns;
return (uploads, removed);
}
// Meshing once the column and its neighbours are lit, and the column
@@ -2056,9 +2076,8 @@ impl Streaming {
}
// Column by column: the checks below hold for all of a column's
// sections, and columns waiting for light cost one look per frame.
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
let columns: Vec<_> = world.dirty_sections.columns().collect();
for ((x, z), sections) in columns {
columns.extend(world.dirty_sections.columns());
for ((x, z), sections) in columns.iter().copied() {
if !world.chunks.contains_key(&(x, z)) {
world.dirty_sections.take_column(x, z);
continue;
@@ -2096,7 +2115,7 @@ impl Streaming {
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.workers * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
for (_, x, z, sections) in candidates.drain(..) {
let Some(region) = Self::region(world, x, z) else {
continue;
};
@@ -2132,6 +2151,10 @@ impl Streaming {
}
}
self.cover_current = false;
self.light_scratch = light;
self.gone_scratch = gone;
self.mesh_candidates_scratch = candidates;
self.mesh_columns_scratch = columns;
(uploads, removed)
}
fn schedule_generation(&mut self, world: &World, workers: usize) {
+8 -16
View File
@@ -151,31 +151,23 @@ fn rotated_grid(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>,
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
// From this level on the sprite is a single texel (0: the views stop before one is), and
// the sampler clamps any level past it to it: there every tap reads the same value, so
// one tap gives the four's mean. Distant tiles sit there.
let single = globals.filtering.z;
if single > 0.0 && low >= single {
return sprite_level(uv, layer, low);
if single > 0.0 && floor(exact) >= single {
return sprite_level(uv, layer, floor(exact));
}
let high_single = single > 0.0 && low + 1.0 >= single;
// sampleRGSS takes each of the four taps at the two mip levels around `exact` and mixes them
// by fract(exact). The sprite sampler's mip filter is linear, so a tap at the fractional
// level is that mix: four texture instructions where the shader mix took eight.
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
var sum_high = vec4(0.0);
var sum = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = uv + offsets[i] * pixel;
sum_low += sprite_level(p, layer, low);
if !high_single {
sum_high += sprite_level(p, layer, low + 1.0);
}
sum += sprite_level(uv + offsets[i] * pixel, layer, exact);
}
if high_single {
sum_high = 4.0 * sprite_level(uv, layer, low + 1.0);
}
return mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return sum * 0.25;
}
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
@@ -1,4 +1,9 @@
//! On-screen touch controls for touch-screen devices (Android first). Nothing
//! here runs unless a touch surface is in use.
pub mod aim;
pub mod layout;
pub use aim::{
attack_indicator, camera_delta, hotbar_slot, item_places_entity_on_tap, item_uses_on_tap,
projectile_crosshair, screen_direction,
};
pub use layout::{Control, Mode, Placement, Settings};
@@ -0,0 +1,255 @@
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
// 150a414b09a1, `crates/horizon-client-render/src/touch.rs`), written by Vasysik and the
// Starling contributors. Copyright (c) 2026 Ludovic Marie and the Horizon contributors, under the
// same MIT license as Horizon (LICENSE at the repository root).
//! What a touch aims at and what it does: camera turns from a drag, the ray
//! through a tapped point, the hotbar slot under a finger, and which held
//! items act on a short tap. Pure functions; the touch controls call them.
use super::layout::Settings;
/// Items that place an entity where the player taps, like a block: a short tap
/// places them instead of waiting for a long press.
pub fn item_places_entity_on_tap(item: &str) -> bool {
let Some(id) = item.strip_prefix("minecraft:") else {
return false;
};
id.ends_with("_boat")
|| id.ends_with("_raft")
|| id.ends_with("_spawn_egg")
|| id == "minecart"
|| id.ends_with("_minecart")
|| matches!(
id,
"armor_stand" | "item_frame" | "glow_item_frame" | "painting" | "end_crystal"
)
}
/// Items used on the tapped surface (buckets, bone meal, flint and steel...):
/// a short tap uses them instead of waiting for a long press.
pub fn item_uses_on_tap(item: &str) -> bool {
item_places_entity_on_tap(item)
|| item
.strip_prefix("minecraft:")
.is_some_and(|id| id.ends_with("_bucket") && id != "milk_bucket")
|| matches!(
item,
"minecraft:bone_meal"
| "minecraft:flint_and_steel"
| "minecraft:fire_charge"
| "minecraft:honeycomb"
| "minecraft:glass_bottle"
| "minecraft:bucket"
)
}
/// Items that are aimed rather than pointed at a block: while one is held, the
/// tap schemes show the crosshair and shoot along it.
pub fn projectile_crosshair(item: &str) -> bool {
matches!(
item,
"minecraft:bow"
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:snowball"
| "minecraft:egg"
| "minecraft:ender_pearl"
| "minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:wind_charge"
| "minecraft:fishing_rod"
)
}
/// The camera turn in degrees (yaw, pitch) for a drag of `delta`, normalized to
/// a `width` x `height` surface. Equal physical drags turn equally on either
/// axis, in landscape and in portrait; a drag across the long side is 180°.
pub fn camera_delta(delta: [f32; 2], width: u32, height: u32, s: &Settings) -> [f32; 2] {
let scale = width.max(height).max(1) as f32;
[
delta[0] * width as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_horizontal,
delta[1] * height as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_vertical,
]
}
/// The attack indicator to draw (the `attackIndicator` option: 0 off,
/// 1 crosshair, 2 hotbar). Without a crosshair, the crosshair gauge moves to
/// the hotbar, as in vanilla's hotbar setting.
pub fn attack_indicator(setting: u8, crosshair: bool) -> u8 {
if setting == 1 && !crosshair {
2
} else {
setting
}
}
/// The unit ray from the eye through `point` (normalized surface coordinates,
/// y down) for a perspective view with vertical `fov` in degrees. The view
/// itself does not turn: a tap acts where it lands.
pub fn screen_direction(
forward: [f64; 3],
up: [f64; 3],
point: [f32; 2],
aspect: f64,
fov: f64,
) -> [f64; 3] {
// forward x up: screen right in the game's coordinates.
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
let t = (fov.to_radians() / 2.0).tan();
let x = (f64::from(point[0]) * 2.0 - 1.0) * aspect * t;
let y = (1.0 - f64::from(point[1]) * 2.0) * t;
let ray = std::array::from_fn::<_, 3, _>(|i| forward[i] + right[i] * x + up[i] * y);
let length = ray.iter().map(|v| v * v).sum::<f64>().sqrt();
ray.map(|v| v / length)
}
/// The hotbar slot under `point`, in GUI pixels on a `width` x `height` GUI:
/// the nine 20-pixel slots of the 22-pixel bar centred at the bottom.
pub fn hotbar_slot(width: f32, height: f32, point: [f32; 2]) -> Option<usize> {
let x = point[0] - (width / 2.0 - 90.0);
(point[1] >= height - 22.0 && point[1] < height && (0.0..180.0).contains(&x))
.then(|| (x / 20.0) as usize)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn camera_turns_by_physical_distance_on_both_axes() {
let mut settings = Settings::default();
for (w, h) in [(2400, 1080), (1080, 2400)] {
// 100 pixels right and 100 pixels down.
let drag = [100.0 / w as f32, 100.0 / h as f32];
let turn = camera_delta(drag, w, h, &settings);
assert!((turn[0] - turn[1]).abs() < 0.0001);
settings.sensitivity_vertical = 0.5;
let tuned = camera_delta(drag, w, h, &settings);
assert!((tuned[0] - tuned[1] * 2.0).abs() < 0.0001);
settings.sensitivity_vertical = 1.0;
}
let full = camera_delta([1.0, 0.0], 2400, 1080, &settings);
assert!((full[0] - 180.0).abs() < 0.0001);
settings.sensitivity = 2.0;
let fast = camera_delta([0.5, 0.0], 2400, 1080, &settings);
assert!((fast[0] - 180.0).abs() < 0.0001);
// A zero-sized surface turns nothing instead of dividing by zero.
assert_eq!(camera_delta([0.5, 0.5], 0, 0, &settings), [0.0, 0.0]);
}
#[test]
fn surface_tools_act_on_tap_but_projectiles_aim() {
for id in [
"bone_meal",
"flint_and_steel",
"fire_charge",
"water_bucket",
"lava_bucket",
"powder_snow_bucket",
"bucket",
"honeycomb",
"glass_bottle",
] {
assert!(item_uses_on_tap(&format!("minecraft:{id}")), "{id}");
}
assert!(!item_uses_on_tap("minecraft:milk_bucket"));
assert!(!item_uses_on_tap("minecraft:apple"));
for id in [
"bow",
"crossbow",
"snowball",
"egg",
"wind_charge",
"trident",
] {
let id = format!("minecraft:{id}");
assert!(!item_uses_on_tap(&id), "{id}");
assert!(projectile_crosshair(&id), "{id}");
}
assert!(!projectile_crosshair("minecraft:firework_rocket"));
assert!(!projectile_crosshair("minecraft:stone"));
}
#[test]
fn entity_placers_act_on_tap() {
for id in [
"oak_boat",
"bamboo_chest_raft",
"villager_spawn_egg",
"minecart",
"chest_minecart",
"armor_stand",
"item_frame",
"glow_item_frame",
"painting",
"end_crystal",
] {
let id = format!("minecraft:{id}");
assert!(item_places_entity_on_tap(&id), "{id}");
assert!(item_uses_on_tap(&id), "{id}");
}
for id in [
"egg",
"snowball",
"wind_charge",
"firework_rocket",
"fishing_rod",
"apple",
"wheat_seeds",
"diamond_helmet",
"bow",
] {
assert!(
!item_places_entity_on_tap(&format!("minecraft:{id}")),
"{id}"
);
}
// Only vanilla ids: a modded item that happens to end in `_boat` is left alone.
assert!(!item_places_entity_on_tap("example:spell_boat"));
}
#[test]
fn attack_gauge_moves_to_the_hotbar_without_a_crosshair() {
assert_eq!(attack_indicator(0, false), 0);
assert_eq!(attack_indicator(1, false), 2);
assert_eq!(attack_indicator(1, true), 1);
assert_eq!(attack_indicator(2, false), 2);
assert_eq!(attack_indicator(2, true), 2);
}
#[test]
fn tap_ray_goes_through_the_point_without_turning_the_view() {
let forward = [0.0, 0.0, 1.0];
let up = [0.0, 1.0, 0.0];
assert_eq!(
screen_direction(forward, up, [0.5, 0.5], 2.0, 90.0),
forward
);
let ray = screen_direction(forward, up, [0.75, 0.25], 2.0, 90.0);
// Right of centre is -x for this view; above centre is +y.
assert!(ray[0] < 0.0 && ray[1] > 0.0 && ray[2] > 0.0);
assert!((ray.iter().map(|x| x * x).sum::<f64>() - 1.0).abs() < 1e-10);
// The top edge at a 90° vertical fov is 45° up.
let top = screen_direction(forward, up, [0.5, 0.0], 2.0, 90.0);
assert!((top[1] - top[2]).abs() < 1e-10);
}
#[test]
fn hotbar_has_nine_slots_and_nothing_beside_it() {
for i in 0..9 {
assert_eq!(
hotbar_slot(427.0, 240.0, [133.5 + i as f32 * 20.0, 230.0]),
Some(i)
);
}
assert_eq!(hotbar_slot(427.0, 240.0, [320.0, 230.0]), None);
assert_eq!(hotbar_slot(427.0, 240.0, [100.0, 230.0]), None);
// Above the bar is the world, not a slot.
assert_eq!(hotbar_slot(427.0, 240.0, [213.5, 210.0]), None);
}
}
@@ -168,6 +168,12 @@ pub struct Settings {
pub button_visibility: [u8; CONTROLS],
/// One row of placements per [`Mode`], indexed by [`Control`].
pub layouts: [[Placement; CONTROLS]; 3],
/// Camera turn speed for look drags; scales both axes.
pub sensitivity: f32,
/// Extra scale on the yaw (left and right) turn.
pub sensitivity_horizontal: f32,
/// Extra scale on the pitch (up and down) turn.
pub sensitivity_vertical: f32,
}
impl Default for Settings {
@@ -203,6 +209,9 @@ impl Default for Settings {
shield_button: true,
button_visibility: [FOLLOW_SCHEME; CONTROLS],
layouts,
sensitivity: 1.0,
sensitivity_horizontal: 1.0,
sensitivity_vertical: 1.0,
}
}
}
@@ -238,6 +247,13 @@ impl Settings {
for p in self.layouts.iter_mut().flatten() {
p.sanitize();
}
for v in [
&mut self.sensitivity,
&mut self.sensitivity_horizontal,
&mut self.sensitivity_vertical,
] {
*v = finite_or(*v, 1.0).clamp(0.1, 3.0);
}
}
/// Whether attack and use act on the crosshair rather than on a tap.
@@ -385,6 +401,28 @@ mod tests {
assert_eq!(p.opacity, 0.65);
}
#[test]
fn sensitivity_stays_finite_and_in_range() {
let mut s = Settings {
sensitivity: f32::INFINITY,
sensitivity_horizontal: 9.0,
sensitivity_vertical: 0.0,
..Settings::default()
};
s.sanitize();
assert_eq!(
[
s.sensitivity,
s.sensitivity_horizontal,
s.sensitivity_vertical
],
[1.0, 3.0, 0.1]
);
// Files saved before these options existed keep the defaults.
let read: Settings = serde_json::from_str(r#"{"mode":"tap"}"#).unwrap();
assert_eq!(read.sensitivity, 1.0);
}
#[test]
fn left_hands_mirror_and_dpad_can_swap_jump_and_sneak() {
let mut s = Settings::default();
@@ -108,6 +108,9 @@ pub struct World {
pub inhabited: FxHashMap<(i32, i32), i64>,
pub game_time: i64,
pub day_time: i64,
/// The End's world clock (`WorldClocks.THE_END`, the End sky flash's), saved apart from
/// the game time and, like every clock, still while `advance_time` is off.
pub end_clock: i64,
pub weather: Weather,
/// Every block position written since the log was last drained, kept
/// only while the world is open to the network (see `lan`).
@@ -238,6 +241,7 @@ impl World {
inhabited: FxHashMap::default(),
game_time: 0,
day_time: 1000,
end_clock: 0,
weather: Weather::default(),
block_log: None,
block_log_held: Vec::new(),
@@ -148,9 +148,7 @@
"Size of menus and the HUD.": "Grootte van spyskaarte en die HUD.",
"Sets the options below together. Changing one makes it Custom.": "Stel die opsies hieronder saam. Om een te verander, maak dit Pasgemaak.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vuur en sonlig skyn helderder as wit. Benodig 'n HDR-skerm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Jou skerm is die donkerste swart. 0 vir OLED; LCD's gloei 'n bietjie.",
"How bright highlights get. Auto: the peak your system reports.": "Hoe helder hoogtepunte word. Outo: die hoogtepunt wat jou stelsel rapporteer.",
"How bright white paper and menus look. Auto: your desktop's white.": "Hoe helder wit papier en spyskaarte lyk. Outo: jou lessenaar is wit.",
"How bright the HUD and menus are, against paper white.": "Hoe helder die HUD en spyskaarte is, teenoor papierwit.",
"How far mobs, crops and machines keep working. CPU time.": "Hoe ver skare, gewasse en masjiene aanhou werk. SVE tyd.",
"How far away mobs and items are drawn, as a share of normal.": "Hoe ver weg skares en items getrek word, as 'n deel van normaal.",
@@ -163,9 +163,7 @@
"Size of menus and the HUD.": "حجم القوائم وHUD.",
"Sets the options below together. Changing one makes it Custom.": "يضبط الخيارات أدناه معًا. تغيير واحد يجعله مخصصًا.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "الحمم البركانية والنار وأشعة الشمس تتألق أكثر من اللون الأبيض. يحتاج إلى شاشة HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "اللون الأسود الداكن لشاشتك. 0 لـ OLED؛ تتوهج شاشات LCD قليلاً.",
"How bright highlights get. Auto: the peak your system reports.": "كيف تحصل على النقاط البارزة الساطعة. تلقائي: ذروة تقارير النظام الخاص بك.",
"How bright white paper and menus look. Auto: your desktop's white.": "كيف تبدو الورق والقوائم البيضاء الزاهية. تلقائي: سطح المكتب الخاص بك باللون الأبيض.",
"How bright the HUD and menus are, against paper white.": "مدى سطوع شاشة HUD والقوائم مقابل الورق الأبيض.",
"How far mobs, crops and machines keep working. CPU time.": "إلى أي مدى يستمر الغوغاء والمحاصيل والآلات في العمل. وقت وحدة المعالجة المركزية.",
"How far away mobs and items are drawn, as a share of normal.": "إلى أي مدى يتم رسم الغوغاء والعناصر، كنسبة من المعدل الطبيعي.",
@@ -147,9 +147,7 @@
"Size of menus and the HUD.": "Menyuların ölçüsü və HUD.",
"Sets the options below together. Changing one makes it Custom.": "Aşağıdakı seçimləri birlikdə təyin edir. Birinin dəyişdirilməsi onu Xüsusi edir.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, od və günəş işığı ağdan daha parlaq parlayır. HDR displey lazımdır.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Ekranınız ən tünd qaradır. OLED üçün 0; LCD-lər bir az parlayır.",
"How bright highlights get. Auto: the peak your system reports.": "Parlaq məqamlar necə əldə edilir. Avtomatik: sisteminizin ən yüksək hesabatı.",
"How bright white paper and menus look. Auto: your desktop's white.": "Ağ kağız və menyular necə parlaq görünür. Avtomatik: masaüstünüz ağ rəngdədir.",
"How bright the HUD and menus are, against paper white.": "HUD və menyular ağ kağıza qarşı nə qədər parlaqdır.",
"How far mobs, crops and machines keep working. CPU time.": "Kütlələr, məhsullar və maşınlar nə qədər işləməyə davam edir. CPU vaxtı.",
"How far away mobs and items are drawn, as a share of normal.": "Nə qədər uzağa mob və əşyalar çəkilir, normal bir pay olaraq.",
@@ -155,9 +155,7 @@
"Size of menus and the HUD.": "Размер меню һәм HUD.",
"Sets the options below together. Changing one makes it Custom.": "Түбәндәге варианттарҙы бергә ҡуя. Бер үҙгәртеү уны Ҡулланыусы итә.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, ут һәм ҡояш нурҙары аҡтан да сағыуыраҡ балҡый. HDR дисплей кәрәк.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Һеҙҙең дисплей&#8217;ы иң ҡара ҡара. 0 өсөн OLED; ЖК-дисплейҙар бер аҙ балҡый.",
"How bright highlights get. Auto: the peak your system reports.": "Нисек сағыу өҫтөнлөктәр ала. Авто: пик һеҙҙең система отчеттар.",
"How bright white paper and menus look. Auto: your desktop's white.": "Нисек сағыу аҡ ҡағыҙ, менюлар күренә. Авто: һеҙҙең өҫтәл&#8217;ы аҡ.",
"How bright the HUD and menus are, against paper white.": "Нисек сағыу HUD һәм менюлар, ҡағыҙ аҡ ҡаршы.",
"How far mobs, crops and machines keep working. CPU time.": "Күпме алыҫҡа мобтар, культуралар һәм машиналар эшләп тора. Процессор ваҡыты.",
"How far away mobs and items are drawn, as a share of normal.": "Нисек алыҫҡа мобтар һәм әйберҙәр тартыла, өлөшө булараҡ нормаль.",
@@ -150,9 +150,7 @@
"Size of menus and the HUD.": "Памер меню і HUD.",
"Sets the options below together. Changing one makes it Custom.": "Разам усталёўвае параметры ніжэй. Змена аднаго робіць яго карыстальніцкім.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, агонь і сонечнае святло ззяюць ярчэй белага. Патрэбен дысплей HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Самы цёмны чорны на вашым дысплеі. 0 для OLED; ВК-дысплеі трохі свецяцца.",
"How bright highlights get. Auto: the peak your system reports.": "Якія яркія моманты становяцца. Аўта: пік, пра які паведамляе ваша сістэма.",
"How bright white paper and menus look. Auto: your desktop's white.": "Як ярка выглядаюць белая папера і меню. Аўтаматычна: ваш працоўны стол белы.",
"How bright the HUD and menus are, against paper white.": "Наколькі яркія HUD і меню на фоне белай паперы.",
"How far mobs, crops and machines keep working. CPU time.": "Як далёка працягваюць працаваць мобы, ураджай і машыны. працэсарны час.",
"How far away mobs and items are drawn, as a share of normal.": "Наколькі далёка намаляваны мобы і прадметы ў долі звычайнага.",
@@ -147,9 +147,7 @@
"Size of menus and the HUD.": "Размер на менютата и HUD.",
"Sets the options below together. Changing one makes it Custom.": "Задава заедно опциите по-долу. Промяната на един го прави персонализиран.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лавата, огънят и слънчевата светлина блестят по-ярко от бялото. Има нужда от HDR дисплей.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Вашият дисплей е най-тъмно черен. 0 за OLED; LCD дисплеите светят малко.",
"How bright highlights get. Auto: the peak your system reports.": "Колко ярки стават акцентите. Автоматично: пикът, който вашата система отчита.",
"How bright white paper and menus look. Auto: your desktop's white.": "Колко ярки изглеждат бялата хартия и менютата. Автоматично: вашият работен плот е бял.",
"How bright the HUD and menus are, against paper white.": "Колко ярки са HUD и менютата на бяла хартия.",
"How far mobs, crops and machines keep working. CPU time.": "Докъде продължават да работят тълпите, културите и машините. процесорно време.",
"How far away mobs and items are drawn, as a share of normal.": "Колко далеч са изтеглени тълпите и предметите, като дял от нормалното.",
@@ -148,9 +148,7 @@
"Size of menus and the HUD.": "Ment ar mennozhioù hag an HUD.",
"Sets the options below together. Changing one makes it Custom.": "Lakaat a ra an dibarzhioù amañ dindan asambles. Cheñch unan a ra anezhañ Personelaet.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, tan ha gouloù an heol a lugern muioc'h eget ar gwenn. Ezhomm zo eus ur skramm HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Du teñvalañ eo ho skramm. 0 evit OLED; LCDoù a lugern un tamm.",
"How bright highlights get. Auto: the peak your system reports.": "Pegen skedus e teu ar sklêrijennoù. Emgefre : ar pik a vez embannet gant ho reizhiad.",
"How bright white paper and menus look. Auto: your desktop's white.": "Pegen skedus e seblant ar paper gwenn hag ar mennozhioù. Emgefre : gwenn eo ho burev.",
"How bright the HUD and menus are, against paper white.": "Pegen skedus eo an HUD hag ar mennozhioù, a-enep gwenn ar paper.",
"How far mobs, crops and machines keep working. CPU time.": "Pegen pell e kendalc'h ar mobs, ar c'hoadoù hag ar mekanikoù da labourat. Amzer ar CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Pegen pell e vez tennet ar mobs hag an traoù, evel ul lodenn eus ar pezh a vez graet normal.",
@@ -115,9 +115,7 @@
"Size of menus and the HUD.": "Veličina menija i HUD-a.",
"Sets the options below together. Changing one makes it Custom.": "Zajedno postavlja donje opcije. Promjenom jednog postaje prilagođeno.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vatra i sunčeva svjetlost sijaju jače od bijele. Potreban je HDR ekran.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Vaš ekran je najtamnije crn. 0 za OLED; LCD-i malo svijetle.",
"How bright highlights get. Auto: the peak your system reports.": "Kako svetli naglasci postaju. Automatski: vrhunac o kojem vaš sistem prijavljuje.",
"How bright white paper and menus look. Auto: your desktop's white.": "Kako izgledaju svijetli bijeli papir i meniji. Auto: vaša radna površina je bijela.",
"How bright the HUD and menus are, against paper white.": "Koliko su svijetli HUD i meniji, naspram bijelog papira.",
"How far mobs, crops and machines keep working. CPU time.": "Koliko mobovi, usjevi i mašine rade. CPU vrijeme.",
"How far away mobs and items are drawn, as a share of normal.": "Koliko su udaljeni mobovi i predmeti, kao udio normalnog.",
@@ -144,9 +144,7 @@
"Size of menus and the HUD.": "Mida dels menús i l'HUD.",
"Sets the options below together. Changing one makes it Custom.": "Estableix les opcions següents juntes. Canviar-ne un el fa personalitzat.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "La lava, el foc i la llum del sol brillen més que el blanc. Necessita una pantalla HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "La pantalla és el negre més fosc. 0 per a OLED; Els LCD brillen una mica.",
"How bright highlights get. Auto: the peak your system reports.": "Com es fan els reflexos brillants. Automàtic: el pic que informa el vostre sistema.",
"How bright white paper and menus look. Auto: your desktop's white.": "Quin aspecte brillant del paper blanc i dels menús. Automàtic: el teu escriptori és blanc.",
"How bright the HUD and menus are, against paper white.": "Què brillants són l'HUD i els menús, contra el paper blanc.",
"How far mobs, crops and machines keep working. CPU time.": "Fins on segueixen treballant les turbes, els conreus i les màquines. Temps de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A quina distància es dibuixen les turbes i els articles, com a part de la normalitat.",
@@ -137,9 +137,7 @@
"Size of menus and the HUD.": "Velikost menu a HUD.",
"Sets the options below together. Changing one makes it Custom.": "Nastaví společně níže uvedené možnosti. Změnou jednoho se stane vlastní.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Láva, oheň a sluneční světlo září jasněji než bílé. Vyžaduje HDR displej.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Váš displej je nejtmavší černý. 0 pro OLED; LCD trochu svítí.",
"How bright highlights get. Auto: the peak your system reports.": "Jak se získá jasný melír. Auto: vrchol, který váš systém hlásí.",
"How bright white paper and menus look. Auto: your desktop's white.": "Jak vypadají zářivě bílé papíry a nabídky. Auto: vaše plocha je bílá.",
"How bright the HUD and menus are, against paper white.": "Jak jasný je HUD a nabídky oproti bílé na papíře.",
"How far mobs, crops and machines keep working. CPU time.": "Jak dalece davy, plodiny a stroje fungují. CPU čas.",
"How far away mobs and items are drawn, as a share of normal.": "Jak daleko jsou mobové a předměty nakresleny jako podíl normálu.",
@@ -152,9 +152,7 @@
"Size of menus and the HUD.": "Менюсен калӑпӑшӗ тата HUD.",
"Sets the options below together. Changing one makes it Custom.": "Аялта панӑ опцисене пӗрле лартать. Пӗрне улӑштарни ӑна Custom тӑвать.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вут-ҫулӑм тата хӗвел ҫути шурӑран ҫутӑрах ҫуталса тӑраҫҫӗ. HDR дисплей кирлӗ.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Сирӗн дисплей чи тӗксӗм хура. 0 OLED валли; ЖК-дисплейсем кӑштах ҫуталаҫҫӗ.",
"How bright highlights get. Auto: the peak your system reports.": "Мӗнле ҫутӑ ҫутӑсем пулаҫҫӗ. Авто: сирӗн системӑн пӗлтерекен пикӗ.",
"How bright white paper and menus look. Auto: your desktop's white.": "Мӗнле ҫутӑ шурӑ хут тата менюсем курӑнаҫҫӗ. Авто: сирӗн ӗҫ сӗтелӗ шурӑ.",
"How bright the HUD and menus are, against paper white.": "HUD тата менюсем мӗнле ҫутӑ, хут ҫинчи шурӑ тӗспе танлаштарсан.",
"How far mobs, crops and machines keep working. CPU time.": "Мобсем, культурӑсем тата машинӑсем мӗн таран ӗҫлеҫҫӗ. Процессор вӑхӑчӗ.",
"How far away mobs and items are drawn, as a share of normal.": "Мобсемпе япаласем мӗн чухлӗ аяккалла туртӑнаҫҫӗ, йӗркеллӗ пай пек.",
@@ -139,9 +139,7 @@
"Size of menus and the HUD.": "Maint y bwydlenni a'r HUD.",
"Sets the options below together. Changing one makes it Custom.": "Yn gosod yr opsiynau isod gyda'i gilydd. Mae newid un yn ei gwneud yn Custom.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Mae lafa, tân a golau'r haul yn disgleirio'n fwy disglair na gwyn. Angen arddangosfa HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Du tywyllaf eich arddangosfa. 0 ar gyfer OLED; Mae LCDs yn tywynnu ychydig.",
"How bright highlights get. Auto: the peak your system reports.": "Sut mae uchafbwyntiau disglair yn ei gael. Auto: yr uchafbwynt y mae eich system yn ei adrodd.",
"How bright white paper and menus look. Auto: your desktop's white.": "Sut olwg sydd ar bapur gwyn llachar a bwydlenni. Auto: gwyn eich bwrdd gwaith.",
"How bright the HUD and menus are, against paper white.": "Pa mor llachar yw'r HUD a'r bwydlenni, yn erbyn papur gwyn.",
"How far mobs, crops and machines keep working. CPU time.": "Pa mor bell y mae mobs, cnydau a pheiriannau yn dal i weithio. Amser CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Pa mor bell i ffwrdd mae mobs ac eitemau yn cael eu tynnu, fel cyfran o'r arferol.",
@@ -131,9 +131,7 @@
"Size of menus and the HUD.": "Størrelse på menuer og HUD.",
"Sets the options below together. Changing one makes it Custom.": "Indstiller nedenstående muligheder sammen. At ændre en gør det brugerdefineret.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, ild og sollys skinner klarere end hvidt. Har brug for en HDR-skærm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Din skærm er mørkest sort. 0 for OLED; LCD'er lyser lidt.",
"How bright highlights get. Auto: the peak your system reports.": "Hvor lyse højdepunkter bliver. Auto: det højeste, dit system rapporterer.",
"How bright white paper and menus look. Auto: your desktop's white.": "Hvor lyst hvidt papir og menuer ser ud. Auto: dit skrivebord er hvidt.",
"How bright the HUD and menus are, against paper white.": "Hvor lys HUD og menuer er i forhold til hvidt papir.",
"How far mobs, crops and machines keep working. CPU time.": "Hvor langt mobs, afgrøder og maskiner bliver ved med at arbejde. CPU tid.",
"How far away mobs and items are drawn, as a share of normal.": "Hvor langt væk mobs og genstande trækkes, som en andel af normalen.",
@@ -1,15 +1,74 @@
{
"horizon.extras.header.world": "Welt",
"horizon.extras.pregen": "Chunk-Vorausgenerierung",
"horizon.extras.pregen.tooltip": "Aktiviert /pregen in deinen Einzelspieler- und LAN-Welten. Mit aktivierten Cheats erzeugst du mit /pregen start ein Gebiet, bevor du es erkundest. Auf Servern, denen du beitrittst, schaltet es den Befehl nicht frei.",
"horizon.options.controller": "Controller",
"horizon.options.three_d_skin_layers": "3D-Skin-Schichten",
"horizon.options.vibration": "Vibration",
"horizon.options.play_in_background": "Im Hintergrund spielen",
"horizon.options.adaptive_triggers": "Adaptive Trigger",
"horizon.options.gameplay_guide": "Spielanleitung",
"horizon.options.gameplay_guide.basic": "Einfach",
"horizon.options.gameplay_guide.full": "Vollständig",
"horizon.options.gameplay_guide.off": "Aus",
"horizon.controller.guide.use": "Benutzen",
"horizon.controller.guide.open": "Öffnen",
"horizon.controller.guide.ride": "Reiten",
"horizon.controller.guide.trade": "Handeln",
"horizon.controller.guide.feed": "Füttern",
"horizon.controller.guide.place": "Platzieren",
"horizon.controller.guide.eat": "Essen",
"horizon.controller.guide.drink": "Trinken",
"horizon.controller.guide.shoot": "Schießen",
"horizon.controller.guide.load": "Laden",
"horizon.controller.guide.throw": "Werfen",
"horizon.controller.guide.raise": "Heben",
"horizon.controller.guide.equip": "Ausrüsten",
"horizon.controller.guide.fish": "Angeln",
"horizon.controller.guide.zoom": "Zoom",
"horizon.controller.guide.boost": "Beschleunigen",
"horizon.controller.guide.break": "Abbauen",
"horizon.controller.guide.attack": "Angreifen",
"horizon.controller.guide.spectate": "Zuschauen",
"horizon.controller.guide.spectator_menu": "Zuschauermenü",
"horizon.controller.guide.release_shoot": "Zum Schießen loslassen",
"horizon.controller.guide.release_throw": "Zum Werfen loslassen",
"horizon.controller.guide.release_stop": "Zum Anhalten loslassen",
"horizon.controller.guide.fly": "Fliegen",
"horizon.controller.guide.fly_up": "Aufsteigen",
"horizon.controller.guide.fly_down": "Absteigen",
"horizon.controller.guide.swim_up": "Hochschwimmen",
"horizon.controller.guide.swim_down": "Hinabschwimmen",
"horizon.controller.guide.glide": "Gleiten",
"horizon.controller.guide.dismount": "Absitzen",
"horizon.controller.guide.stand": "Aufstehen",
"horizon.controller.guide.drop": "Gegenstand fallen lassen",
"horizon.controller.guide.swap": "Hände tauschen",
"horizon.controller.guide.take": "Nehmen",
"horizon.controller.guide.take_half": "Hälfte nehmen",
"horizon.controller.guide.put": "Ablegen",
"horizon.controller.guide.put_one": "Eines ablegen",
"horizon.controller.guide.swap_stack": "Tauschen",
"horizon.controller.guide.quick_move": "Schnell verschieben",
"horizon.controller.guide.drop_stack": "Stapel fallen lassen",
"horizon.controller.guide.place_recipe": "Rezept platzieren",
"horizon.controller.guide.place_recipe_all": "Alle platzieren",
"horizon.controller.guide.alternatives": "Alternativen",
"horizon.controller.guide.select": "Auswählen",
"horizon.controller.guide.close": "Schließen",
"horizon.controller.guide.pages": "Seiten",
"horizon.controller.guide.categories": "Kategorien",
"horizon.controller.guide.tabs": "Tabs",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
"horizon.options.invert_x": "Blick-X-Achse umkehren",
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
"horizon.options.craft_on_servers": "Schnelles Herstellen auf Servern",
"horizon.controller.direct_craft": "Schnelles Herstellen",
"horizon.options.sneak_button": "Schleichtaste",
"horizon.options.move_deadzone": "Totzone der Bewegung",
"horizon.options.look_deadzone": "Totzone des Blicks",
"horizon.options.trigger_threshold": "Auslöseschwelle",
"horizon.options.button_glyphs": "Tastensymbole",
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
"horizon.options.chat_delay": "Chatverzögerung",
@@ -19,6 +78,9 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.nintendo_buttons": "Nintendo-Tasten",
"horizon.options.nintendo_buttons.position": "Nach Position",
"horizon.options.nintendo_buttons.letter": "Nach Buchstabe",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -62,6 +124,23 @@
"horizon.studio.path.locked": "Studio-Kamerapfadwiedergabe aktiviert",
"horizon.studio.path.unlocked": "Studio-Kamerapfadwiedergabe gestoppt",
"horizon.studio.path.usage": "Verwendung: /studio path <lock|unlock> (zuerst mindestens ein Schlüsselbild hinzufügen)",
"horizon.pages.studio": "Replay Studio",
"horizon.split.leave": "Verlassen",
"horizon.pages.music": "Musik",
"horizon.pages.music.menu": "Menü",
"horizon.pages.music.overworld": "Oberwelt",
"horizon.pages.music.creative": "Kreativ",
"horizon.pages.music.underwater": "Unterwasser",
"horizon.pages.music.end": "Das Ende",
"horizon.pages.music.boss": "Boss",
"horizon.pages.music.credits": "Abspann",
"horizon.pages.music.overworld_biomes": "Oberwelt-Biome",
"horizon.pages.music.nether_biomes": "Nether-Biome",
"horizon.pages.music.end_biomes": "End-Biome",
"horizon.pages.music.other": "Andere Musik",
"horizon.pages.music.empty": "Keine Musiktitel: Die Klänge des Spiels sind nicht geladen.",
"horizon.pages.music.tooltip": "Die eingebauten Titel, nach Situation: Spiele einen jetzt ab oder schalte einen aus, damit das Spiel ihn nicht mehr wählt.",
"horizon.pages.studio.tooltip": "Durchsuche gespeicherte Aufnahmen und ihre Metadaten.",
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
@@ -98,12 +177,36 @@
"horizon.pages.open.controller_binds": "Controller-Belegung …",
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
"horizon.pages.open.studio": "Replay Studio …",
"horizon.pages.open.music": "Musik …",
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
"horizon.mods.stopped": " (angehalten)",
"horizon.mods.not_built": " (nicht gebaut)",
"horizon.mods.dev": " (Entwicklung)",
"horizon.mods.settings": "Einstellungen",
"horizon.mods.in_world": " (Welt verlassen, um zu ändern)",
"horizon.mods.open_folder": "Modordner öffnen",
"horizon.mods.browse": "Mods durchsuchen …",
"horizon.mods.browse.title": "Mods durchsuchen",
"horizon.mods.browse.search": "Mods suchen …",
"horizon.mods.browse.project": "Mod",
"horizon.mods.browse.loading": "Horizons Mod-Register wird gelesen …",
"horizon.mods.browse.failed": "Mod-Register konnte nicht gelesen werden: %s",
"horizon.mods.browse.none": "Keine Mod gefunden",
"horizon.mods.browse.has_update": " (Update)",
"horizon.mods.browse.is_installed": " (installiert)",
"horizon.mods.browse.by": "von %s",
"horizon.mods.browse.installing": "Wird installiert …",
"horizon.mods.browse.install": "Installieren",
"horizon.mods.browse.update": "Aktualisieren",
"horizon.mods.browse.installed": "Installiert",
"horizon.mods.browse.open_page": "Seite öffnen",
"horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Änderungsprotokoll",
"horizon.mods.browse.done": "%s %s ist installiert und eingeschaltet.",
"horizon.mods.browse.install_failed": "Installation fehlgeschlagen: %s",
"horizon.mods.switch.worldgen": "ändert die Weltgenerierung",
"horizon.mods.switch.registries": "fügt Blöcke, Gegenstände oder andere Registereinträge hinzu",
"horizon.mods.switch.restart": "Starte das Spiel neu, um %s anzuwenden: Sie fügen Blöcke, Gegenstände oder Weltgenerierung hinzu.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
"horizon.multiplayer.join": "Beitreten",
@@ -115,6 +218,7 @@
"LOD Distance": "LOD-Weite",
"Far Detail": "Ferndetails",
"Cave Culling": "Höhlen-Culling",
"GPU Culling": "GPU-Culling",
"Entity Culling": "Objekt-Culling",
"Chunk Updates": "Chunk-Updates",
"Upload Budget": "Upload-Budget",
@@ -133,6 +237,10 @@
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
"UI Brightness": "GUI-Helligkeit",
"HDR Lighting": "HDR-Beleuchtung",
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Lichtquellen, die Sonne und Sonnenlicht leuchten über Weiß hinaus. Aus: wie SDR.",
"Smooth Gradients": "Weiche Verläufe",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-Bit-Bild: Dunkle Himmel und Schatten zeigen keine Streifen. Etwas mehr VRAM. Nächster Start.",
"Battery Mode": "Akkumodus",
"Graphics API": "Grafik-API",
"Translucent Batching": "Transparenz-Batching",
@@ -169,7 +277,28 @@
"Medium cost": "Mittlere Kosten",
"High cost": "Hohe Kosten",
"Saves time": "Spart Zeit",
"Not with software rendering, which runs it on the CPU.": "Nicht mit Software-Rendering, das auf der CPU läuft.",
"Out of video memory: off until the next start.": "Zu wenig Videospeicher: aus bis zum nächsten Start.",
"The graphics device was lost: off until the next start.": "Das Grafikgerät ist verloren gegangen: aus bis zum nächsten Start.",
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Noch nicht unter DirectX 12: Probiere die Grafik-API Vulkan.",
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Culling nahen Geländes auf der GPU: weniger CPU bei großer Sichtweite, mehr GPU.",
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Auto: Probiert es dort, wo deine CPU das Bild begrenzt, und behält es, wenn die Bilder schneller werden.",
"horizon.realms.not_signed_in": "Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um auf Realms zu spielen.",
"General": "Allgemein",
"Advanced": "Erweitert",
"Undo": "Rückgängig",
"Search options": "Einstellungen durchsuchen",
"Shortcut": "Tastenkürzel",
"Distance": "Entfernung",
"Display": "Anzeige",
"Interface": "Oberfläche",
"Sky and weather": "Himmel und Wetter",
"Lighting and entities": "Beleuchtung und Entitäten",
"Textures": "Texturen",
"Image": "Bild",
"Chunk loading": "Chunk-Laden",
"Power": "Strom",
"Frames": "Bilder",
"Overlay": "Einblendung",
"HDR": "HDR",
"Graphics": "Grafik",
@@ -178,6 +307,120 @@
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
"Output": "Ausgabe",
"Brightness": "Helligkeit",
"How far you see, the window and the HUD.": "Wie weit du siehst, das Fenster und das HUD.",
"The sky and weather, lighting, textures and the image.": "Himmel und Wetter, Beleuchtung, Texturen und das Bild.",
"Chunk loading, culling and power use.": "Chunk-Laden, Culling und Stromverbrauch.",
"How frames reach the screen, and the performance overlay.": "Wie Bilder auf den Bildschirm gelangen und die Leistungsanzeige.",
"Brighter-than-white output on an HDR display.": "Ausgabe heller als Weiß auf einem HDR-Bildschirm.",
"Results for \"%s\"": "Ergebnisse für „%s“",
"No settings match.": "Keine Einstellungen passen.",
"horizon.extras.header.menus": "Menüs",
"horizon.extras.music_page": "Musikseite",
"horizon.extras.wheel_cycles_list_settings.tooltip": "Das Mausrad über einer Schaltfläche in einer Einstellungsliste ändert ihren Wert. Aus scrollt das Rad wie in Vanilla die Liste.",
"horizon.extras.wheel_cycles_settings": "Mausrad ändert Einstellungen",
"horizon.extras.pack_browser": "Ressourcenpaket-Browser",
"horizon.extras.music_page.tooltip": "Musik & Geräusche erhält eine Seite „Musik …“: Spiele jeden eingebauten Titel jetzt ab oder schalte einen aus, damit das Spiel ihn nicht mehr wählt. Nichts ändert sich, bis du einen Titel ausschaltest.",
"horizon.friends.relay_stopped": "Zu viele falsche Kurzcodes, deshalb ist das Relay aus, bis du erneut teilst. Freunde können weiterhin mit dem vollständigen Code beitreten.",
"horizon.extras.pack_browser.tooltip": "Ressourcenpakete auswählen erhält eine Schaltfläche „Hinzufügen“, die Modrinth durchsucht und ein Paket in deinen Paketordner installiert. Aus kontaktiert das Spiel Modrinth nie.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "ein Monitor",
"horizon.splitscreen.setup.window": "Fenster %s",
"horizon.splitscreen.setup.new_window": "Neues Fenster",
"horizon.splitscreen.setup.fill_monitor": "%s füllen",
"horizon.splitscreen.setup.done": "Fertig",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "An",
"horizon.splitscreen.setup.off": "Aus",
"horizon.splitscreen.setup.player": "Spieler %s",
"horizon.splitscreen.setup.revert": "Verwerfen",
"horizon.splitscreen.setup.apply": "Anwenden",
"horizon.splitscreen.setup.warning.empty_main": "Niemand spielt in Fenster 1: Es wird ausgeblendet",
"horizon.splitscreen.setup.warning.empty_close": "Niemand spielt in Fenster %s: Es schließt sich beim Anwenden",
"horizon.splitscreen.setup.warning.empty_new": "Niemand spielt in Fenster %s: Es öffnet sich nicht",
"horizon.splitscreen.setup.warning.on_main": "Fenster %s füllt den Monitor von Fenster 1: Seine Spieler spielen in Fenster 1",
"horizon.splitscreen.setup.warning.unplugged": "Der Monitor von Fenster %s ist nicht angeschlossen: Seine Spieler spielen in Fenster 1",
"horizon.splitscreen.setup.status.off": "Splitscreen ist aus.",
"horizon.splitscreen.setup.status.enable_hint": "Schalte ihn ein und drücke dann in einer Welt eine Taste auf einem anderen Controller.",
"horizon.splitscreen.setup.status.join_hint": "Drücke A, um beizutreten, und wähle ein Profil.",
"horizon.splitscreen.setup.status.idle": "Splitscreen inaktiv",
"horizon.splitscreen.setup.status.alone": "Keine weiteren lokalen Spieler.",
"horizon.splitscreen.setup.status.solo_controls": "Steuerung im Alleingang und Controller-Feedback bleiben unverändert.",
"horizon.splitscreen.setup.window_empty": "Fenster %s: leer",
"horizon.splitscreen.setup.note.select": "Klicke einen Spieler an oder ziehe ihn auf ein Fenster oder einen Monitor.",
"horizon.splitscreen.setup.note.apply": "Mit Anwenden spielst du so, mit Verwerfen machst du es rückgängig.",
"horizon.splitscreen.setup.note.place": "Wo soll %s spielen?",
"horizon.splitscreen.setup.this_player": "dieser Spieler",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen schaltet sich aus, sobald die anderen gegangen sind (ihr Pausenmenü).",
"horizon.splitscreen.setup.window_place": "Fenster %s (%s von %s)",
"horizon.splitscreen.setup.names": "Namen",
"horizon.splitscreen.setup.identify": "Kennzeichnen",
"horizon.splitscreen.setup.layout.two": "2 Spieler teilen: %s",
"horizon.splitscreen.setup.layout.three": "3 Spieler teilen: %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Nebeneinander",
"horizon.splitscreen.setup.layout.two.2": "Übereinander",
"horizon.splitscreen.setup.layout.three.0": "Einer breit oben",
"horizon.splitscreen.setup.layout.three.1": "Einer hochkant links",
"horizon.splitscreen.setup.layout.three.2": "Nebeneinander",
"horizon.splitscreen.setup.help.player": "Klicke %s an oder ziehe daran, um ihn zu verschieben.",
"horizon.splitscreen.setup.help.layout_auto": "%s (nebeneinander, wenn das Fenster breit ist). Drücken zum Ändern.",
"horizon.splitscreen.setup.help.layout": "%s. Drücken zum Ändern.",
"horizon.splitscreen.setup.help.switch": "Splitscreen für diesen Computer, an oder aus.",
"horizon.splitscreen.setup.help.identify": "Die Ansicht jedes Spielers zeigt, in welchem Fenster er ist.",
"horizon.splitscreen.setup.help.names": "Der Name jedes Spielers in der Ecke seiner Ansicht.",
"horizon.splitscreen.setup.help.revert": "Die noch nicht angewendeten Änderungen verwerfen.",
"horizon.splitscreen.setup.help.apply": "So spielen, wie die Karte es zeigt.",
"horizon.splitscreen.setup.help.unchanged": "Noch nichts geändert.",
"horizon.splitscreen.setup.give_up_pad": "Controller abgeben",
"horizon.splitscreen.prompt.controller_connected": "Controller verbunden",
"horizon.splitscreen.prompt.controller_disconnected": "Controller getrennt",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Verbinde ihn erneut oder drücke eine Taste auf einem anderen",
"horizon.splitscreen.prompt.join.one": "%s möchte beitreten",
"horizon.splitscreen.prompt.join.many": "%s Controller möchten beitreten",
"horizon.splitscreen.prompt.join.yes": "Beitreten",
"horizon.splitscreen.prompt.join.as_p1": "Als P1 spielen",
"horizon.splitscreen.prompt.join.no": "Nein",
"horizon.splitscreen.prompt.lost": "%s: Controller getrennt",
"horizon.splitscreen.prompt.lost.use_another": "Anderen verwenden",
"horizon.splitscreen.prompt.lost.leave": "Verlassen",
"horizon.splitscreen.setup.help.give_up_pad": "Dein Controller steuert dich nicht mehr und wird zum Beitreten angeboten (A).",
"horizon.splitscreen.setup.note.held_at": "%s nach %s: A legt es hier ab, B legt es zurück.",
"horizon.splitscreen.setup.note.held": "%s aufgenommen: Links/rechts wählt den Ort, A legt ab, B legt zurück.",
"horizon.splitscreen.online_only": "Dieser Server läuft im Online-Modus: Offline-Profile können ihm nicht beitreten, jeder Spieler braucht sein eigenes Minecraft-Konto.",
"horizon.splitscreen.own_account": "Dieser Server benötigt das eigene Minecraft-Konto jedes Spielers",
"horizon.splitscreen.local_only": "Andere Spieler können diesem Server nur mit ihrem eigenen Minecraft-Konto beitreten: Offline-Profile spielen in deiner Welt, im LAN oder auf einem Server in deinem lokalen Netzwerk.",
"Block Entities": "Blockentitäten",
"Shulker Boxes": "Shulker-Kisten",
"Reuses settled boxes; lid movement stays animated.": "Verwendet ruhende Kisten erneut; die Deckelbewegung bleibt animiert.",
"Signs": "Schilder",
"Reuses sign text until text, light, or font changes.": "Verwendet Schildertext erneut, bis sich Text, Licht oder Schriftart ändern.",
"Decorated Pots": "Verzierte Tontöpfe",
"Reuses still pots; break wobble and sherd changes redraw.": "Verwendet ruhende Töpfe erneut; Wackeln beim Zerbrechen und Scherbenänderungen zeichnen neu.",
"Bells": "Glocken",
"Reuses still bells; ringing stays animated.": "Verwendet ruhende Glocken erneut; das Läuten bleibt animiert.",
"Chests": "Truhen",
"Reuses closed chests; moving lids stay animated.": "Verwendet geschlossene Truhen erneut; bewegte Deckel bleiben animiert.",
"Banners": "Banner",
"Reuses poles; flags and pattern layers still update.": "Verwendet Stangen erneut; Fahnen und Musterschichten aktualisieren sich weiterhin.",
"Copper Golem Statues": "Kupfergolem-Statuen",
"Reuses statue geometry until its block state changes.": "Verwendet die Statuengeometrie erneut, bis sich ihr Blockzustand ändert.",
"Opaque Ice": "Undurchsichtiges Eis",
"Draws ice in the solid pass, without its see-through texture.": "Zeichnet Eis im massiven Durchgang, ohne seine durchsichtige Textur.",
"Find BlueMap": "BlueMap finden",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Findet auf Servern deren BlueMap-Webkarte für fernes Gelände. Nächster Beitritt.",
"BlueMap Radius": "BlueMap-Radius",
"How far around you BlueMap tiles are downloaded. Next join.": "Wie weit um dich herum BlueMap-Kacheln heruntergeladen werden. Nächster Beitritt.",
"Hidden Faces": "Versteckte Flächen",
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Lässt Blattflächen hinter zwei Laubschichten weg: schneller, gleiche Kronen.",
"%s nits": "%s Nits",
"Auto (%s nits)": "Auto (%s Nits)",
"%.2f nits": "%.2f Nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter undurchsichtigen Blöcken. Entitäten aktualisieren sich. Aus bei benutzerdefinierten Paketen.",
"Entity Culling Precheck": "Objekt-Culling-Vorprüfung",
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Führt die AABB-Vorprüfung für Kreaturen nach dieser Anzahl geladener Kreaturen aus. Null deaktiviert sie.",
"%s mobs": "%s Kreaturen",
"Only applies to asynchronous entity culling.": "Nur für asynchrones Objekt-Culling.",
"%s chunks": "%s Chunks",
"%s per frame": "%s pro Bild",
@@ -194,9 +437,7 @@
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
@@ -407,10 +648,45 @@
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten.",
"horizon.extras.repro_keys": "Aufnahme-Tastenkürzel",
"horizon.extras.repro_keys.tooltip": "F3+R startet oder beendet eine Repro-Aufzeichnung und F3+E schreibt ihr Paket. Standardmäßig aus: Eine Aufzeichnung kopiert die Welt.",
"horizon.agent.record_session": "Aufzeichnungssitzung starten",
"horizon.agent.record_stop": "Aufzeichnungssitzung beenden",
"horizon.agent.export_repro": "Repro-Paket exportieren",
"horizon.repro.started": "Aufzeichnung gestartet; F3+R beendet sie und schreibt das Paket.",
"horizon.repro.stopped": "Aufzeichnung beendet: %s",
"horizon.repro.bundle": "Repro-Paket: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "Es gibt noch keine Aufzeichnung zum Exportieren.",
"horizon.repro.replay_started": "Wiedergabe gestartet: %s",
"horizon.splitscreen.profiles.offline": " (Offline)",
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
"horizon.splitscreen.profiles.sign_in": "Melde dich im Launcher erneut an",
"horizon.splitscreen.profiles.online": "Microsoft online",
"horizon.splitscreen.account_unavailable": "%s ist offline. Melde dich im Launcher erneut an und starte dann das Spiel neu.",
"horizon.splitscreen.account_expired": "Die Microsoft-Anmeldung von %s ist abgelaufen. Melde dich im Launcher erneut an und starte dann das Spiel neu.",
"Particle Culling": "Partikel-Culling",
"Draws every particle.": "Zeichnet alle Partikel.",
"Skips offscreen, subpixel and terrain-hidden particles.": "Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain.",
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain und entlastet den Render-Thread.",
"horizon.pack.add": "+",
"horizon.pack.addInfo": "Ein Ressourcenpaket von Modrinth hinzufügen",
"horizon.pack.addWorking": "Modrinth wird angefragt …",
"horizon.pack.addNone": "Keine Ressourcenpakete gefunden",
"horizon.pack.addTitle": "Ein Ressourcenpaket hinzufügen",
"horizon.pack.install": "Installieren",
"horizon.pack.installing": "Wird installiert",
"horizon.pack.installed": "Installiert",
"horizon.pack.search": "Suchen",
"horizon.pack.searchHint": "Modrinth durchsuchen …",
"horizon.pack.filters": "Filter",
"horizon.pack.filter.realistic": "Realistisch",
"horizon.pack.filter.simplistic": "Schlicht",
"horizon.pack.filter.vanillaLike": "Vanilla-ähnlich",
"horizon.pack.filter.x16": "16x",
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "Das Paket konnte nicht hinzugefügt werden: %s",
"horizon.extras.header.cosmetics": "Kosmetik",
"horizon.extras.cosmetics_mine": "Meine Kosmetik",
"horizon.extras.cosmetics_mine.tooltip": "Ausgeblendet: Alle Horizon-Spieler sehen dich ohne deine Kosmetik, du selbst auch. Derselbe Schalter wie in der Garderobe des Launchers.",
@@ -451,6 +727,11 @@
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
"horizon.options.pointer_speed": "Geschwindigkeit des Menüzeigers",
"horizon.options.scroll_speed": "Scrollgeschwindigkeit im Menü",
"horizon.options.repeat_delay": "Verzögerung der Navigationswiederholung",
"horizon.options.repeat_interval": "Intervall der Navigationswiederholung",
"horizon.options.milliseconds": "%s ms",
"horizon.extras.cosmetics_solo": "Im Einzelspieler",
"horizon.extras.cosmetics_solo.tooltip": "Deine Kosmetik und das Abzeichen deines Namens auch in deinen Einzelspielerwelten zeigen (deinen Namen mit „Eigenes Namensschild“). Aus: nur auf Servern.",
"horizon.extras.horizon_marks": "Horizon-Logos",
@@ -460,6 +741,276 @@
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)",
"horizon.pages.pvp": "PvP & HUD",
"horizon.pages.pvp.tooltip": "Fadenkreuz, HUD und PvP-Einstellungen, alle standardmäßig aus. Nichts hier zeigt, was Vanilla nicht zeigt.",
"horizon.pages.open.pvp": "PvP & HUD …",
"horizon.pvp.header.crosshair": "Fadenkreuz",
"horizon.pvp.crosshair": "Benutzerdefiniertes Fadenkreuz",
"horizon.pvp.crosshair_shape": "Form",
"horizon.pvp.crosshair_size": "Größe",
"horizon.pvp.crosshair_gap": "Abstand",
"horizon.pvp.crosshair_thickness": "Dicke",
"horizon.pvp.crosshair_colour": "Farbe",
"horizon.pvp.crosshair_outline": "Umrandung",
"horizon.pvp.crosshair_dot": "Mittelpunkt",
"horizon.pvp.crosshair_hover": "Farbe auf Entitäten",
"horizon.pvp.crosshair.tooltip": "Zeichnet dein eigenes Fadenkreuz statt dem von Vanilla. Seine ersten Einstellungen sehen aus wie die von Vanilla.",
"horizon.pvp.crosshair_shape.tooltip": "Ein Kreuz, ein T, ein X, ein Kreis, ein Quadrat oder nur ein Punkt.",
"horizon.pvp.crosshair_size.tooltip": "Die Länge jedes Arms in GUI-Pixeln.",
"horizon.pvp.crosshair_gap.tooltip": "Der leere Raum in der Mitte.",
"horizon.pvp.crosshair_thickness.tooltip": "Wie breit jede Linie ist.",
"horizon.pvp.crosshair_colour.tooltip": "Invertiert zeigt die Farbe darunter invertiert, so wie es Vanillas Fadenkreuz tut.",
"horizon.pvp.crosshair_outline.tooltip": "Ein dünner schwarzer Rand, damit es auf jedem Hintergrund sichtbar ist.",
"horizon.pvp.crosshair_dot.tooltip": "Ein Punkt in der Mitte des Fadenkreuzes.",
"horizon.pvp.crosshair_hover.tooltip": "Eine andere Farbe, während das Fadenkreuz über einer Kreatur oder einem Spieler ist, den du treffen könntest, so wie Vanillas Angriffsanzeige es erkennt.",
"horizon.pvp.colour.inverted": "Invertiert",
"horizon.pvp.colour.white": "Weiß",
"horizon.pvp.colour.red": "Rot",
"horizon.pvp.colour.orange": "Orange",
"horizon.pvp.colour.yellow": "Gelb",
"horizon.pvp.colour.green": "Grün",
"horizon.pvp.colour.cyan": "Cyan",
"horizon.pvp.colour.blue": "Blau",
"horizon.pvp.colour.magenta": "Magenta",
"horizon.pvp.colour.black": "Schwarz",
"horizon.pvp.shape.cross": "Kreuz",
"horizon.pvp.shape.t": "T",
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Kreis",
"horizon.pvp.shape.square": "Quadrat",
"horizon.pvp.shape.dot": "Punkt",
"horizon.pvp.outline": "Benutzerdefinierte Blockumrandung",
"horizon.pvp.outline.tooltip": "Ändert die Umrandung um den Block, den du ansiehst.",
"horizon.pvp.outline_colour": "Farbe",
"horizon.pvp.outline_colour.tooltip": "Die Farbe der Umrandung. Schwarz bei 40 % ist die von Vanilla.",
"horizon.pvp.outline_opacity": "Deckkraft",
"horizon.pvp.outline_opacity.tooltip": "Wie kräftig die Umrandung ist.",
"horizon.pvp.outline_thickness": "Dicke",
"horizon.pvp.outline_thickness.tooltip": "Die Breite der Umrandung, als Vielfaches von Vanilla.",
"horizon.pvp.outline_fill": "Füllung",
"horizon.pvp.outline_fill.tooltip": "Färbt die Flächen des Blocks, den du ansiehst, leicht ein.",
"horizon.pvp.hit": "Benutzerdefinierte Trefferfarbe",
"horizon.pvp.hit.tooltip": "Ändert die Farbe, in der Kreaturen und Spieler aufblitzen, wenn sie verletzt werden. Rot bei 30 % ist die von Vanilla.",
"horizon.pvp.hit_colour": "Farbe",
"horizon.pvp.hit_colour.tooltip": "Die Farbe, in der verletzte Kreaturen und Spieler aufblitzen.",
"horizon.pvp.hit_opacity": "Deckkraft",
"horizon.pvp.hit_opacity.tooltip": "Wie stark die Farbe sie überdeckt.",
"horizon.pvp.header.outline": "Blockumrandung",
"horizon.pvp.header.hit": "Trefferfarbe",
"horizon.pvp.hud_armour": "Rüstung",
"horizon.pvp.hud_armour.tooltip": "Die Rüstung, die du trägst, Kopf zuerst, mit der verbleibenden Haltbarkeit jedes Teils.",
"horizon.pvp.hud_armour_corner": "Rüstungsecke",
"horizon.pvp.hud_armour_corner.tooltip": "Wo die Rüstung angezeigt wird.",
"horizon.pvp.hud_armour_percent": "Haltbarkeit in %",
"horizon.pvp.hud_armour_percent.tooltip": "Verbleibende Nutzungen als Prozentwert des Maximums des Teils statt als Zahl.",
"horizon.pvp.hud_effects": "Effekte",
"horizon.pvp.hud_effects.tooltip": "Deine Effekte mit Namen, Stufen und Restzeit statt der Symbole oben rechts.",
"horizon.pvp.hud_effects_corner": "Ecke der Effekte",
"horizon.pvp.hud_effects_corner.tooltip": "Wo die Effekte angezeigt werden.",
"horizon.pvp.hud_held": "Anzahl des gehaltenen Gegenstands",
"horizon.pvp.hud_held.tooltip": "Wie viele des Gegenstands in deiner Hand dein Inventar enthält; Pfeile bei Bogen oder Armbrust.",
"horizon.pvp.hud_held_corner": "Ecke der gehaltenen Anzahl",
"horizon.pvp.hud_held_corner.tooltip": "Wo die Anzahl angezeigt wird.",
"horizon.pvp.hud_background": "Widget-Hintergründe",
"horizon.pvp.hud_background.tooltip": "Ein dunkler Kasten hinter jedem Widget.",
"horizon.pvp.hud_opacity": "Hintergrunddeckkraft",
"horizon.pvp.hud_opacity.tooltip": "Wie dunkel die Kästen hinter den Widgets sind.",
"horizon.pvp.hud_text_colour": "Textfarbe",
"horizon.pvp.hud_text_colour.tooltip": "Die Textfarbe der Widgets. Die Haltbarkeit behält ihr Grün bis Rot.",
"horizon.pvp.hud_shadow": "Textschatten",
"horizon.pvp.hud_shadow.tooltip": "Ein Schatten unter dem Text der Widgets, wie ihn Vanillas HUD-Text hat.",
"horizon.pvp.header.hud": "HUD-Widgets",
"horizon.pvp.corner.top_left": "Oben links",
"horizon.pvp.corner.top_right": "Oben rechts",
"horizon.pvp.corner.bottom_left": "Unten links",
"horizon.pvp.corner.bottom_right": "Unten rechts",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "Die Latenz, die der Server für dich meldet, in Millisekunden. Nur im Mehrspieler.",
"horizon.pvp.hud_ping_corner": "Ping-Ecke",
"horizon.pvp.hud_ping_corner.tooltip": "Wo der Ping angezeigt wird.",
"horizon.pvp.hud_coords": "Koordinaten",
"horizon.pvp.hud_coords.tooltip": "Deine Blockposition und die Richtung, in die du blickst, wie F3 sie zeigt. Versteckt, wenn die Welt oder der Server F3 einschränkt.",
"horizon.pvp.hud_coords_corner": "Koordinatenecke",
"horizon.pvp.hud_coords_corner.tooltip": "Wo die Koordinaten angezeigt werden.",
"horizon.pvp.hud_server": "Serveradresse",
"horizon.pvp.hud_server.tooltip": "Die Adresse des Servers, auf dem du spielst. Nur im Mehrspieler.",
"horizon.pvp.hud_server_corner": "Server-Ecke",
"horizon.pvp.hud_server_corner.tooltip": "Wo die Adresse angezeigt wird.",
"horizon.pvp.hud_combo": "Combo-Zähler",
"horizon.pvp.hud_combo.tooltip": "Treffer in Folge, die der Server bestätigt hat, bis du verletzt wirst oder 3 Sekunden ohne einen vergehen.",
"horizon.pvp.hud_combo_corner": "Combo-Ecke",
"horizon.pvp.hud_combo_corner.tooltip": "Wo die Combo angezeigt wird.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Blickrichtung: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "Norden",
"horizon.pvp.hud.direction.south": "Süden",
"horizon.pvp.hud.direction.east": "Osten",
"horizon.pvp.hud.direction.west": "Westen",
"horizon.pvp.hud_fps": "FPS",
"horizon.pvp.hud_fps.tooltip": "Bilder, die in der letzten Sekunde gezeichnet wurden.",
"horizon.pvp.hud_fps_corner": "FPS-Ecke",
"horizon.pvp.hud_fps_corner.tooltip": "Wo die FPS angezeigt werden.",
"horizon.pvp.hud_cps": "Klicks pro Sekunde",
"horizon.pvp.hud_cps.tooltip": "Deine linken und rechten Klicks in der letzten Sekunde.",
"horizon.pvp.hud_cps_corner": "CPS-Ecke",
"horizon.pvp.hud_cps_corner.tooltip": "Wo die Klicks pro Sekunde angezeigt werden.",
"horizon.pvp.hud_keys": "Tastenanschläge",
"horizon.pvp.hud_keys.tooltip": "Deine Bewegungstasten, Maustasten und Springen, beleuchtet, während du sie hältst, mit Klicks pro Sekunde.",
"horizon.pvp.hud_keys_corner": "Ecke der Tastenanschläge",
"horizon.pvp.hud_keys_corner.tooltip": "Wo die Tastenanschläge angezeigt werden.",
"horizon.pvp.hud_clock": "Uhr",
"horizon.pvp.hud_clock.tooltip": "Die Zeit deines Computers.",
"horizon.pvp.hud_clock_corner": "Ecke der Uhr",
"horizon.pvp.hud_clock_corner.tooltip": "Wo die Uhr angezeigt wird.",
"horizon.pvp.hud_reach": "Reichweite",
"horizon.pvp.hud_reach.tooltip": "Wie weit von deinen Augen entfernt dein letzter Treffer gelandet ist, für 3 Sekunden. Es misst nur: Deine Reichweite bleibt die von Vanilla.",
"horizon.pvp.hud_reach_corner": "Reichweitenecke",
"horizon.pvp.hud_reach_corner.tooltip": "Wo die Reichweite angezeigt wird.",
"horizon.pvp.hud_speed": "Geschwindigkeit",
"horizon.pvp.hud_speed.tooltip": "Wie schnell du dich über den Boden bewegst, in Blöcken pro Sekunde.",
"horizon.pvp.hud_speed_corner": "Geschwindigkeitsecke",
"horizon.pvp.hud_speed_corner.tooltip": "Wo die Geschwindigkeit angezeigt wird.",
"horizon.pvp.hud.fps": "%s FPS",
"horizon.pvp.hud.cps": "CPS: %s | %s",
"horizon.pvp.hud.reach": "Reichweite: %s",
"horizon.pvp.hud.speed": "Geschwindigkeit: %s B/s",
"horizon.pvp.hud.keys.attack": "LMB",
"horizon.pvp.hud.keys.use": "RMB",
"horizon.pvp.hud.keys.cps": "%s CPS",
"horizon.pvp.numeric_enchants": "Verzauberungsstufen als Zahlen",
"horizon.pvp.numeric_enchants.tooltip": "Tooltips und der Zaubertisch zeigen Schärfe 5 statt Schärfe V.",
"horizon.pvp.header.items": "Gegenstände",
"horizon.pvp.fire_lower": "Tieferes Feuer",
"horizon.pvp.fire_lower.tooltip": "Verschiebt die Flammen, die du beim Brennen siehst, nach unten, damit sie weniger vom Bildschirm bedecken.",
"horizon.pvp.shield_size": "Schildgröße",
"horizon.pvp.shield_size.tooltip": "Die Größe des Schilds in deiner Hand in der Ego-Perspektive.",
"horizon.pvp.totem_size": "Größe der Totem-Animation",
"horizon.pvp.totem_size.tooltip": "Die Größe des Totems, das den Bildschirm füllt, wenn es dich rettet.",
"horizon.pvp.glint_colour": "Farbe des Verzauberungsschimmers",
"horizon.pvp.glint_colour.tooltip": "Die Farbe des Schimmers auf verzauberten Gegenständen und Rüstung. Aus ist Vanillas Lila.",
"horizon.pvp.clear_water": "Klares Wasser",
"horizon.pvp.clear_water.tooltip": "Unter Wasser siehst du von Anfang an so weit, wie Vanilla es dir nach 30 Sekunden dort erlaubt.",
"horizon.pvp.clear_lava": "Klare Lava",
"horizon.pvp.clear_lava.tooltip": "In Lava siehst du so weit, wie Vanillas Feuerresistenz es dir erlaubt, ohne den Effekt.",
"horizon.pvp.header.visual": "Visuelle Anpassungen",
"horizon.pvp.view_time": "Tageszeit",
"horizon.pvp.view_time.tooltip": "Die Tageszeit, die du siehst: nur Himmel und sein Licht. Die Zeit der Welt läuft weiter und nichts wird an den Server gesendet.",
"horizon.pvp.view_weather": "Wetter",
"horizon.pvp.view_weather.tooltip": "Das Wetter, das du siehst: nur Regen, sein Licht und Nebel. Das Wetter der Welt läuft weiter und nichts wird an den Server gesendet.",
"horizon.pvp.header.view": "Deine Ansicht",
"horizon.pvp.view.world": "Welt",
"horizon.pvp.time.morning": "Morgen",
"horizon.pvp.time.noon": "Mittag",
"horizon.pvp.time.sunset": "Sonnenuntergang",
"horizon.pvp.time.midnight": "Mitternacht",
"horizon.pvp.weather.clear": "Klar",
"horizon.pvp.weather.rain": "Regen",
"horizon.pvp.weather.thunder": "Gewitter",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Chat",
"horizon.chat.copied": "Nachricht kopiert",
"horizon.chat.search": "Chat durchsuchen:",
"horizon.pvp.chat_timestamps": "Zeitstempel",
"horizon.pvp.chat_timestamps.tooltip": "Die Zeit, zu der jede Nachricht kam, davor.",
"horizon.pvp.chat_compact": "Kompakter Chat",
"horizon.pvp.chat_compact.tooltip": "Eine hintereinander wiederholte Nachricht wird einmal angezeigt, mit der Anzahl, wie oft sie kam: (x3).",
"horizon.pvp.chat_copy": "Kopieren beim Klick",
"horizon.pvp.chat_copy.tooltip": "Ein Klick auf eine Nachricht im geöffneten Chat kopiert ihren Text, außer der Klick macht etwas anderes.",
"horizon.pvp.chat_search": "Chatsuche",
"horizon.pvp.chat_search.tooltip": "Strg+F im geöffneten Chat: Was du tippst, zeigt nur die Nachrichten, die es enthalten. Eingabe oder erneut Strg+F beendet es.",
"horizon.pvp.preset": "PvP-Preset",
"horizon.pvp.preset.tooltip": "Schaltet das benutzerdefinierte Fadenkreuz, die Rüstungs- und Effekt-Widgets, Sprinten und Schleichen als Umschalter, tieferes Feuer sowie den FPS-Zähler und die Tastenanschläge-Mods ein, falls installiert. Deine Einstellungen dafür bleiben.",
"horizon.pvp.hud_editor_key": "Taste für den HUD-Editor",
"horizon.pvp.hud_editor_key.tooltip": "Öffnet den HUD-Editor aus dem Spiel. Klicke und drücke dann eine Taste; Escape hebt die Belegung auf.",
"horizon.pvp.editor": "HUD bearbeiten",
"horizon.pvp.editor.tooltip": "Verschiebe die Widgets im Spiel: Ziehe sie überallhin, ändere ihre Größe mit dem Mausrad, rechtsklicke eines, um es in seine Ecke zurückzusetzen.",
"horizon.pvp.editor.hint": "Widgets ziehen · Mausrad: Größe · Rechtsklick: zurück in die Ecke",
"horizon.pvp.combat_feel": "Kampfgefühl",
"horizon.pvp.combat_feel.tooltip": "Legacy zeichnet Kämpfe wie 1.8.9: den Gegenstand in deiner Hand, seinen Schwung, Essen, den Bogen und ein erhobenes Schwert, während du Benutzen hältst, ohne das Absinken nach jedem Treffer oder die Angriffsanzeige. Nur das Aussehen: Was du an einen Server sendest, bleibt gleich.",
"horizon.pvp.combat_indicator": "Angriffsanzeige in Legacy",
"horizon.pvp.combat_indicator.tooltip": "Behält die Angriffsanzeige unter Legacy, die 1.8.9 nicht hatte.",
"horizon.pvp.combat_feel.modern": "Modern",
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
"horizon.pvp.header.combat": "Kampfgefühl",
"horizon.pvp.combat_sounds": "1.8-Treffergeräusche",
"horizon.pvp.combat_sounds.tooltip": "Lässt unter Legacy die Angriffsgeräusche weg, die 1.9 hinzufügte (stark, schwach, Schwung, kritisch …). Verletzungsgeräusche bleiben.",
"horizon.pvp.combat_rules": "1.8-Kampf in Welten, die du hostest",
"horizon.pvp.combat_rules.tooltip": "Einzelspieler und die Welten, die du im LAN öffnest, kämpfen nach den Regeln von 1.8.9: keine Angriffsabklingzeit oder Schwungangriff, 1.8-Waffenschaden, Stärke, Schärfe und kritische Treffer. Server, denen du beitrittst, behalten ihre eigenen Regeln.",
"Bindings": "Belegungen",
"Play": "Spielen",
"Device": "Gerät",
"Look": "Blick",
"Movement": "Bewegung",
"Menus": "Menüs",
"Actions": "Aktionen",
"Reset": "Zurücksetzen",
"Detected": "Erkannt",
"Glyphs": "Symbole",
"Feedback": "Feedback",
"Controller": "Controller",
"How the sticks turn and move you, and how menus repeat and scroll.": "Wie die Sticks dich drehen und bewegen und wie Menüs wiederholen und scrollen.",
"Which button does what. Each player has their own.": "Welche Taste was tut. Jeder Spieler hat seine eigenen.",
"The controller the game sees, its button glyphs and its feedback.": "Der Controller, den das Spiel sieht, seine Tastensymbole und sein Feedback.",
"Press this row, then the button that should do it.": "Drücke diese Zeile und dann die Taste, die es tun soll.",
"Off, the game ignores every controller for this player.": "Aus ignoriert das Spiel jeden Controller für diesen Spieler.",
"How fast the right stick turns the view.": "Wie schnell der rechte Stick die Ansicht dreht.",
"Turn the view the other way, left and right.": "Dreht die Ansicht andersherum, links und rechts.",
"Turn the view the other way, up and down.": "Dreht die Ansicht andersherum, hoch und runter.",
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Wie weit sich der rechte Stick bewegt, bevor sich die Ansicht dreht. Erhöhe ihn, wenn die Ansicht von selbst wandert.",
"Hold the button to sneak, or press it once to start and again to stop.": "Halte die Taste zum Schleichen oder drücke sie einmal zum Starten und erneut zum Aufhören.",
"How far the left stick moves before you walk. Raise it if you drift.": "Wie weit sich der linke Stick bewegt, bevor du gehst. Erhöhe ihn, wenn du wanderst.",
"How far a trigger is pulled before it counts as pressed.": "Wie weit ein Trigger gezogen wird, bevor er als gedrückt gilt.",
"Shows what your buttons do right now, in play and in inventories.": "Zeigt, was deine Tasten gerade tun, im Spiel und in Inventaren.",
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Zeigt nur, was du mit dem tun kannst, was du ansiehst, und die wichtigsten Inventartasten.",
"Doesn't show what your buttons do.": "Zeigt nicht, was deine Tasten tun.",
"Shows the buttons of the controller you're using.": "Zeigt die Tasten des Controllers, den du verwendest.",
"Always shows Xbox buttons.": "Zeigt immer Xbox-Tasten.",
"Always shows PlayStation buttons.": "Zeigt immer PlayStation-Tasten.",
"Always shows Nintendo buttons.": "Zeigt immer Nintendo-Tasten.",
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A rechts bestätigt und springt, B geht zurück, wie auf einer Switch.",
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B unten bestätigt und springt, A geht zurück, wie auf anderen Controllern.",
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Öffnet auf einem Steam Deck unter Steam die Tastatur von Steam, sonst die von Horizon.",
"Always opens Horizon's keyboard.": "Öffnet immer die Tastatur von Horizon.",
"Always opens Steam's keyboard.": "Öffnet immer die Tastatur von Steam.",
"A DualSense's light shows your health, from green through amber to red.": "Das Licht eines DualSense zeigt deine Gesundheit, von Grün über Bernstein bis Rot.",
"A DualSense's light changes with the dimension you're in.": "Das Licht eines DualSense ändert sich mit der Dimension, in der du bist.",
"A DualSense keeps its own light.": "Ein DualSense behält sein eigenes Licht.",
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Erlaubt Handwerken mit LT + A auf Servern, nicht nur in deinen eigenen Welten. Manche Server werten es als Makro.",
"Show the buttons for what you can do, while you play with a controller.": "Zeigt die Tasten für das, was du tun kannst, während du mit einem Controller spielst.",
"How fast the stick moves the crosshair in inventories.": "Wie schnell der Stick das Fadenkreuz in Inventaren bewegt.",
"How fast the right stick scrolls a list.": "Wie schnell der rechte Stick eine Liste scrollt.",
"How long a held direction waits before it repeats.": "Wie lange eine gehaltene Richtung wartet, bevor sie sich wiederholt.",
"How long a held direction takes between repeats.": "Wie lange eine gehaltene Richtung zwischen Wiederholungen braucht.",
"Which on-screen keyboard opens when a text field is pressed.": "Welche Bildschirmtastatur sich öffnet, wenn ein Textfeld gedrückt wird.",
"Every action back on its default button.": "Jede Aktion zurück auf ihre Standardtaste.",
"These settings belong to this player's profile.": "Diese Einstellungen gehören zum Profil dieses Spielers.",
"The controller driving this player, or else the first one connected.": "Der Controller, der diesen Spieler steuert, sonst der zuerst verbundene.",
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "USB-Hersteller und -Produkt, die das System meldet. Nützlich, wenn du einen Controller meldest, der sich falsch verhält.",
"The glyph family the game picked from the controller's maker.": "Die Symbolfamilie, die das Spiel anhand des Herstellers des Controllers gewählt hat.",
"Follow the controller, or always show one family's buttons.": "Dem Controller folgen oder immer die Tasten einer Familie zeigen.",
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Ob A und B eines Nintendo-Controllers nach ihrer Position oder nach ihren Buchstaben arbeiten.",
"The buttons menus show right now.": "Die Tasten, die Menüs gerade zeigen.",
"Rumble when something happens to you.": "Vibriert, wenn dir etwas passiert.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Der Controller spielt weiter, während dieses Fenster nicht im Fokus ist, und der Fokusverlust pausiert nicht.",
"A DualSense's triggers push back on bows and tools.": "Die Trigger eines DualSense drücken bei Bogen und Werkzeugen dagegen.",
"What a DualSense's light shows, over USB or Bluetooth.": "Was das Licht eines DualSense über USB oder Bluetooth zeigt.",
"Player profile": "Spielerprofil",
"Vendor and product": "Hersteller und Produkt",
"Detected glyphs": "Erkannte Symbole",
"Glyphs in use": "Verwendete Symbole",
"Unknown": "Unbekannt",
"No controller": "Kein Controller",
"Editing: %s": "Bearbeiten: %s",
"Press the button for this. %s cancels.": "Drücke die Taste dafür. %s bricht ab.",
"This button also does: %s": "Diese Taste tut auch: %s",
"horizon.pad.notice.connected.press": "Drücken zum Verwenden",
"horizon.recipe_transfer.missing": "Fehlende Gegenstände",
"horizon.recipe_transfer.no_room": "Inventar voll",
"horizon.recipe_transfer.unavailable": "Rezept passt nicht in dieses Menü",
"horizon.recipe_transfer.unsupported": "Server erfordert ein bekanntes Rezept",
"horizon.studio.timeline.freecam_on": "Freie Kamera: EIN",
"horizon.studio.timeline.freecam_off": "Freie Kamera: AUS",
"horizon.studio.timeline.freecam_short": "Cam",
@@ -142,9 +142,7 @@
"Size of menus and the HUD.": "Το μέγεθος των μενού και το HUD.",
"Sets the options below together. Changing one makes it Custom.": "Ρυθμίζει τις παρακάτω επιλογές μαζί. Η αλλαγή ενός το καθιστά Προσαρμοσμένο.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Η λάβα, η φωτιά και το φως του ήλιου λάμπουν πιο φωτεινά από το λευκό. Χρειάζεται οθόνη HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Η οθόνη σας είναι το πιο σκούρο μαύρο. 0 για OLED? Οι LCD λάμπουν λίγο.",
"How bright highlights get. Auto: the peak your system reports.": "Πόσο φωτεινά σημεία αποκτούν. Auto: η κορύφωση που αναφέρει το σύστημά σας.",
"How bright white paper and menus look. Auto: your desktop's white.": "Πόσο φωτεινό φαίνεται το λευκό χαρτί και τα μενού. Αυτόματο: η επιφάνεια εργασίας σας είναι λευκή.",
"How bright the HUD and menus are, against paper white.": "Πόσο φωτεινά είναι τα HUD και τα μενού, σε σχέση με το λευκό χαρτί.",
"How far mobs, crops and machines keep working. CPU time.": "Πόσο μακριά συνεχίζουν να λειτουργούν όχλοι, καλλιέργειες και μηχανές. Χρόνος CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Πόσο μακριά κληρώνονται όχλοι και αντικείμενα, ως ποσοστό του κανονικού.",
@@ -307,9 +307,9 @@
"Size of menus and the HUD.": "Size of menus and the HUD.",
"Sets the options below together. Changing one makes it Custom.": "Sets the options below together. Changing one makes it Custom.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Your display's darkest black. 0 for OLED; LCDs glow a little.",
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Lifts shadows onto your display's darkest black. 0: true black, no lift.",
"How bright highlights get. Auto: the peak your system reports.": "How bright highlights get. Auto: the peak your system reports.",
"How bright white paper and menus look. Auto: your desktop's white.": "How bright white paper and menus look. Auto: your desktop's white.",
"How bright white looks in the world. Auto: your desktop's white.": "How bright white looks in the world. Auto: your desktop's white.",
"How bright the HUD and menus are, against paper white.": "How bright the HUD and menus are, against paper white.",
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
@@ -689,6 +689,17 @@
"horizon.pack.filter.x32": "32x",
"horizon.pack.filter.x64": "64x",
"horizon.pack.addFailed": "The pack could not be added: %s",
"horizon.pack.options": "Pack Options",
"horizon.pack.optionsInfo": "Options of this pack",
"horizon.pack.options.title": "Pack Options",
"horizon.pack.options.general": "General",
"horizon.pack.options.presets": "Presets",
"horizon.pack.options.presets.custom": "Custom",
"horizon.pack.options.impact": "Performance impact: %s",
"horizon.pack.options.impact.low": "low",
"horizon.pack.options.impact.medium": "medium",
"horizon.pack.options.impact.high": "high",
"horizon.pack.options.impact.varies": "varies",
"horizon.extras.header.cosmetics": "Cosmetics",
"horizon.extras.cosmetics_mine": "My cosmetics",
"horizon.extras.cosmetics_mine.tooltip": "Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe.",
@@ -147,9 +147,7 @@
"Size of menus and the HUD.": "Grandeco de menuoj kaj la HUD.",
"Sets the options below together. Changing one makes it Custom.": "Kunigas la eblojn sube. Ŝanĝi unu faras ĝin Propra.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lafo, fajro kaj sunlumo brilas pli hele ol blanka. Bezonas HDR-ekranon.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Via ekrano estas plej malhela nigra. 0 por OLED; LCD-oj brilas iomete.",
"How bright highlights get. Auto: the peak your system reports.": "Kiel brilaj brilaĵoj ricevas. Aŭtomata: la pinto kiun via sistemo raportas.",
"How bright white paper and menus look. Auto: your desktop's white.": "Kiel brile blankaj paperoj kaj menuoj aspektas. Aŭtomata: via labortablo estas blanka.",
"How bright the HUD and menus are, against paper white.": "Kiel brilaj la HUD kaj menuoj estas, kontraŭ papera blanka.",
"How far mobs, crops and machines keep working. CPU time.": "Kiom malproksime amasoj, kultivaĵoj kaj maŝinoj daŭre funkcias. CPU-tempo.",
"How far away mobs and items are drawn, as a share of normal.": "Kiom malproksime amasoj kaj eroj estas tiritaj, kiel parto de normalo.",
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff. Show more