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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12754

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12749

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12707

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

Fixes #12706

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

Fixes #12705

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

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

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

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

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

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

Closes #12709

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

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

Closes #12660

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

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

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

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

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

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

Fixes #12522.

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

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

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

Fixes #12682

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12650

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

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

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

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

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

Fixes #12632

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

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

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

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

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

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

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

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

Fixes #12645

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12586

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

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

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

Fixes #12588

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

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

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

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

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

Part of #12572

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

Fixes #12519

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

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

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

Improved terminology consistency to better align with standard Minecraft concepts.

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

Corrected grammatical cases, declension, and gender agreement.

Converted button labels to infinitive form for consistency.

Translated remaining English strings.

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

Corrected product name spelling and its declension.

Replaced mistranslations with accurate terms.

Fixed preposition usage.

Corrected typos.

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

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

Part of #10492

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #12536

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

Closes #12534

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:31:09 -03:00
Ludovic Marie 11102a5651 Merge pull request #12516 from RINNECODER/up5/port-armor-tags
🤖 Agent: The declaration passes horizon-mods' item check: max_stack_size 1 is the field (1..=99), and minecraft:equippable with only slot is a valid 26.3 Equippable. The #tag walk stops at 8 levels and reads only the carried jar's tags, so the oracle path and untagged items are unchanged.
2026-11-08 14:28:04 +01:00
Ludovic Marie 0cf6116d8d Merge pull request #12505 from sluisr/sec/protocol-recursion-and-threads-soundness
🤖 Agent: Same diff as #12490, which I reviewed before its author closed it (its verify failure was a linker crash on a full-disk runner). A server's registry ids can no longer walk out of the resources folder (no ., .., empty or upper-case segments), predicate components get the same nesting guard as potions, and banner layer counts are bounded by the bytes left (2 per layer).
2026-11-08 14:28:00 +01:00
Ludovic Marie bf53e59fae Merge pull request #12511 from MSedgeMC/port_map/debug-profile
🤖 Agent: DebugEntries has load, set_status, toggle_status, enabled and save, and the F3+F6 editor is game/debug_options.rs, so the 'no editor' skip note was out of date.
2026-11-08 14:27:57 +01:00
Ludovic Marie affb4cbc98 Merge pull request #12510 from MSedgeMC/port_map/multiplayer-allowed
🤖 Agent: AccountStatus::allows_multiplayer in ban_notice.rs is the account check the title screen reads; a skip:service with no note was stale.
2026-11-08 14:27:52 +01:00
Ludovic Marie ab6fe1802d Merge pull request #12509 from MSedgeMC/port_map/world-dimensions-stable
🤖 Agent: dimension_is_vanilla_like in save.rs holds the three stable arms, so the isStable* rows are inline there and isVanillaLike is the port.
2026-11-08 14:27:47 +01:00
martipopsandClaude Opus 5.5 0043afcd0c Merge ui/pad-glyphs into ui/controller-shell
Controller Settings' binding rows take the player's resolved `Glyphs`
(`Context::glyphs`, from `OptionsUi::pad_glyphs`). docs/ui-kit.md keeps
main's HUD kit guide and gains the settings shell as its own section and
table row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:27:45 -05:00
Ludovic Marie c23c62df76 Merge pull request #12508 from MSedgeMC/port_map/equipment-is-armor
🤖 Agent: Slot::is_armor exists in mobs/equip.rs (head, chest, legs, feet, not the hands), so the stale skip:other becomes the port.
2026-11-08 14:27:42 +01:00
Ludovic Marie 67093685db Merge pull request #12458 from glitchsnake/fix/11807-summon-duplicate-uuid
🤖 Agent: PersistentEntitySectionManager refuses a UUID already in the level, so a duplicate inside a summoned tree stays a saved passenger under Passengers. Cost to watch: target_with_uuid scans every known UUID, now once per loaded entity with a UUID. I pushed #[ignore] on its second test, which unwraps HORIZON_TEST_RESOURCES like its ignored sibling (verify failed on it).
2026-11-08 14:27:39 +01:00
Ludovic Marie 012391f4aa Merge pull request #12405 from TaquitoSuabe/perf/cheap-in-order-chunks
🤖 Agent: Columns decode on up to 4 workers and are delivered strictly in arrival order (seq). I pushed a fix: the attachments retain runs after the column's block entities load again, so a resent column keeps its block entities' attachments.
2026-11-08 14:23:38 +01:00
Ludovic Marie 4757e2ddca Merge pull request #12507 from MSedgeMC/port_map/weather-layers
🤖 Agent: Builder::weather_layers and weather_layer are in horizon-world's environment_system.rs on main; environment.rs no longer has apply_weather, so the two rows point at the code that runs.
2026-11-08 14:23:35 +01:00
Ludovic Marie c1e8fc5d4b Merge pull request #12495 from MSedgeMC/fix/non-tame-prey-recheck
🤖 Agent: NonTameRandomTargetGoal: canUse needs an untamed mob, and the target keeps matching the selector (Turtle.BABY_ON_LAND_SELECTOR now checks !isInWater), so a baby turtle that reaches water is let go.
2026-11-08 14:22:01 +01:00
Ludovic Marie aa3d35ebc7 Merge pull request #12492 from sluisr/perf/entity-pool-and-meshing-allocs
🤖 Agent: pack_lighting reuses one stack array (every row below h is fully written, the rows above refilled), the server's object states reuse the pooled States, and the guest turn skips the 5-stack clone when the gear is unchanged.
2026-11-08 14:21:58 +01:00
Ludovic Marie d178e80ea5 Merge pull request #12484 from TheMrZZ/fix/h182-trader-llama-discard
🤖 Agent: TraderLlama.maybeDespawn discards in aiStep, so a removed trader llama leaves the mob list in that tick (tick returns false); tame or persistent ones stay.
2026-11-08 14:21:54 +01:00
Ludovic Marie 7f117f484e Merge pull request #12465 from TheMrZZ/fix/armor-stand-lodestone-inventory-tick
🤖 Agent: EntityEquipment.tick runs each equipped item's inventoryTick, so a stand's lodestone compass loses its target when the lodestone goes; only compass stacks pay the lookup.
2026-11-08 14:21:50 +01:00
Ludovic Marie d3910293e0 Merge pull request #12457 from Joansitoh/fix/11886-trident-falls
🤖 Agent: AbstractArrow.tick: a stuck trident falls when the block at its position differs from lastState (the hit block) and nothing is within 0.06; startFalling's three nextFloat()*0.2F draws scale x, y, z in that order.
2026-11-08 14:21:47 +01:00
Ludovic Marie c3d6c640ac Merge pull request #12383 from TheMrZZ/fix/h259-unused-feature-startup
🤖 Agent: An unused invalid fill_layer in a selected pack now fails world preparation through the existing leaf codec, as vanilla's registry load does, instead of the dedicated server falling back to a flat generator; its capture harness (--kind/--output) runs by name only (pushed).
2026-11-08 14:21:44 +01:00
Ludovic Marie 37e9e82dfd Merge pull request #12501 from MSedgeMC/fix/spider-vehicle-attack
🤖 Agent: SpiderAttackGoal.canUse is super.canUse() && !isVehicle(): Mob.rider (a player on its back, set by mounts.rs and the remote/LAN vehicle paths) now counts beside extra.rider (a mob jockey).
2026-11-08 14:21:40 +01:00
Ludovic Marie f4eebc417e Merge pull request #12500 from MSedgeMC/fix/hurt-by-universal-anger
🤖 Agent: HurtByTargetGoal.canUse returns false for a player's hit when universal_anger is on (checked after the timestamp, before the ignore classes, as vanilla); Who has only Player/Mob, so guests' hits take the same path.
2026-11-08 14:21:36 +01:00
Ludovic Marie 0e83631f9d Merge pull request #12499 from TheMrZZ/fix/entity-predicate-movement
🤖 Agent: MovementPredicate.matches as vanilla: per-tick velocity × 20, speed and horizontal_speed against squared bounds (a negative bound squares, as MinMaxBounds.Doubles does), vertical_speed on |y|, exact bounds with no tolerance; only an unmounted, unridden mob gets the snapshot, so other subjects keep today's false.
2026-11-08 14:21:33 +01:00
Ludovic Marie a2140a9ef6 Merge pull request #12502 from TheMrZZ/fix/golem-defense-reputation
🤖 Agent: DefendVillage reads the villager's gossips by the selected player's own UUID instead of level.player_uuid, so a hated guest on a server is attacked and a hated host no longer turns the golem on a guest. I pushed a commit dropping a doc paragraph and a constant copied from another test file.
2026-11-08 14:21:30 +01:00
Ludovic Marie 35b2938c47 Merge pull request #12503 from vnsmoreira/packs/polytone-particle-runtime
🤖 Agent: Merged main in (game.rs frame block and the add/add on horizon-polytone particles.rs, which matches main plus tick_source); the merged diff equals the last commit. Without a Polytone pack, polytone_animate/polytone_entity/make return at the first `let Some(registry)`, so the animate tick and spawn paths pay nothing.
2026-11-08 14:21:26 +01:00
Ludovic Marie 236b13f290 Merge pull request #12389 from Vaspyyy/armor-stand-entity-collisions
🤖 Agent: Stands collide with shulkers, boats and a still adult happy ghast through Entity.collide's getEntityCollisions, with an obstacle grid built only when a stand moves, and Shulker.makeBoundingBox's lid lifts what rests on it.
2026-11-08 14:21:22 +01:00
Ludovic Marie 838ae17615 Merge pull request #12504 from horizon-rust/cg72/grindstone-default-max-damage
🤖 Agent: Grindstone max_damage fix for main's menu_click 7998/8000 (#11434): writes the merged MAX_DAMAGE only when it differs from the item's own, as PatchedDataComponentMap.set does; unit test with the failing case
2026-11-08 14:21:18 +01:00
apeirs 5883c0a485 Adreno under DirectX 12: draw vanilla terrain flat
wgpu's indirect multi-draw is ExecuteIndirect on DX12, and on Qualcomm's Adreno
each per-section entry costs the GPU more than the flat path's compute copy and
one index list per page. Measured on an Adreno X1-45 (Windows 11 ARM64), the
four perf_bench scenes held still, three alternating rounds: 3-11% higher FPS at
render distance 12, 2-13% at 32, the same picture.
2026-11-08 14:17:48 +01:00
Florian ErnstandClaude Opus 5.5 66ff309502 A broken armor stand rolls its entity loot table
ArmorStand.brokenByAnything calls dropAllDeathLoot before it pops the
equipment: a stand that is not small (isBaby is isSmall) rolls its
type's table minecraft:entities/armor_stand when mob_drops is on. Horizon
only popped the equipment, so a data pack's loot for armor stands never
dropped when a stand burnt out, was blown up or broken by /damage. The
break now rolls that table first, with seed 0 (the table's random
sequence, else the level's random), and spawns each stack at the stand.

Part of #12514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:15:14 +01:00
RINNECODERandClaude Opus 5.5 bdc8a23f21 Port pack: without the oracle, armour the vanilla armour tags name is worn in its slot
A carry without the oracle's facts declared every item that has no block as
a stick, so a mod's helmets and chestplates could not be worn. Items the
carried minecraft:{head,chest,leg,foot}_armor tags name (directly or through
the mod's own tags) now get minecraft:equippable with that slot and a stack
size of 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:11:06 -06: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 cfeab5a7e9 Measure a broken armor stand's loot table (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:07:25 +01:00
Horizon agentandClaude Opus 5.5 087ce1708b Merge origin/main into armor-stand-entity-collisions
ObstacleGrid keeps main's reused scratch list (near takes &mut self) with
this branch's roots, near_except and huge-area path; the shulker keeps one
progress_aabb with a scale and bullet_origin is the centre of bounds_at;
the shulker shell tick and main's trader llama tick both run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 14:03:16 +01:00
MSedgeMC 61b7fa5b26 port_map: the three stable-dimension helpers are arms of dimension_is_vanilla_like 2026-11-08 13:01:34 +00:00
MSedgeMC 1df93f9739 port_map: the rest of the debug profile editor rows are ports 2026-11-08 13:00:39 +00:00
MSedgeMC 17e2982614 port_map: the debug profile load and save rows are ports
Both methods are implemented in debug_entries.rs and used by the F3+F6
DebugOptionsScreen; the rows' "no editor" note is stale.
2026-11-08 12:59:18 +00:00
Horizon agentandClaude Opus 5.5 eb74ad2a63 Merge origin/main into packs/polytone-particle-runtime
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:59:14 +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
MSedgeMC 34aa927de4 port_map: Minecraft.allowsMultiplayer is allows_multiplayer
The row skipped the account check as a service. AccountStatus::allows_multiplayer
is documented as the method and the title screen enables its Multiplayer and
Realms buttons from it.
2026-11-08 12:58:48 +00:00
MSedgeMC 491403897f port_map: WorldDimensions.isVanillaLike is dimension_is_vanilla_like
The row skipped the predicate, but save::dimension_is_vanilla_like is
documented as the method and its caller world_gen_unstable drives the
experimental-settings warning the note mentions.
2026-11-08 12:58:14 +00:00
MSedgeMC 3f7e6970ae port_map: EquipmentSlot.isArmor is Slot::is_armor
The row skipped the predicate as a plain constant, but Slot::is_armor is
documented as EquipmentSlot.isArmor and read by slot_for and mob loot
replacement.
2026-11-08 12:57:49 +00:00
Horizon agentandClaude Opus 5.5 d609f56d42 golem_defense_tests: drop a doc paragraph and a constant copied from another test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:57:20 +01:00
MSedgeMC 37ed06402d port_map: the weather layer rows name environment_system
apply_weather is gone; the weather layers live in horizon-world's
environment_system: Builder::weather_layers adds one per attribute rain or
thunder changes, and Builder::weather_layer registers the entries weather
runs.
2026-11-08 12:57:19 +00:00
sluisr e5043bb090 fix(security): bound item predicate recursion, validate registry paths, and fix threads UB
- Guard recursive `Component::read` calls in `skip::component_matchers`,
  `gameplay::block_predicate`, and `v1_20_3::from_tag` with `Nested::enter()`
  to prevent stack overflow DoS via deeply nested `CAN_PLACE_ON` / `CAN_BREAK`
  exact component matchers, and tighten `banner_layers` pre-allocation cap.
- Validate registry and entry resource location segments in
  `net::fill_known_entries` before joining paths and reading local files on
  `Clientbound::RegistryData`.
- Fix data race UB in `horizon_threads::heap_census` (`slot` / `slot_name`)
  by publishing `NAME_PTR` with `Release` and loading with `Acquire`, and
  guard `CensusAllocator::alloc` / `realloc` against size addition overflow.
- Fix provenance UB in `horizon_threads::boot_paint` where an immutable
  reference `&count` was passed as Win32 `AddFontMemResourceEx`'s mutable
  `pNumFonts` out-pointer, and require `'static` font byte slices.
2026-11-08 13:55:29 +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
Nikita Maximilian GrimmandClaude Opus 5.5 1c1ad8ec32 The outer ring of a portal ticket runs scheduled ticks and block events
A portal ticket's streaming area had radius ENTITY_TICKING - level (1 for
the level-30 ticket), so chunks finished only out to ring 2 and a ring-2
chunk never had all its neighbours: its scheduled block and fluid ticks and
block events never ran. Vanilla keeps ring 3 FULL (level 33), so ring 2
(BLOCK_TICKING) runs them. The area's radius is now BLOCK_TICKING - level.

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

Fixes #12288

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:51:55 +01:00
Horizon agentandClaude Opus 5.5 d035060e66 Grindstone: a merged max damage equal to the item's own writes no component
GrindstoneMenu.mergeItems sets MAX_DAMAGE to the larger of the two, and
PatchedDataComponentMap.set drops a value equal to the prototype's. Since
#11434 Horizon always wrote it, so merging two plain diamond pickaxes gave
one carrying max_damage:1561 (menu_click 7998/8000 on main).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:50:25 +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
MSedgeMC afb2ce0f1e A spider carrying a player does not start its attack
`SpiderAttackGoal.canUse` is `super.canUse() && !isVehicle()`, and
`isVehicle()` is true for any rider. Horizon's `SpiderAttack` checked only
mob riders (`extra.rider`), so a spider with a player on its back began
the melee goal.
2026-11-08 12:48:48 +00:00
MSedgeMC 2ffc8b5f56 Universal anger leaves a player's hit to the anger goals
`HurtByTargetGoal.canUse` refuses a player's hit when the
`universal_anger` game rule is on, leaving that hit to the anger target
goals. Horizon's `HurtByTarget` always retaliated against the player, so
the rule changed the anger bookkeeping but not the direct retaliation.
2026-11-08 12:48:16 +00:00
martipopsandClaude Opus 5.5 1241a139e9 Merge upstream/main into ui/pad-glyphs
Resolve docs/ui-kit.md onto main's HUD kit guide, with the glyph section added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:47:33 -05:00
Kacpherek b5aa2303fb port_ledger blame: from a parity trace's first difference to the code
A tick-by-tick parity trace says where a run first differs from vanilla, not
which code computed the value. `blame ENTITY FIELD...` lists the vanilla
methods an entity's tick runs (overrides resolved, plus the goals its
registerGoals builds) that write the field, directly (putfield) or through
its setter, its own class first, each with its Rust port and the ledger's
gaps on it. test_mob_ai_parity.py prints these under each mob's first
divergence when a ledger has been built, and the port of the vanilla frame
of the first random draw when a draw differs.
2026-11-08 13:43:25 +01:00
Kacpherek 616ba9f131 port_ledger: float gaps, a widened float literal spelled as a double
A vanilla method that adds `0.3F` to a double works with 0.30000001192092896
(javac folds the widened float into the bytecode); a port that writes 0.3
computes another number. The new `float` gap kind finds a method's widened
float constants in the jar and flags its port (or the fn whose inline comment
names it) when the port, or a fn it calls, spells the value only as a double:
not as `x_f32`, `f64::from(x_f32)`, an f32 const, or the widened value
written out.
2026-11-08 13:43:25 +01:00
Ludovic Marie 447e9d277d Merge pull request #12477 from vnsmoreira/packs/polytone-particles
🤖 Agent: Includes #12475 and #12476. Particle and emitter files are parsed with every expression compiled at load (a typo fails the load); nothing spawns them yet, so no runtime cost.
2026-11-08 13:42:09 +01:00
TheMrZZandCodex d0d9e050d3 Evaluate movement predicates for supported Mob query snapshots
Preserve global ranges and unsupported provider contexts.

Co-Authored-By: Codex <[email protected]>
2026-11-08 13:41:10 +01:00
MSedgeMC 678dfde14b A non-tame predator stops hunting prey its selector no longer matches
`NonTameRandomTargetGoal.canContinueToUse` re-tests the goal's
`targetConditions` while hunting, so the `Turtle.BABY_ON_LAND_SELECTOR`
(a baby turtle out of water) drops out as soon as the turtle enters
water. Horizon's `NearestTarget` only ran the base continue rules, and its
turtle filter never checked the water, so a wolf or cat kept hunting the
turtle.
2026-11-08 12:37:25 +00:00
AkirabaneandClaude Sonnet 5.5 32715a15da Recipe book: take the entries a server's packet carries
The client kept only the display id of each entry of a
ClientboundRecipeBookAddPacket and looked it up in the numbering of its
own recipes. A server that numbers its recipes differently (a plugin's
recipes, a datapack, another load order) had its recipes dropped, or
shown as another recipe, such as TNT missing on a Paper server.

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

Fixes #11705

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 13:36:50 +01:00
Ludovic Marie 1597853de7 Merge pull request #12486 from echo-ilovech3ss/fix/12287-windows-save-roundtrip-paths
🤖 Agent: Tooling only: save_roundtrip compares relative .dat paths as POSIX on every OS, so Windows' backslashes no longer make every file look renamed.
2026-11-08 13:36:22 +01:00
Ludovic Marie 7d0f866a47 Merge pull request #12481 from RINNECODER/up4/upgrade-surface-rule-biomes
🤖 Agent: A surface rule's biome_is keeps vanilla's biomes and the pack's own; others (a "comment" entry, another mod's biome) are dropped and reported as lossy, since 26.3 refuses the pack over them.
2026-11-08 13:36:18 +01:00
Vinícius MoreiraandClaude Opus 5.5 600fd5f04a Polytone: run custom particles and block and entity particle emitters
Resource packs' custom particle types now run in the particle engine: the
initializer sets the particle up, the ticker moves, recolours and removes it,
its emitters spawn more, emitter values seed the particles they spawn, and
`limit` caps how many live at once. Block modifiers' emitters run from the
block animate tick, the way torches spawn flames; entity modifiers' emitters
run on the player each client tick (fog, dust and weather around them).
Expressions see the particle, the blocks and biome around it, the player,
the camera, the time and weather.

Model and GPU particles aren't drawn yet (model ones still tick and emit);
additive blending and rotation modes fall back to the plain translucent
billboard; entity emitters ignore bones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:33:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 2145f26929 Polytone: read custom particles and particle emitters
Reads what a pack's particles do (wiki "Custom Particle Types", "Block
Properties Modifiers", "Entity Modifiers"): a custom particle type's kind
(custom, semi custom copying a vanilla one, GPU), render type, liquid
affinity, physics and despawn flags, limit, tick interval, exclusion
radius, light level and rotation mode; its initializer and ticker
expressions with their named variables; and the particle emitters that
particles, blocks and entity bones carry (the particle by id or with its
options, chance, count, position, motion, colour, size and customs, the
state predicate, biomes, spawn location, bone, max distance). Every
expression is compiled when the pack loads, so a typo is a load error.
Nothing spawns them yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:33:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 36e962a58c Polytone: colormap values in attribute layers
An attribute value or argument can name a Polytone colormap (wiki
"Colormaps"): an image read at the column and row two expressions pick,
each 0 to 1 with 1 the top-left corner, rounded to the nearest pixel by
default, the row scaled by the column when triangular. Without a .json
the axes are the biome's temperature and downfall, which the frame now
passes along. The colour is the modifier's argument whenever the layer
applies; a transparent pixel falls back to the value before the pack (the
entry the modifier replaced), as fallback_colormap's default "original".

Colormaps load once per pack reload, shared by the three dimensions'
stacks, and only those a modifier names, decoded to RGBA ints as on
Java. The biome_id axis isn't supported yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:33:09 -03:00
Vinícius MoreiraandClaude Opus 5.5 3ce27e636b Polytone: an expression value out of range is clamped, not dropped
A number an expression gives outside its attribute's range goes through the range as the sampled
value does (AttributeRange.sanitize) instead of failing validation and leaving the value as it
was. A pack whose fog end goes below zero deep underground (14 * view distance - 128 under the
surface) now gets its thickest fog there, as on the real mod, where the cave was left with no fog.

Part of #11486

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:32:42 -05:00
Ludovic Marie db2b5ff76b Merge pull request #12479 from NikitaMGrimm/hdr-resolve-gone
🤖 Agent: HDR output now takes its gain from Hdr::draw_params instead of the removed mark-resolve lane; the change stays behind HDR, which is off by default, and frame_identity's test covers the no-pack path.
2026-11-08 13:29:59 +01:00
Florian ErnstandClaude Opus 5.5 06fe2cc687 An iron golem reads the reputation of the player it selects
DefendVillageTargetGoal asks the villagers in the golem's box for the
reputation of the player (Villager.getPlayerReputation: the gossips
about that player's UUID). Horizon read one reputation per villager,
keyed by the level's own player, while each golem's goal looks at the
player it selects, often a nearby guest: on a server, a hated guest was
never attacked, and a hated host could turn the golem on a guest it
did not hate. The goal now reads the villager's gossips about the
selected player's UUID.

Fixes #12487

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:27:39 +01:00
Ludovic Marie a9033e8c41 Merge pull request #12483 from RINNECODER/up4/upgrade-biome-effects
🤖 Agent: 1.21.1 biome effects move to 26.3's visual/audio attributes (colours as #rrggbb), carvers become the air list, and liquid carvers are reported as lossy.
2026-11-08 13:26:43 +01:00
Ludovic Marie c3a1e1293f Merge pull request #12478 from glitchsnake/fix/command-components-expected-equal
🤖 Agent: A bare component in an item argument ([custom_data]) now fails with expected '=' as ItemParser's reader.expect does, instead of being ignored; !removals are unchanged.
2026-11-08 13:26:40 +01:00
Ludovic Marie 1e3e77270d Merge pull request #12470 from oloion/perf/client-culling-and-chunk-streaming
🤖 Agent: Frustum select picks the same corner as the branches did (-0.0 >= 0 both ways) and the resident bounds are origin()'s arithmetic inline. I pushed a fix: upload order measured the camera section as floor(y/16) while keys count from the world's bottom, four sections off in the overworld.
2026-11-08 13:26:36 +01:00
Ludovic Marie 138616afdb Merge pull request #12475 from vnsmoreira/packs/polytone-expression-values
🤖 Agent: Pack expressions are compiled at load and evaluated against the frame only when a pack's environment stack is loaded (pack.environments), so the vanilla path sets no frame and keeps folding constant layers.
2026-11-08 13:23:43 +01:00
Florian ErnstandClaude Opus 5.5 1863a04353 Measure a golem's defence against the player it selects (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:22:56 +01:00
Evan-lhm 3fa4cbd9a8 Merge remote-tracking branch 'origin/main' into fix/mod-content-below-packs 2026-11-08 13:21:06 +01:00
Shreyas 8634b05990 save_roundtrip: normalize relative dat paths to POSIX slashes on Windows (#12287) 2026-11-08 17:51:06 +05:30
Evan-lhmandClaude Opus 5.5 f2719daa05 Mod content tests: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:21:06 +01:00
Ludovic Marie bf928658ef Merge pull request #12474 from horizon-rust/cg72/loot-mine-named-only
🤖 Agent: Tooling only: loot_mine_location_block asserts its .local/h079/capture is new, so it fails at startup on the Mac runner after one run (#12405); it joins the other capture harnesses in named_only.
2026-11-08 13:20:28 +01:00
Ludovic Marie 8c58505b3c Merge pull request #12456 from martipops/ui/hud-room
🤖 Agent: The room is rebuilt each frame from the boss bars (BossHealthOverlay: y 12, step 19); with 0-1 bar the notice stays at y 18, and HudBatch::new there allocates nothing. I merged main (client_lib re-export list: hud_kit beside main's social).
2026-11-08 13:18:14 +01:00
Ludovic Marie d3151e0cc2 Merge pull request #12418 from RINNECODER/up4/port-item-place-block
🤖 Agent: A block item whose block has another name (seeds) is carried with place_block; one whose block is left out stays in items_unknown. I merged main (both branches added a test at the same spot; both kept).
2026-11-08 13:18:10 +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
Vinícius MoreiraandClaude Opus 5.5 2017a3b977 Polytone: read custom particles and particle emitters
Reads what a pack's particles do (wiki "Custom Particle Types", "Block
Properties Modifiers", "Entity Modifiers"): a custom particle type's kind
(custom, semi custom copying a vanilla one, GPU), render type, liquid
affinity, physics and despawn flags, limit, tick interval, exclusion
radius, light level and rotation mode; its initializer and ticker
expressions with their named variables; and the particle emitters that
particles, blocks and entity bones carry (the particle by id or with its
options, chance, count, position, motion, colour, size and customs, the
state predicate, biomes, spawn location, bone, max distance). Every
expression is compiled when the pack loads, so a typo is a load error.
Nothing spawns them yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 a3b56d6218 Polytone: colormap values in attribute layers
An attribute value or argument can name a Polytone colormap (wiki
"Colormaps"): an image read at the column and row two expressions pick,
each 0 to 1 with 1 the top-left corner, rounded to the nearest pixel by
default, the row scaled by the column when triangular. Without a .json
the axes are the biome's temperature and downfall, which the frame now
passes along. The colour is the modifier's argument whenever the layer
applies; a transparent pixel falls back to the value before the pack (the
entry the modifier replaced), as fallback_colormap's default "original".

Colormaps load once per pack reload, shared by the three dimensions'
stacks, and only those a modifier names, decoded to RGBA ints as on
Java. The biome_id axis isn't supported yet.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:21 -03:00
Vinícius MoreiraandClaude Opus 5.5 30aee13f1d Polytone: an expression value out of range is clamped, not dropped
A number an expression gives outside its attribute's range goes through the range as the sampled
value does (AttributeRange.sanitize) instead of failing validation and leaving the value as it
was. A pack whose fog end goes below zero deep underground (14 * view distance - 128 under the
surface) now gets its thickest fog there, as on the real mod, where the cave was left with no fog.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:20 -03:00
sluisr fec26a0044 perf: reuse server States tracking pool and reduce meshing/lighting allocations
- Reuse the pooled EntityState entries in States (`out.add`) across all 21
  object and projectile loops in `lan_entities.rs`, and push vehicle, armor
  stand pose, and arrow metadata directly into `e.meta`.
- Look up vehicle driver mobs via `self.mob_index` in `lan_riding` instead
  of a linear scan over `self.mobs`.
- Compare `play.equipped` by reference before cloning 5 `ItemStack`s on
  every `enter_guest` turn, and reuse `play.craft_grid` via `std::mem::swap`.
- Eliminate per-fluid-cell `Vec::new()` allocations in `mesher/fluid.rs` and
  deduplicate animated fluid layers directly into `out.sprite_layers`.
- Reuse `SectionMesh::packed` capacity and a thread-local scratch buffer in
  `mesher/packed.rs` during `sort_solid`.
- Pack uniform/single-channel lighting from a stack buffer in `chunk.rs`
  (`Light::from_slice`) instead of allocating and dropping a 4 KiB `Box`.
- Check `!self.saves_beside_final` and neighbor readiness before cloning
  `Chunk` in `Streaming::update`.
2026-11-08 13:15:43 +01:00
Delete228andCodex e9aa68c3ed Merge Horizon main into the state-dependent light PR
Keep entity-context geometry and state-dependent light in the same content compiler. Exercise both selectors in the light propagation fixture.

Co-authored-by: Codex <[email protected]>
2026-11-08 15:11:38 +03:00
LokiandAntigravity 9fd2c8c339 items: require '=' for non-removal command components
Java 26.3 `ItemParser.State.readComponents` expects '=' after a component
type unless it begins with '!'. Return an error for bare components rather
than silently ignoring them.

Co-Authored-By: Antigravity <[email protected]>
2026-11-08 21:08:01 +09:00
Ludovic Marie 3f58a9511b Merge pull request #12461 from MSedgeMC/port_map/creative-screen
🤖 Agent: draw_creative, click_creative and creative_tooltip_tabs moved to game/creative_screen.rs on main; the rows follow them.
2026-11-08 13:06:07 +01:00
Ludovic Marie 2d087e4826 Merge pull request #12460 from MSedgeMC/port_map/shear-saddle
🤖 Agent: Game::shear_saddle exists on main and takes a saddle off any saddleable mob, so attemptToShearEquipment is a port row now.
2026-11-08 13:06:03 +01:00
Horizon agentandClaude Opus 5.5 d426f98ae2 parity: run loot_mine_location_block by name only
It writes .local/h079/capture and asserts the capture is new, so every automatic
run on a checkout that ran it once fails at startup (the Mac mini runner).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 13:04:57 +01:00
RINNECODERandClaude Opus 5.5 48f02115ed Port pack: 1.21.1 biomes upgrade their effects to 26.3 attributes and their carvers to a list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:02:45 -06:00
RINNECODERandClaude Opus 5.5 06b5147e56 Port pack: surface rules drop the biomes nothing defines, as 1.21.1 never matched them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:02:32 -06:00
Ludovic Marie 9fc6abb08b Merge pull request #12468 from kacpherek/fix/server-sword-blocking
🤖 Agent: A consumable or blocks_attacks the server's patch adds (components 24 and 37) now starts a use like the item's own (getUseDuration reads the stack); items with their own consumable are unchanged.
2026-11-08 13:01:32 +01:00
Horizon agentandClaude Opus 5.5 1a9663f21a uploads: measure the camera's section from the world's bottom
Pending upload keys count sections from min_y, so floor(y / 16) put the camera
four sections low in the overworld.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:58:30 +01:00
Florian Ernst 8b4a95b099 Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 12:53:45 +01:00
Ludovic Marie eaca6fe37a Merge pull request #12467 from masa-masa-0314/fix/realms-terms
🤖 Agent: Realms answers 6002 for a new account: RealmsTermsScreen shows (Agree posts /mco/tos/agreed, then the join resumes; Disagree goes back), with vanilla aka.ms/MinecraftRealmsTerms and mco.terms.* keys.
2026-11-08 12:53:12 +01:00
Vinícius MoreiraandClaude Opus 5.5 c52446638a Polytone: expression values in attribute layers
A pack's attribute value or argument can be a scripting expression
(wiki "Environment Attributes"): fog that closes in with the rain and
with depth, a cloud height scaled from the value the layers before gave.
Each one is compiled when the pack loads and evaluated whenever its layer
applies, against what the frame sets: g (rain, time, day time, sky type,
dimension, render distance, moon phase), p (position, in water), c
(position, view distance in blocks), o (the camera's position, sky and
block light), r (random), v (the value so far, colours packed ARGB) and
the pack's config(id). Its result is the modifier's argument; one that
fails leaves the value as it was. A dynamic entry is never folded into
the base value, since it changes every frame.

Expressions are compiled with Arc instead of Rc so a pack's stacks can be
built on the loading worker.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:51:58 -03:00
Ludovic Marie e8acedb5c2 Merge pull request #12401 from Web-mc1/studio-screen-bridge
🤖 Agent: Screen::Studio is opened only by `studio screen open` and draws nothing itself (no chat behind it); Escape closes it. I pushed the rustfmt fix verify failed on.
2026-11-08 12:50:37 +01:00
Horizon agentandClaude Opus 5.5 a0d1a9f0e3 Merge origin/main into up4/port-item-place-block
Both branches added a test before the Windows-separator one: both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:48:19 +01:00
Ludovic Marie 4b2cba530a Merge pull request #12451 from apeirs/fix/fxc-entity-written-color
🤖 Agent: FXC (DX12) failed on discards one call below the entry point; fs_entity now computes entity_color itself and hands it on, same colour on every backend.
2026-11-08 12:48:16 +01:00
Ludovic Marie 4b99e9a701 Merge pull request #12441 from RINNECODER/up3/far-tile-walk
🤖 Agent: Boxing the far tile meshes shrinks Tile for the per-frame walk; release paths unbox each mesh once, nothing double-released.
2026-11-08 12:48:12 +01:00
Horizon agentandClaude Opus 5.5 4497f18b31 Merge origin/main into ui/hud-room
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:47:26 +01:00
Riccardo GuerrieroandClaude Opus 5.5 a978363647 Merge origin/main into perf/eager-chunk-saves
dedicated.rs: main's `stop_now` loop condition, keeping the per-tick
`server_tick_deadline` the eager saves read for `haveTime`.
ModifiedChunks gains `clear()` (dirty set only, like `drain`) for main's
new mod_block_entity_tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fekd42N6Dh4LhpXkVwZmHs
2026-11-08 12:45:37 +01:00
Ludovic Marie e761e6394a Merge pull request #12463 from MSedgeMC/port_map/edge-walk
🤖 Agent: VoxelShape::edges exists in horizon-core's shape.rs, so forAllEdges is a port now rather than skip:data.
2026-11-08 12:45:18 +01:00
Ludovic Marie bced9e4ecd Merge pull request #12462 from MSedgeMC/port_map/watcher-home
🤖 Agent: PackSelectionScreen.Watcher spans datapack.rs::folder_stamp and options_ui.rs's watch, so a home row fits better than a single port target.
2026-11-08 12:43:01 +01:00
Evan-lhmandClaude Opus 5.5 c853168fb7 Mod content: mark a reused root beside it, not in it (#12403)
With the packs now laid over the content root, rewriting the root's stamp
on every reuse changed a file the pack overlay depends on, so a second
launch with packs on rebuilt the overlay (53 s instead of 18 s). Mark the
reuse in `<root>.used` beside the root; pruning takes the newer of the two
times and removes the marker with its root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:39:35 +01:00
Ludovic Marie 4dd22ecd6a Merge pull request #12459 from MSedgeMC/port_map/infested-silverfish
🤖 Agent: spawn_infested, player_infested and mobs.rs::infested_silverfish all exist on main; the rows now name where the roll and the spawn happen.
2026-11-08 12:38:53 +01:00
Ludovic Marie ba041dc3bb Merge pull request #12439 from RINNECODER/up3/pack-halflives
🤖 Agent: Pack half-lives replace OptiFine's defaults in the same units (600/200/10/1). I pushed a scripted-clock version of its test: backdating u.last was the wall-clock flake #12354 removed.
2026-11-08 12:34:49 +01:00
861ac2b28d Thorns answers a mob's attacks on a mob, and a player's reaches the dragon
A mob wearing Thorns now hurts the mob that attacks it, as
EnchantmentHelper.doPostAttackEffects runs after each vanilla attack
caller: melee, stings, cube contacts, golem hits, charges, spear stabs,
direct arrows, tridents, wind charges, fireballs, spit, shulker bullets,
wither skulls and owned evoker fangs. Ravager roars, guardian beams and
retaliation, hot-cube contact and other non-attacks get their own
DamageMob event that skips it. The damage rolls from the attacker's own
random, the chance from the level's.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-08 12:33:49 +01:00
Ludovic Marie 706aad56c0 Merge pull request #12455 from Evan-lhm/fix/release-sums-windows-test
🤖 Agent: Git Bash's sha256sum writes `*name` (binary mode); the test strips the star, nothing else changes.
2026-11-08 12:29:15 +01:00
Ludovic Marie 564f031294 Merge pull request #12445 from RINNECODER/up3/lod-dispatch-incremental
🤖 Agent: Mesh jobs' samples are kept per 8x8 shard and synced at every active/ready change; debug builds assert the copy matches active ∩ ready.
2026-11-08 12:29:12 +01:00
Ludovic Marie f18c761edc Merge pull request #12443 from RINNECODER/up3/mod-content-copy
🤖 Agent: Plain files are copied with the same size and path checks read() made; tags, atlases, declarations, biome modifiers, worldgen and structures are still read and parsed.
2026-11-08 12:29:08 +01:00
Ludovic Marie c2661a333d Merge pull request #12410 from masa-masa-0314/feat/friends-service
🤖 Agent: The friends worker never holds the lock across HTTP (reads the etag, unlocks, fetches, relocks), and nothing starts unless the account's FRIENDS_ENABLED flag is set.
2026-11-08 12:29:05 +01:00
Ludovic Marie c09321478d Merge pull request #12408 from m-uvex/pluto/add-flake
🤖 Agent: The flake builds the launcher from the repo itself, so it follows main, unlike #11200's pinned tarball; both outputs evaluate on NixOS (horizon-launcher-0.2.0.drv, nix-shell.drv); CI does not build it.
2026-11-08 12:29:01 +01:00
Horizon agentandClaude Opus 5.5 519d9b6d97 persist: ignore the second summon UUID test without prepared resources
It unwraps HORIZON_TEST_RESOURCES like its sibling, which is already #[ignore]d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:27:29 +01:00
TheMrZZandCodex gpt-6.1-sol fe3ebc3b8d Tick lodestone compasses equipped by armor stands
Reuse the existing loaded-target predicate for actual stand equipment inside
the entity-ticking guard. Preserve player behavior and bounded tracker rules.

Fixes #12395

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 12:26:24 +01:00
MSedgeMC 98e37bbb84 port_map: the edge-walk rows name VoxelShape::edges
forAllEdges and forAllAxisEdges were skipped as data, but the walk is in
crates/horizon-core/src/geometry/shape.rs: VoxelShape::edges is documented
as forAllEdges, and its inner axis scan is forAllAxisEdges.
2026-11-08 11:24:22 +00:00
Kacper 7965bc432e Merge pull request #12447 from RINNECODER/up3/mount-tick-order
Riding: the driven mount ticks before its rider, on the input the rider's last tick applied
2026-11-08 12:24:13 +01:00
Kacper 50f53a5a2b Merge pull request #12437 from RINNECODER/up3/mount-air-speed
Mounts: a player-steered mount moves through the air at a tenth of its speed (LivingEntity.getFlyingSpeed)
2026-11-08 12:24:06 +01:00
Kacper 113ac28e79 Merge pull request #12426 from RINNECODER/up4/port-food
Port pack: food items carry over, as 26.3's food, consumable and use_remainder
2026-11-08 12:23:59 +01:00
Kacper 509deae34e Merge pull request #12424 from RINNECODER/up4/upgrade-weird-scaled-sampler
upgrade-datapack: weird_scaled_sampler becomes 26.3's interval_select of scaled noises
2026-11-08 12:23:52 +01:00
Kacper 156f335169 Merge pull request #12422 from RINNECODER/up4/upgrade-nether-vegetation
upgrade-datapack: nether_forest_vegetation becomes 26.3's spread simple_block
2026-11-08 12:23:45 +01:00
Kacper f4cbc6311e Merge pull request #12416 from RINNECODER/up4/port-farmland-template
Port pack: carried models on 1.21.1's farmland template use 26.3's indented cube
2026-11-08 12:23:38 +01:00
MSedgeMC 7a4012d00f port_map: PackSelectionScreen.Watcher is a home row
The class row was written with a `port` verdict and the function that
represents it. Class rows take `home` with the files that represent the
class; list datapack.rs (folder_stamp) and options_ui.rs (watch_data_packs).
2026-11-08 11:23:32 +00:00
Kacper dca8dbddd1 Merge pull request #12414 from RINNECODER/up4/port-recipe-conditions
Port pack: recipes with NeoForge conditions are upgraded when NeoForge loaded them
2026-11-08 12:23:31 +01:00
Kacper 6410d3f446 Merge pull request #12412 from RINNECODER/up4/port-tag-namespaces
Port pack: tags alone claim no namespace
2026-11-08 12:23:24 +01:00
Kacper 059eb482c4 Merge pull request #12400 from Solenopsisbot/perf/obstacle-grid-scratch
perf: reuse obstacle grid query scratch
2026-11-08 12:23:13 +01:00
MSedgeMC 3835921949 port_map: the creative screen rows name game/creative_screen.rs
The creative inventory screen moved out of game.rs into its own module.
draw_creative, creative_tooltip_tabs and click_creative are unchanged; only
the module path in the six rows that name them was left behind.
2026-11-08 11:23:10 +00:00
Kacper f8be508301 Merge pull request #12397 from Solenopsisbot/perf/creature-path-writes
perf: move creature path writes without copying
2026-11-08 12:23:06 +01:00
Kacper 8b06e4ba1e Merge pull request #12386 from TheMrZZ/fix/stand-command-hurt
/damage breaks, lights or burns an armor stand as its hurt does
2026-11-08 12:22:59 +01:00
Kacper 197a6ef7dc Merge pull request #12381 from svetch/discovery-texture-uris
parity: take valid texture URIs from the services discovery document
2026-11-08 12:22:52 +01:00
Kacper ba54ecff05 Merge pull request #12375 from TheMrZZ/fix/test-run-cross-dimension-coordinate-feedback
fix: send completed test coordinate feedback to its command source
2026-11-08 12:22:45 +01:00
MSedgeMC 44a27421ca port_map: the equipment shearing rows name shear_saddle
`Game::shear_pig_saddle` was renamed and generalized to `Game::shear_saddle`
when shears began taking the saddle off any saddleable mob. Its doc names
`Mob.attemptToShearEquipment` and `Mob.shearItem`; re-point the two rows.
2026-11-08 11:22:40 +00:00
Kacper eb8f8e38b5 Merge pull request #12374 from TheMrZZ/fix/h064-vibration-source-uuid
Save the source and projectile owner UUIDs of a vibration
2026-11-08 12:22:38 +01:00
Kacper d276c60075 Merge pull request #12371 from glitchsnake/fix/11382-vehicle-entity-random
game: use entity random for boat, minecart and item frame operations (fixes #11382)
2026-11-08 12:22:31 +01:00
Kacper ee91d5907a Merge pull request #12366 from vnsmoreira/packs/polytone-environment
Polytone: biome and dimension modifiers as attribute layers
2026-11-08 12:22:24 +01:00
Kacper 1cec3b3751 Merge pull request #12365 from vnsmoreira/packs/polytone-overlays
Polytone: pack configs and conditional overlays
2026-11-08 12:22:17 +01:00
MSedgeMC 04ead01cb4 port_map: the infested hurt rows name spawn_infested and player_infested
`Game::infested_hurt` was split. `Game::spawn_infested` spawns a silverfish
per drawn velocity at half the bearer's height with the hurt sound, and
`Game::player_infested` is the player's roll and spawn. Re-point the two
rows that still name the old function.
2026-11-08 11:22:13 +00:00
Kacper 447341b6a4 Merge pull request #12364 from vnsmoreira/attributes/integration
Environment: sample the sky through the vanilla attribute stack
2026-11-08 12:22:10 +01:00
Kacper 2bb7df1bf9 Merge pull request #12352 from k3yh/potion-contents-names
A potion or tipped arrow is named by its contents
2026-11-08 12:22:02 +01:00
Kacper 13c56dd065 Merge pull request #12345 from Vaspyyy/title-icon-tooltips
Show the title screen icon buttons' tooltips while hovered like vanilla
2026-11-08 12:21:55 +01:00
Kacper cceab3b2b5 Merge pull request #12344 from Vaspyyy/shulker-bullet-parity
Fire shulker bullets from the box centre and turn them at standable blocks
2026-11-08 12:21:48 +01:00
Kacper 6e00e1bbf1 Merge pull request #12341 from Vaspyyy/vehicle-container-scatter
A destroyed chest boat or container minecart scatters its contents like vanilla
2026-11-08 12:21:40 +01:00
Kacper fc14c6fc28 Merge pull request #12336 from k3yh/loot-give-full-inventory
/loot give to a full inventory throws nothing out
2026-11-08 12:21:33 +01:00
Kacper d03fbd593a Merge pull request #12260 from Akirabane/fix/9335-passengers
Riders turn like vanilla's: vehicle body only where vanilla copies it
2026-11-08 12:21:26 +01:00
Kacper 0335218910 Merge pull request #11682 from TheMrZZ/fix/loot-mine-location-block-state
Evaluate loaded block-state offsets in loot-mine location checks
2026-11-08 12:21:19 +01:00
Kacper 3b76fcb951 Merge pull request #11642 from TheMrZZ/fix/inline-armor-trim-holders
Preserve fully inline armor-trim holders in item stacks
2026-11-08 12:21:11 +01:00
masa-masa-0314andZCode a7f89f1c62 Realms: the terms screen before a new account's first join
A new account's first join gets error 6002; GetServerDetailsTask shows
RealmsTermsScreen instead of the generic error. Port it:

- The title, the first sentence and the underlined terms link at
  row(5), with the hover colours and the link's measured hit box
  (extractRenderState's onLink). A click opens
  CommonLinks.REALMS_TERMS (https://aka.ms/MinecraftRealmsTerms).
- Agree (width/4) and Disagree (width/2 + 4), each width/4 - 2 wide at
  row(12). Agree posts mco/tos/agreed (RealmsClient.agreeToTos) on a
  worker, then runs GetServerDetailsTask again from the same screen;
  a failure logs and stays. Disagree and Escape return to the screen
  that started the join (GetServerDetailsTask.lastScreen, now carried
  by Connecting).

Co-Authored-By: ZCode <[email protected]>
2026-11-08 19:21:06 +08:00
Kacper 73b2d7434f Merge pull request #11588 from TheMrZZ/fix/named-predicate-context
Resolve named predicate references through the Game context
2026-11-08 12:21:04 +01:00
Kacper e19018da1c Merge pull request #11564 from TheMrZZ/fix/h157-player-save-projection
fix: project live player motion fields into saved data
2026-11-08 12:20:57 +01:00
Kacper 5a94e4c188 Merge pull request #11488 from TheMrZZ/fix/h181-noai-trader-llama-delay
Advance eligible TraderLlama despawn delay with NoAI
2026-11-08 12:20:51 +01:00
Kacper 6db9351111 Merge pull request #11301 from TheMrZZ/fix/h052-vehicle-container-save
Respect class-owned container fields in vehicle saves
2026-11-08 12:20:43 +01:00
martipopsandClaude Opus 5.5 4dd40471f8 Make the letter mode an opaque Glyphs value
Review follow-up. Glyph::of and Glyph::menu took any GlyphStyle, so a
caller could pass the raw setting and lose a Nintendo pad's letters,
and NintendoLetters leaked into the setting's enum.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:13:40 -05:00
Horizon agentandClaude Opus 5.5 7d2f225c85 pack uniforms: drive the half-life test with a scripted clock
Backdating u.last measured the gap with the wall clock; a few ms of runner delay
moved wetness past the 1e-3 tolerance (the flake #12354 fixed in the Iris test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:12:58 +01:00
Horizon agentandClaude Opus 5.5 f15f153836 remote chunks: drop block-entity attachments after the column's block entities load
Moving the retain before the block entities loaded dropped every attachment of a
resent column, and skipping it for new columns kept those of forgotten ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:12:19 +01:00
JoansitohandClaude Sonnet 5.5 c9f62a1572 Trident: a stuck trident falls when its block is destroyed
AbstractArrow.tick: if lastState != blockState && shouldFall() startFalling().

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 12:11:48 +01:00
LokiandAntigravity 067a6a1db1 game: skip in-tree duplicate UUIDs in summoned passenger trees (fixes #11807)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 20:11:37 +09:00
Horizon agentandClaude Opus 5.5 7ba6ef0912 studio screen: rustfmt the control replies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 12:11:35 +01:00
Kacpherek 34c91f0c34 Hold a server-patched consumable or blocks_attacks use on a server
A server that adds blocks_attacks and a long consumable to a sword (1.8
style sword blocking, #11817) sends both components in the stack patch,
which the client reads past. The client never started the use itself and
remote_tick_using dropped the use the server's living flags started on
the next tick, so the right click kept restarting the use and the input
slowdown flickered (GrimAC NoSlow).

Read the added components from the wire patch, as Item.use does from the
stack's components: Consumable.startConsuming / startUsingItem start the
use, Item.getUseDuration takes the consumable's consumeTicks (else 72000
for blocks_attacks), and the use is held until the button lets go or the
server stops it, as LivingEntity.updateUsingItem never completes a use on
the client (LocalPlayer.onSyncedDataUpdated keeps it running).
2026-11-08 12:10:32 +01:00
Evan-lhmandClaude Opus 5.5 0f36e33ccf Tests: read SHA256SUMS names with Git Bash's binary-mode * (#12454)
On Windows, Git Bash's sha256sum writes `<digest> *<name>`, so the
optional release assets test kept the `*` in every name and failed for
all four masks. Both forms are valid sha256sum output; accept them.

Fixes #12454

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

Fixes #12403

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:49:07 +01:00
apeirs 94e2918668 DX12: keep entity_color's discards one call below fs_entity, so FXC compiles it
Since #12282, fs_entity called entity_written_color, which called entity_color.
FXC (every DX12 game: Horizon ships no dxcompiler.dll) fails on that with
"internal error: argument pulled into unrelated predicate" while creating the
first entity pipeline, and the game crashes at start. entity_written_color now
takes entity_color's result instead of calling it.
2026-11-08 11:46:09 +01:00
m_uvexandPluto ecc9a21e18 packaging: build Horizon launcher from source in flake
Addresses review feedback from #11200:
- Builds horizon-launcher from repository source via rustPlatform.buildRustPackage
  instead of fetching a pinned binary tarball by hash.
- Native build support for both x86_64-linux and aarch64-linux.
- Added essential POSIX tools to devShells.default for pr_ready test suite.

Co-Authored-By: Pluto <[email protected]>
2026-11-08 13:41:26 +03:00
TaquitoSuabe 00127a51c7 remote_chunks: decode chunk packets concurrently with strict in-order retirement
- Parallelize chunk decompression across a bounded worker pool while enforcing sequential retirement (FIFO arrival order) via monotonic sequence ordering.
- Filter resident neighbors and batch section dirtying with column masks.
- Skip attachment and block entity cleanup scans for fresh (non-resident) chunks.
- Enable TCP_QUICKACK on Linux streams and reset empty reader buffers to prevent drift.
- Elevate network worker thread QoS on supported platforms.
2026-11-08 03:40:59 -07:00
Ludovic Marie 92fd45976d Merge pull request #12393 from Solenopsisbot/perf/worldgen-decoration-biomes
🤖 Agent: Same fixed [u64; 128] bitset with spill as the generator's (#12346): spilled words keep index 128 + offset, so the decorated chunk's biomes stay ascending, and the exact reserve avoids regrowth.
2026-11-08 11:39:18 +01:00
SolenopsisbotandCodex 3bf71f9926 perf: reuse obstacle grid query scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:33:40 +11:00
Web-mc1 df92a7cbc5 Open and close Screen::Studio from horizon via studio screen, with test 2026-11-08 23:29:06 +13:00
Web-mc1 b7215560ba Add Screen::Studio so Studio windows don't borrow the chat slot 2026-11-08 23:28:48 +13:00
Ludovic Marie 79bd301973 Merge pull request #12388 from kacpherek/fix/remote-push-tick-order
🤖 Agent: ClientLevel.tickEntities ticks in add order: after login (and after a respawn re-adds the player last) a mob added later pushes from where both ended the tick, so the push reaches the player's next move, which is what GrimAC's Simulation check expects.
2026-11-08 11:27:31 +01:00
SolenopsisbotandCodex ec73a1af84 perf: move creature path writes without copying
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:24:37 +11:00
Horizon agentandClaude Opus 5.5 bbbbfa6dd2 Parity: unused_feature_startup runs by name only
It needs --kind, --output and --binary, which an automatic parity run
doesn't pass (#12372).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 11:23:29 +01:00
Ludovic Marie 33facd006a Merge pull request #12361 from adiraj66132/security/audit-hardening
🤖 Agent: client_mod_command is the chat-side mod hook; the SMP and other server mods use native_perform_command, so gating guests (lan_acting) out of it removes a level-0 path without touching server commands. The 4 GiB zip budget is per opened archive, and every Zip lives for one load.
2026-11-08 11:22:13 +01:00
Ludovic Marie 0ad6f93762 Merge pull request #12280 from NikitaMGrimm/portal-ticket-persistence
🤖 Agent: PORTAL tickets are saved in chunk_tickets.dat with level and ticks left, reload on restart (prepareLevels), and do not expire while their chunk is still loading (canTicketExpire). I fixed the main merge: #12232's ticket fast path compared (chunk, level) pairs with this PR's PortalTicket (LAND-STOP: E0308); it compares (ticket.chunk, ticket.level) now.
2026-11-08 11:22:09 +01:00
SolenopsisbotandCodex 1467568676 perf: reuse decorated biome discovery scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:18:11 +11:00
masa-masa-0314andZCode da428abd4d Friends: use ? for the presence request's missing session and endpoint
Co-Authored-By: ZCode <[email protected]>
2026-11-08 18:15:47 +08:00
Ludovic Marie 02deae8774 Merge pull request #12377 from Solenopsisbot/perf/station-defaults-reuse
🤖 Agent: Smithing results and banner defaults now share the shared() snapshot StationData loads once; the standalone loaders keep reading their own.
2026-11-08 11:14:56 +01:00
Ludovic Marie 90267f9863 Merge pull request #12359 from Solenopsisbot/perf/items-enchant-defaults
🤖 Agent: item_defaults::shared is the cached item_defaults::read, so the enchantment properties read the same snapshot Items already decoded; the standalone loader still reads its own.
2026-11-08 11:12:04 +01:00
martipopsandClaude Opus 5.5 88dd9e8d1e Document the HUD kit's Room and Plate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:02 -05:00
martipopsandClaude Opus 5.5 8ce5ba0e52 Keep the controller notice and the splitscreen join strip off the boss bars (#12342)
Game::hud_room gathers the boss bars the HUD shows each frame; hud empties the room every
frame first. The notice and the join strip go below the bars when they would cover them,
and stay at y 18 otherwise.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:02 -05:00
martipopsandClaude Opus 5.5 9fb2965c3a HUD kit: Room keeps Horizon's overlays below the boss bars, and Plate
hud_kit::room holds a view's keep-outs, today the boss bars, and tells a centred overlay
where it goes: where it wanted unless the bars reach past it. boss_bars_bottom follows
BossHealthOverlay's layout. hud_kit::plate draws the notice's box.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:09:01 -05:00
Kacpherek 5c616363b1 Push the local player by mobs in ClientLevel's tick order
Vanilla's ClientLevel ticks entities in the order they were added: after
login the player comes first, so a mob added later pushes it from where
both ended the tick, and the push reaches the player's next move. After a
respawn the new player is added after what the level holds, so those
entities tick before it. Horizon applied every mob's push before the
player's move, from where the mobs had just moved: walking into a mob
the server is pushing away tripped anticheats (GrimAC Simulation 0.02-0.04
on every push).
2026-11-08 11:01:33 +01:00
SolenopsisbotandCodex 58140be02b fix: satisfy station defaults lint
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:00:32 +11:00
SolenopsisbotandCodex 4fab87e3ce perf: reuse station item defaults
Co-Authored-By: Codex <[email protected]>
2026-11-08 21:00:32 +11:00
Ludovic Marie d61235cb39 Merge pull request #12370 from Solenopsisbot/perf/stream-decoration-scratch
🤖 Agent: The 9-key array and Box<[Proto; 9]> build the same region in the same order; the removed try_into fallback could never fail (always 9 keys).
2026-11-08 10:59:28 +01:00
Ludovic Marie 38d670c0ac Merge pull request #12379 from Solenopsisbot/perf/block-tick-scratch
🤖 Agent: The spawner and trial-spawner lists keep their tick order and are taken around the client effects, so a client tick can't see a half-filled list.
2026-11-08 10:59:24 +01:00
Horizon agentandClaude Opus 5.5 e5cad94537 Zip budget test: pass the budget by shared reference
clippy (-D warnings): read_within takes &AtomicU64, so &mut was needless.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:56:41 +01:00
Ludovic Marie 1c17df10b4 Merge pull request #12367 from Solenopsisbot/perf/remote-tick-scratch
🤖 Agent: Indexing the retained buffers by mob index is safe: nothing between the fluid loop and the rain loop adds or removes remote mobs, and an early return only loses the warm capacity.
2026-11-08 10:55:57 +01:00
Ludovic Marie d1b5000930 Merge pull request #12360 from Solenopsisbot/perf/outline-scratch
🤖 Agent: The outline strokes keep their order (black then bright) and the custom PvP colour; both scratch vectors are bounded by MAX_OUTLINE_EDGES, so the retained capacity stays small.
2026-11-08 10:55:53 +01:00
Florian ErnstandClaude Opus 5.5 83d9347f4b Merge origin/main into fix/h259-unused-feature-startup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:55:15 +01:00
SolenopsisbotandCodex 0dab8b30f5 perf: reuse item defaults for enchantments
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:53:56 +11:00
masa-masa-0314andZCode 5740311a36 Friends: the service's empty answer reads as three empty lists
The live getFriends answer an account with no friends gets is
{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]};
the parser reads the three lists and ignores the flag. Recorded as a
regression case beside the populated answer.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 17:53:30 +08:00
Florian ErnstandClaude Opus 5.5 17f3a0ff81 /damage breaks, lights or burns an armor stand as its hurt does
ArmorStand.hurtServer returns false for an explosion, fire that ignites
armor stands and burning, after it has acted on the stand: a blast breaks
it (its equipment pops, then it is killed), in_fire lights a stand that
is not burning for 5 seconds, and on_fire burns 4 health off one with
more than 0.5. The command route had no armor stand arm, so /damage left
the stand as it was. It now runs those sources with the stand's guards
and still fails as vanilla's command does.

Part of #12380

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:53:20 +01:00
Horizon agentandClaude Opus 5.5 c0f9600544 Parity: run_coordinate_feedback runs by name only
It needs --binary, --work-dir and --reference-dir, which an automatic
parity run doesn't pass (#12372).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:50:57 +01:00
VaspyyyandClaude Opus 5.5 f1be49b7df Lid pushes follow vanilla's tick order, and boats reach an open lid
- BoxReach (the boats' and carts' mob pre-filter) is worked out from each
  mob's real box, so a shulker whose open lid grows its box sideways or
  below its position is still found: a boat runs into the lid and picks
  the shulker up, as in vanilla.
- ArmorStand::shove makes up for Horizon ticking stands before mobs: a
  stand that joined after its shulker gets vanilla's push-then-travel
  ground and speed, one that joined before gets Entity.move's own.
- /data merge keeps a mob's and a stand's join order (Entity.load changes
  the entity in place).
- A shulker a boat or a minecart picks up turns to AttachFace DOWN
  (Shulker.startRiding).
- The stand's box uses the float height 1.975F, canStayAt the shulker's
  scale.
- The harness adds wall, ceiling and squeezed lids, with the stand joined
  after and before its shulker, and the boat that runs into an open lid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:47:00 +01:00
VaspyyyandClaude Opus 5.5 a1ecb7f6de An opening shulker lid lifts the armor stand on it
Shulker.onPeekAmountChange moves every entity with physics in the lid's
path with Entity.move(MoverType.SHULKER), armor stands included, and
Shulker.makeBoundingBox grows the shell's box by the lid's physical peek.
Horizon pushed only the player and mobs and sized the box from the
dimensions, so a stand resting on a normal shulker sank into it the first
time the lid opened.

- The lid sends MobEvent::LidPush; ArmorStand::shove moves each stand with
  physics through the collision resolver. Mob::bounds_at gives a shulker
  its makeBoundingBox, scaled and grown by getPhysicalPeek in float.
- Shulker.tick's own part (keep a face, ease the lid) runs without AI
  too, and not for a shell that rides something (isPassenger).
- /data merge keeps a shulker's eased currentPeekAmount: Entity.load reads
  only the raw Peek.
- A stand leaves out the boxes of mobs riding it
  (isPassengerOfSameVehicle), so a stand carrying a /ride shulker rises.
- ObstacleGrid::near tests each box once when the query spans more cells
  than there are boxes, and a stand's loaded Motion drops a component past
  10 (Entity.load), so a huge Motion no longer stalls the server.
- A rider sits on a stand's float passenger point (1.975F widened).
- The harness adds a stand carrying a riding shulker and the lid opening
  and shutting under the first stand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:47:00 +01:00
VaspyyyandClaude Opus 5.5 6880103394 Armor stands land on and stop at shulkers and boats like vanilla
ArmorStand.travel moves with Entity.move, whose Entity.collide adds
EntityGetter.getEntityCollisions: the entities that canBeCollidedWith the
stand (a living shulker, a boat, a happy ghast held still) stop it as
blocks do. Horizon resolved a stand's move against blocks and the world
border only, so a stand dropped onto a shulker sank into it, one dropped
onto a boat fell through to the ground, and one pushed into a shulker
went through it (#11877).

The stand tick now gathers those boxes once, when the first stand
travels (the same set that stops a dropped item, primed TNT or a falling
block), and passes the ones meeting each stand's box stretched by its
movement into the move. It runs in the server's tick, so single player,
LAN hosts and dedicated servers all get it.

The new stand_entity_collision harness drops stands onto a NoAI shulker,
a boat and the stone, slides one into a shulker's side, and compares
their Pos, Motion, OnGround and fall_distance with vanilla's tick by
tick. Without the fix 27 of its 198 records differ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:47:00 +01:00
RINNECODERandClaude Opus 5.5 4d5de18e40 Riding: the driven mount ticks before its rider (ClientLevel.tickNonPassenger) on the input the rider's last tick applied (xxa/zza through modifyInput); it moved a tick ahead of the server's view (GrimAC Simulation on a horse, 27 flags)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
RINNECODERandClaude Opus 5.5 340d4d8b22 Mounts: LivingEntity.getFlyingSpeed, a player-steered mount moves through the air at a tenth of its speed, not 0.02 (a horse's jump)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
RINNECODERandClaude Opus 5.5 d3c183549e shader packs: honour wetnessHalflife, drynessHalflife, eyeBrightnessHalflife, centerDepthHalflife
Packs set these const directives (ShaderDoc, OptiFine's shaders.txt);
Horizon always used the defaults (600/200/10/1 ticks). IterationT sets
wetnessHalflife 200 and drynessHalflife 50, so under Horizon its sky
stayed sunny for a minute after rain started (wetness rose 3x too
slowly) and stayed wet 4x too long after it stopped. The pack log now
names the half-lives a pack sets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:41 -06:00
RINNECODERandClaude Opus 5.5 f711009645 Far tiles: an FxHashMap, not SipHash, for the renderer's tiles
draw_frame looks every visible tile up 3-4 times a frame and upload_lods walks them all; with
lodDistance 256 (~8.5k tiles) std's SipHash was 8-10% of the main thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
RINNECODERandClaude Opus 5.5 9cde3d1f9a Far tiles: keep mesh jobs' samples as tiles change, not gathered per batch
`Lod::dispatch_meshes` collected every active tile's ready samples into a new
map for each batch of mesh jobs (thousands of entries, rehashed as it grew):
5-10% of the main thread in flight. The map is now kept up to date where a
tile turns active or inactive and where its samples change
(`Lod::sync_sample`), in shards of 8 by 8 tiles behind `Arc`s: a batch copies
the shards' handles, and a change copies only its shard while a job still
reads it. Debug builds check it against the full gather on every dispatch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
RINNECODERandClaude Opus 5.5 c7c143cbf1 Mod content: copy plain files into the overlay instead of reading and rewriting them
Building a mod set's resource root read every content file and wrote it back out; with the
14 Popular Mods (Chipped's 10k textures) that was about two thirds of a 37 s first start
(sample: open/read/write on the main thread). Files the build does not parse are now copied
with fs::copy (a clone on APFS), checked as reading them was (name, 16 MiB limit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
RINNECODERandClaude Opus 5.5 cd313d031f Far tiles: box a tile's meshes so the per-frame tile walk stays small
draw_frame tests every far tile against the frustum each frame. A Tile held two SectionGpu
inline (~0.5 KB), so with lodDistance 256 (~8.5k tiles) the walk read ~9 MB a frame and was
the main thread's top cost (17-23% in sample). Boxed, a tile is ~90 bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:46:40 -06:00
TheMrZZandCodex 9bf65b95b4 Validate unused fill-layer entries before world readiness
Partial H259; retain other registry validation as open scope.

Co-Authored-By: Codex <[email protected]>
2026-11-08 10:46:38 +01:00
RINNECODERandClaude Opus 5.5 4efbd5ea91 Port pack: 1.21.1 weird_scaled_sampler densities upgrade to 26.3's interval_select of scaled noises
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:56 -06:00
RINNECODERandClaude Opus 5.5 09d0f6eb7c Port pack: 1.21.1 nether_forest_vegetation upgrades to 26.3's spread simple_block
As 26.3 wrote crimson and warped forest vegetation: a simple block on nylium,
tried spread_width² times at a trapezoid offset, where there is air. Terralith's
last feature left as it was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:56 -06:00
RINNECODERandClaude Opus 5.5 ebec6fdd1c Port pack: 1.21.1 random_patch and flower features upgrade to 26.3's counted, offset placement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:56 -06:00
RINNECODERandClaude Opus 5.5 dabd3c04cc Port pack: an item that places a block of another name (seeds) binds that block
A BlockItem whose block has another name (Farmer's Delight's cabbage seeds
place `cabbages`, Supplementaries' way signs `way_sign_wall`) was left out
as "a block item without a block": 35 items over the 8 mods. It is now
carried with `place_block` naming its block when that block is carried
(vanilla's or one of the mod's declared ones), and left out as "its block
is left out" otherwise. That check looked the item's own name up in the
left-out blocks instead of its block's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 cec040fef1 Port pack: food items carry over, as 26.3's food, consumable and use_remainder
Food was left out ("no food template yet"): 82 items over the eight mods tried, most of Farmer's
Delight. 1.21.1's minecraft:food held what 26.3 splits into food (numbers), consumable (eating
time, on_consume_effects: one apply_effects per effect with its own chance) and use_remainder
(the bowl), as 26.3's golden apple and mushroom stew have them. NeoForge's cures are dropped, and an
effect of the mod's own is left out and reported (the game would refuse the item).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 a2d300a03f Port pack: carried models on 1.21.1's farmland template use 26.3's indented cube
26.3 has no `block/template_farmland`: its farmland model is
`template_cube_bottom_top_indented` with `bottom`, `side` and `top`, where
1.21.1's template read `dirt` for the bottom and sides. A carried model on
the old template (Supplementaries' raked gravel) now names the new one with
its dirt as side and bottom, instead of drawing the missing model.

`minecraft:block/rope` (Supplementaries' bunting particle) is in neither
1.21.1 nor 26.3: not a rename, left as is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 b2e0010655 Port pack: recipes with NeoForge conditions are upgraded when NeoForge loaded them
A vanilla-type recipe with neoforge:conditions was kept as 1.21.1 wrote it, and 26.3 refused its
old ingredients: Quark's 1,466, most of Supplementaries'. The oracle's recipes are the ones whose
conditions passed (a mod's own conditions, such as config flags, are only known there): those
lose the conditions and are upgraded; the others go outside, as NeoForge left them out. Without
the oracle, NeoForge's own conditions are read against the mods ported along.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
RINNECODERandClaude Opus 5.5 d0815a9d0b Port pack: tags alone claim no namespace (Farmer's Delight and Supplementaries both added to create's tags and clashed)
A mod's tag additions under another mod's namespace (data/create/tags/...) made it claim that namespace
when its own files named it; two such mods in one pack then refused to load together (Popular Mods:
Supplementaries off). Tags alone now stay outside the carry, as other compat files do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:55 -06:00
Florian Ernst 217affdc9e Merge origin/main into fix/h064-vibration-source-uuid
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/sculk.rs
2026-11-08 10:44:34 +01:00
Florian ErnstandClaude Opus 5.5 5674b63b57 Measure /damage side effects on armor stands (test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:42:38 +01:00
Florian Ernst 255d45aa56 Merge origin/main into fix/test-run-cross-dimension-coordinate-feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/test_runs.rs
#	crates/horizon-client/src/game/game_tests.rs
2026-11-08 10:42:37 +01:00
SolenopsisbotandCodex 5f599617e6 perf: reuse block entity tick scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:37:03 +11:00
Ludovic Marie 668555dd33 Merge pull request #12376 from horizon-rust/ci/command-exec-dead-flag
🤖 Agent: Main fix: three lines a union merge brought back read a flag #12242 removed, so every command_exec-driven harness stopped with AttributeError (80 of 173 on #12344's run). Nothing else in the driver uses an undefined args attribute; the corpus builds its 161 groups.
2026-11-08 10:36:50 +01:00
Horizon agentandClaude Opus 5.5 db09adeca9 command_exec harness: drop the seed-sync gaps block a merge brought back
#12242 removed --seed-sync-gaps-diagnostic and the block that read it.
The union merge that landed TheMrZZ's series (#12160) kept the block from
an older base without the flag, so every command_exec run (and every
harness that drives it) stopped at
AttributeError: 'Namespace' object has no attribute 'seed_sync_gaps_diagnostic'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:35:08 +01:00
Román Benjámin e46c586cec parity: take valid texture URIs from the services discovery document 2026-11-08 10:34:11 +01:00
Ludovic Marie 1bc5012f4f Merge pull request #12372 from horizon-rust/ci/named-only-capture-harnesses
🤖 Agent: Tooling only: 17 capture-preserving harnesses that fail at startup under parity.py (no --work-dir / a capture folder that must be new) move to named_only; they still run by name and in the nightly --all audit.
2026-11-08 10:33:02 +01:00
Ludovic Marie 7976203343 Merge pull request #12241 from MSedgeMC/fix/tame-panic-damage
🤖 Agent: A cat or parrot panics from any #panic_causes damage and only a wolf keeps #panic_environmental_causes, as TamableAnimalPanicGoal is built per species; the old fire_ticks gate is gone. I made its test return early without extracted mob data (CI compared None with Some(true)).
2026-11-08 10:27:28 +01:00
TheMrZZandCodex gpt-6.1-sol aaf53aa0bd fix: retain test-run source for completion coordinate feedback
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 10:21:52 +01:00
AkirabaneandClaude Opus 5.5 6ff8c950e4 Merge origin/main into fix/9335-passengers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:19:33 +01:00
Ludovic Marie f8f7d2574b Merge pull request #12231 from NikitaMGrimm/tnt-size-eye
🤖 Agent: Primed TNT's box is 0.98F and its eye height 0.15F, widened from float as EntityType.TNT's sized() gives them; the command_exec case reads them back. Merged main (command_exec corpus group order).
2026-11-08 10:16:27 +01:00
Horizon agentandClaude Opus 5.5 0d27a2a38f Parity: keep the capture-preserving harnesses out of automatic runs
Seventeen harnesses need --work-dir (a fresh capture folder) and
--reference-dir, or assert that their fixed capture folder is new.
parity.py passes neither, so whenever --changed selects them they fail
at startup, whatever the pull request changed: 14 of the 18 failures on
#12336's parity run were these. List them under named_only: they still
run when named and in the nightly --all audit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:16:25 +01:00
SolenopsisbotandCodex 728141ad4f fix: retain remote tick vectors after iteration
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:13:42 +11:00
Loki 329aae2ae3 Merge remote-tracking branch 'origin/main' into fix/11382-vehicle-entity-random 2026-11-08 18:09:56 +09:00
LokiandAntigravity db270fecca game: use entity random for boat, minecart and item frame operations (fixes #11382)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 18:09:48 +09:00
SolenopsisbotandCodex 4bf7247bcb perf: reuse decoration scheduling scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 20:07:21 +11:00
adiraj66132 92fc757a73 security: harden guest commands, the JSON parser and zip totals
- A LAN/dedicated guest's command no longer runs the host's client-side
  native mod commands (they ran before any permission check; client mod
  commands belong to the local player only).
- The server-files JSON parser refuses documents nested past 128 levels
  instead of recursing unbounded.
- The zip reader caps the total inflated bytes across entries (4 GiB per
  archive), not just per entry.

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

# Conflicts:
#	crates/horizon-client-render/src/zip.rs
2026-11-08 14:37:10 +05:30
Florian ErnstandClaude Opus 5.5 8a964bbb79 Drop a discarded trader llama from the mob list in the same tick
TraderLlama.maybeDespawn discards an eligible llama in its aiStep when its
DespawnDelay runs out, so it leaves the level's entity list in that tick.
Horizon's countdown set m.removed, but ai::tick still returned true, so
the creature tick kept the llama in game.mobs until the next tick. During
that extra tick the next mob snapshot and the native leave bookkeeping
still saw it. Commands, the tracker and collisions already skip removed
mobs, so commands showed no difference.

The final return of ai::tick now reports a trader llama that its own tick
removed, and the creature tick drops it in that tick, as it does for a
despawned mob. The rest of the llama's tick still runs, and other species
are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:06:51 +01:00
masa-masa-0314andZCode 8a84813b69 Friends: the account's friends service and its worker
Port the service behind vanilla 26.3's friends list, without its screens:

- authlib's FriendsService over the discovery document: getFriends,
  updateFriends, updateAttributes and updatePresence with the account's
  bearer token, If-None-Match/ETag caches, Retry-After poll intervals and
  the ten-second read cooldown (MinecraftServicesFriendsService).
- RemoteFriendListUpdateHandler's worker and state: one read a minute
  (five in the background), a forced read after a successful action, the
  result-code-to-state mapping and the cached lists.
- PlayerSocialManager's flags and accessors, with no screen using them.
- PresenceHandler: the sharePresence option picks the public status, the
  client supplies getPresenceStatus each tick, and closing posts OFFLINE.

Minecraft's constructor decides whether the worker starts from the
account's friendsPreferences (UserFlag.FRIENDS_ENABLED /
ACCEPT_FRIEND_INVITES); title.poll_account now does the same after its
getAttributes answer. The overlay, the Online options switches and the
title/pause icon changes stay for later parts, so no player-visible UI
changes here. A joined Realm is carried to the presence status through
ServerData.isRealm's equivalent (Action::Connect.realm).

No new harness: the list and presence parsers, the state mapping, the
sharePresence levels and Retry-After are unit-tested.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 17:01:47 +08:00
Florian ErnstandClaude Opus 5.5 6fefa6ec54 Save the source and projectile owner UUIDs of a vibration
VibrationInfo keeps the UUIDs of its source entity and of a projectile's
owner, and VibrationInfo.CODEC saves them as source and projectile_owner.
The sculk listener now takes them from the save layer's UUID of the
mob the event names, writes them, and reads them back. Loaded vibrations
keep no live entity; a player source stays unnamed.

Fixes #12186

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 10:01:27 +01:00
Florian Ernst 362e77ba6c Merge origin/main into fix/loot-mine-location-block-state
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
#	crates/horizon-items/src/items.rs
2026-11-08 09:59:18 +01:00
SolenopsisbotandCodex 65f1235daf perf: reuse remote entity tick scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:56:39 +11:00
Florian Ernst cdfe986e8c Merge origin/main into fix/h052-vehicle-container-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/data.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 09:56:29 +01:00
Florian Ernst b56f598a71 Merge origin/main into fix/h181-noai-trader-llama-delay
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	tools/command_exec_corpus.py
2026-11-08 09:56:20 +01:00
Florian Ernst b778599d5f Merge origin/main into fix/h157-player-save-projection
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 09:56:11 +01:00
Florian Ernst 518adb688e Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 09:56:02 +01:00
Florian Ernst 1699b82741 Merge origin/main into fix/named-predicate-context
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/advancements.rs
#	crates/horizon-client/src/game/functions.rs
2026-11-08 09:55:53 +01:00
Florian Ernst 79723cb13e Merge origin/main into fix/inline-armor-trim-holders
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/item_sync.rs
#	crates/horizon-client/src/lan/guest.rs
#	crates/horizon-protocol/src/item.rs
2026-11-08 09:55:26 +01:00
Horizon agent ee61f0ea71 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 09:55:20 +01:00
Florian ErnstandClaude Opus 5.5 84e22207fb Measure saved vibration source UUIDs (H064 test checkpoint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:48:41 +01:00
SolenopsisbotandCodex 83de5c24d6 perf: reuse selection outline scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:48:00 +11:00
Ludovic Marie 2d22e6a7e3 Merge pull request #12311 from voidsipp/fix/round6-12063
🤖 Agent: Server configuration dialogs keep the panorama behind them (DialogScreen inherits Screen.extractBackground) instead of the flat 20/20/20 fill.
2026-11-08 09:43:43 +01:00
Ludovic Marie e1315b7e55 Merge pull request #12357 from umbx-dot/launcher/mod-version-picker
🤖 Agent: Includes #12355. A version picked in the Versions tab is kept (chosen-versions.json) until the player installs the recommended one, so the pre-build swap and the automatic updates no longer undo a deliberate pick.
2026-11-08 09:43:38 +01:00
dd8daeb619 HDR float frame: remove the mark resolve and lane state
Float writers already store colour and coverage. Remove the unused mark
resolve pipeline and shader, and keep the world scratch and parameters for
the tone curve and HUD composite that still use them.

Choose no-pack HDR gain explicitly without storing a frame lane. Keep the
uniform ABI and existing light arithmetic. TAA tracks baked gain and its
settings, preserves compatibility history when gain is off, and rejects
history when it resumes after a shader pack.

Use a non-float legacy oracle for writer tests and restore exact float,
OIT and visibility-buffer sessions in the identity harness. Preserve the
compatibility mark paths and the non-layered hand brightness sentinel.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:37:53 +01:00
Ludovic Marie 6f856b2c0a Merge pull request #12343 from Solenopsisbot/perf/lz4-decode-scratch-v2
🤖 Agent: Each stream now starts from 21 zeroed bytes (clear + resize, pushed): a bare resize left the previous stream's bytes past a short block, which the decompressor may read, so corrupt-stream checks could differ from a fresh buffer.
2026-11-08 09:37:00 +01:00
Ludovic Marie b0ad9abd0c Merge pull request #12333 from Solenopsisbot/perf/pack-page-scratch
🤖 Agent: The retained page list is cleared after the shader pack's post passes (pushed): it keeps its capacity, but no longer holds clones of arena page buffers until the next pack frame, which would keep freed pages alive on the GPU once the pack is off.
2026-11-08 09:36:57 +01:00
Ludovic Marie c5c53d4c6c Merge pull request #12354 from horizon-rust/ci/pack-uniforms-scripted-clock
🤖 Agent: Test-only: the smoothed-uniforms test steps PackInputs::frame_timing instead of backdating the wall clock, so a slow runner can no longer push centerDepthSmooth past its 1e-3 tolerance (failed verify on #12241 and #12311).
2026-11-08 09:33:49 +01:00
Ludovic Marie 3bca17f46a Merge pull request #12160 from TheMrZZ/fix/h248-hurt-cooldown-incoming
🤖 Agent: This head holds all 13 layers of TheMrZZ's series (12010 already merged; 11732 … 12133 below it), so landing it merges the rest. The top layer's own point: inside the hurt cooldown a hit is compared with `lastHurt` by its incoming amount, before armor and Resistance, and only the difference goes on to `actuallyHurt`, as `LivingEntity.hurtServer` does.
2026-11-08 09:26:53 +01:00
Ludovic Marie 7e306e4b25 Merge pull request #12337 from NikitaMGrimm/hdr-marks-b
🤖 Agent: Stacked on #12330: landing it lands the chain. Only the float frame (HORIZON_HDR_FRAME=float) changes its alpha; 8- and 16-bit frames keep their pipelines and marks.
2026-11-08 09:26:49 +01:00
Ludovic Marie 26d73e3f28 Merge pull request #12346 from Solenopsisbot/perf/worldgen-biome-bitset
🤖 Agent: A spilled word keeps its index (128 + spill offset) in the chained iteration, so the ids stay ascending as with the Vec bitset; 1 KB on the stack per generated chunk.
2026-11-08 09:26:46 +01:00
Ludovic Marie 929cb85c85 Merge pull request #12340 from Solenopsisbot/perf/worldgen-empty-blender
🤖 Agent: Blender has no interior mutability (two OpenMaps and an Option, read only), so one shared empty Arc is safe for every chunk without old-world data, and non-empty blenders stay per chunk.
2026-11-08 09:26:42 +01:00
Ludovic Marie e3966b586a Merge pull request #12338 from Solenopsisbot/perf/game-cpu-fanout
🤖 Agent: The ten handoff loaders share only the resource root and the parsed entity document, both immutable; a panic in a worker is resumed on the caller and Creative's error keeps its message, so startup fails the same way. No wall-clock number yet: the gain is the slowest loader instead of the sum.
2026-11-08 09:26:39 +01:00
umbx-dotandClaude Opus 5.5 7a1fe7f702 launcher: a native mod's Versions tab says which version fits, and picks one
A registry native mod's Versions tab lists each version's date and how it
fits the Horizon played (the fit line the trust text already uses), marks
the one the launcher would pick Recommended, and has Install on every
version but the installed one.

A version the player picks there stays as it is: no automatic update, no
offered update and no swap before a build, until the player installs the
mod again or picks its recommended version (mod-browser/chosen-versions.json).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:23:15 +01:00
Ludovic Marie 443f9b0642 Merge pull request #12330 from NikitaMGrimm/hdr-marks-a
🤖 Agent: On the float frame the renderer is rebuilt when the HUD layering changes, not only the format; still behind HORIZON_HDR_FRAME=float.
2026-11-08 09:19:46 +01:00
Horizon agentandClaude Opus 5.5 79738505fb LZ4 scratch: zero the header bytes for each stream, as a new buffer had them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:19:36 +01:00
Horizon agentandClaude Opus 5.5 851bda5b5e Shader pack pages: drop the buffer clones after the frame, keep the capacity
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:19:04 +01:00
Horizon agent ce322efe53 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 09:17:08 +01:00
umbx-dotandClaude Opus 5.5 238bf31f77 launcher: automatic updates keep a native mod on a version that builds
The start's automatic update moved native mods to their newest version, which the build then swapped back when it doesn't build on the Horizon played. It now updates them only to the newest version that builds there, when that is known.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:16:44 +01:00
Horizon agentandClaude Opus 5.5 aa1f9c5dc9 Shader pack test: drive the smoothed uniforms with a scripted clock
smoothed_uniforms_take_iris_half_lives backdated the wall clock and let
update() measure the gap, so any delay between the two calls moved
centerDepthSmooth (0.1 s half-life) past its 1e-3 tolerance. It failed
verify on busy runners for unrelated PRs (#12241, #12311). Use
PackInputs::frame_timing, which update() already takes as the frame's
delta, so every step is exact.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:15:28 +01:00
k3yhandClaude Opus 5.5 b4ed614faa A potion or tipped arrow is named by its contents
PotionItem.getName and TippedArrowItem.getName name these items by
their contents (PotionContents.getName: the contents' custom_name, the
potion, else empty), over item_name. brewing::contents_name_key gives
that key, and the five functions that name a stack take it after the
custom_name component and before item_name.

New command corpus group: potion contents names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:13:32 -05:00
SolenopsisbotandCodex e3082a0281 fix: satisfy LZ4 clippy
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:13:15 +11:00
Horizon agent 013fb324e6 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 09:08:18 +01:00
Horizon agentandClaude Opus 5.5 0a871808fa Ticket fast path: compare the portal tickets with their level, as the sources store them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:08:18 +01:00
Vinícius MoreiraandClaude Opus 5.5 0e6d23f59b Polytone: biome and dimension modifiers as attribute layers
Resource packs' Polytone biome and dimension modifiers now change the
environment attribute stack the frame draws (wiki "Environment
Attributes"): a dimension modifier's base group changes the dimension's
own entries, rain and thunder change the weather's, post_process adds a
layer after all others; a biome modifier's base group changes its target
biomes' entries (ids, #tags through the biome tags, patterns) and
post_process adds a biome layer after the weather. REMOVE drops an
entry; modifiers apply lowest priority first. Values that are scripting
expressions or colormaps are left for the next steps.

The stacks are built when packs load, only when a pack has modifiers, and
only the frame reads them: gameplay (sky darkening, the server's sun
angle) keeps vanilla's. Without such a pack nothing changes.

With such a pack on, its biome and dimension modifiers load and apply;
the environment_render and environment_system harnesses still match
vanilla.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:08:03 -03:00
Ludovic Marie 170cfb468c Merge pull request #12321 from voidsipp/fix/round6-12128
🤖 Agent: LAN join and respawn packets carry the world spawn and its rotation, not the acting player's personal spawn, and /setworldspawn now updates connected players in every dimension (SetWorldSpawnCommand.setSpawn, PlayerList.sendLevelInfo).
2026-11-08 09:07:49 +01:00
Vinícius MoreiraandClaude Opus 5.5 0d0b58a960 Polytone: pack configs and conditional overlays
Polytone packs keep most of their effects in pack overlays that a
polytone_condition turns on: a scripting expression over the pack's own
config entries (config('pack:fog') == 'ON', modOn('minecraft', range)),
or an object with require_config, require_mods and override. A pack's
config entries (assets/<namespace>/polytone/config_entries/*.json) are
now read at their default values, and an overlay with a condition is
active when it holds: an expression decides alone (packs pair it with
formats no version matches), an object only alongside the formats unless
it overrides them. Overlays without one work as before.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:07:42 -03:00
Vinícius MoreiraandClaude Opus 5.5 92e3012389 Environment: sample the sky through the vanilla attribute stack
Environment now builds the dimension's EnvironmentAttributeSystem as
addDefaultLayers does: the dimension type's attributes, a biome layer for
every attribute some biome sets, the timelines its tag lists (in order),
and the rain and thunder layers where Level.canHaveWeather. The frame
samples it with the camera's Gaussian biome mix (the probe's order) and
the biome at the camera, so every attribute blends and changes with the
weather the vanilla way, not only the sky and fog colours, and the
hand-written timeline sampling, biome colour blending and apply_weather
are gone. Biomes keep their attribute maps for the biome layer.

environment_render now passes with no tolerance except one rounding in
Sky's light level fraction (was 218 inexact cases), and covers cloud
height.

Part of #11486

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:07:42 -03:00
martipopsandClaude Opus 5.5 a407637998 Merge upstream/main into ui/pad-glyphs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:05:41 -05:00
SolenopsisbotandCodex 0427c41bde perf: keep biome id bitsets on the stack
Co-Authored-By: Codex <[email protected]>
2026-11-08 19:05:34 +11:00
umbx-dotandClaude Opus 5.5 54bedee730 launcher: use the native mod version that builds on your Horizon
Installing a registry native mod took its newest version, and a build
left out any mod made for another Horizon, though the registry often has
an older version made for the Horizon the player picked.

native_fit::best picks the newest version that builds on the Horizon
played, else the newest that moves the build to its own commit without
clashing. Installs and updates use it, and before each build every mod
turned on whose installed version doesn't build is swapped for that
version, with a line saying which version is used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 09:04:54 +01:00
martipopsandClaude Opus 5.5 56b95a98b9 Ask for every controller glyph by what its button does
A Nintendo pad going by its letters reads its A as the part that
confirms and jumps, but only the binds list and the on-screen keyboard
showed that A. Each caller had to apply the letters itself, and the
guide, declared buttons and notices didn't.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:04:40 -05:00
Kacper 23bd1b7627 Merge pull request #12318 from TheApple05/codex/parity-12181
Keep resource-pack parity paths intact with JSON headers
2026-11-08 09:00:21 +01:00
Kacper 920346fb43 Merge pull request #12010 from TheMrZZ/fix/h236-clone-dimensions
Resolve /clone's source and target dimensions
2026-11-08 09:00:13 +01:00
Kacper 632d6cd4f4 Merge pull request #11654 from MSedgeMC/fix/set-custom-model-data
A loot table's set_custom_model_data writes the item's model data
2026-11-08 09:00:01 +01:00
Kacper 98342f1bd0 Merge pull request #11476 from baytyone/xp-orbs-riding-merge-water
Experience orbs: pickup over the vehicle, a kept following player, merges before the move, eye-level water
2026-11-08 08:59:50 +01:00
Kacper d2cadb1c39 Merge pull request #11473 from MSedgeMC/fix/vindicator-johnny
A vindicator that was named Johnny stays Johnny
2026-11-08 08:59:39 +01:00
Kacper cab807c9d7 Merge pull request #11463 from MSedgeMC/fix/palette-pack
Chunk storage: pack a section's palette as vanilla's PalettedContainer.pack does
2026-11-08 08:59:32 +01:00
SolenopsisbotandCodex 70e0a37264 fix: satisfy pack page lint
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:56:55 +11:00
SolenopsisbotandCodex 58b2eaa55e fix: preserve LZ4 scratch stream length
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:51:32 +11:00
SolenopsisbotandCodex 104fe56b30 perf: reuse LZ4 decode scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:51:32 +11:00
SolenopsisbotandCodex 98d9f172bd perf: share empty worldgen blenders
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:43:32 +11:00
Ludovic Marie b49f32e4ab Merge pull request #12332 from martipops/settings/shell-choice-step
🤖 Agent: Left and Right step a focused choice back and on in the settings shell, as Shift-press and press do; other rows keep their keys.
2026-11-08 08:41:32 +01:00
Ludovic Marie 32613f5637 Merge pull request #12331 from Vaspyyy/biome-id-past-table
🤖 Agent: A biome id past the table after a proxy's server switch names plains instead of panicking (#12148: "the len is 67 but the index is 67"), and the reconfiguring client plays menu music as ServerReconfigScreen lets it.
2026-11-08 08:41:29 +01:00
Ludovic Marie 3f604c3028 Merge pull request #12329 from Solenopsisbot/perf/vegetation-scratch
🤖 Agent: Vegetation patches iterate their position sets directly instead of copying them, and multiface growth shuffles fixed arrays; the shuffle draws the same number of randoms in the same order.
2026-11-08 08:41:25 +01:00
Ludovic Marie ed4077ec1d Merge pull request #12328 from Solenopsisbot/perf/creature-vegetation-scratch
🤖 Agent: The creature tick's MobEvent buffer is kept between ticks and drained, returning its capacity even when a handler errors.
2026-11-08 08:41:22 +01:00
Ludovic Marie f9b605a934 Merge pull request #12327 from Solenopsisbot/perf/pack-resource-fanout
🤖 Agent: Pack names, clouds, the forcefield and custom sky load on four scoped threads; panics still propagate (resume_unwind) and the values are the same.
2026-11-08 08:41:18 +01:00
Ludovic Marie fbd34a0343 Merge pull request #12326 from J5Casey/braid/fishing-medium
🤖 Agent: The fishing_medium native hook lets a mod make a bobber float and fish in another block, with its own loot table and lure range; in water the vanilla path is untouched.
2026-11-08 08:41:14 +01:00
Ludovic Marie e9208d8d86 Merge pull request #12325 from Solenopsisbot/perf/displacement-frame-scratch
🤖 Agent: The displacement pass collects its nearby sections into a SmallVec sized to its 64-section cap and reuses its section table, removing the per-frame heap allocations; the GPU writes are unchanged.
2026-11-08 08:41:10 +01:00
Ludovic Marie e0081cb2ae Merge pull request #12324 from k3yh/loot-components-modelled
🤖 Agent: A loot set_components patch for a component Horizon models (potion_contents and the rest) is read into its field as a saved stack's is, so a rolled potion gives its effect; damage and custom_name keep their own paths.
2026-11-08 08:41:06 +01:00
Ludovic Marie 9990881f3a Merge pull request #12291 from masa-masa-0314/feat/realms-account-uuid
🤖 Agent: The single-player owner is the signed-in account's UUID and older worlds move their owner to it on open; I made the migration keep the offline player files as .dat_offline backups instead of deleting them.
2026-11-08 08:41:01 +01:00
k3yhandClaude Opus 5.5 24a3e509bd /loot give to a full inventory throws nothing out
The give branch of cmd_loot spawned what give couldn't fit at the
command's position and counted every drop. LootCommand.playerGive puts
each drop in with Inventory.add: what fits stays, the rest is lost, and
the drop counts only when add returns true, which it does when all of
it went in or for a creative player.

New command corpus group: loot give full inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 02:39:11 -05:00
SolenopsisbotandCodex 7dda0df3c0 perf: fan out game CPU resource loads
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:38:23 +11:00
Ludovic Marie 6539a95ef6 Merge pull request #12322 from masa-masa-0314/feat/realms-create-world
🤖 Agent: Generate New World opens Create World and uploads the temporary world it writes, as RealmsCreateWorldFlow.createWorld does.
2026-11-08 08:37:45 +01:00
Ludovic Marie 4a1adf44fd Merge pull request #12295 from glitchsnake/fix/11854-mob-health-boost
🤖 Agent: A mob's max_health is refreshed whenever Health Boost's amplifier changes or it ends (load, conversion, milk, death protection), and health is clamped to the new maximum, as refreshDirtyAttributes does. I merged main: #12255 already refreshes Health Boost in apply_death_effect and the trader's drink, so this PR's copies of those blocks were dropped and its test kept.
2026-11-08 08:37:41 +01:00
Ludovic Marie 4cf8cd0957 Merge pull request #12257 from voidsipp/fix/round6-12230
🤖 Agent: Piston block events and sculk charge cursors check the block-ticking ticket range (ServerLevel.shouldTickBlocksAt), not chunk ticking readiness, so they run in a freshly ticketed chunk; moving pistons still wait for readiness, as the oracle shows. Merged main (isPositionTickingWithEntitiesLoaded's doc now names the entities).
2026-11-08 08:33:21 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol eb835292f5 HDR float frame: store colour and coverage without light marks
Choose float alpha storage independently of the no-pack HDR gain. Plain
draws and the mirror keep their RGB; opaque finishes write alpha 1 and
blended draws keep coverage. Keep the existing written-light arithmetic.

Run light-mark redraws and hand unmark/remark only on compatibility
frames. Preserve the non-layered 0.2 hand's UI-brightness sentinel. Stop
resolving float alpha into light, while retaining the resolve oracle and
lane state for the next cleanup.

Check real shader outputs on the GPU and preserve the existing pictures
with differential HDR, pack, OIT, visibility-buffer and hand captures.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-08 08:32:06 +01:00
SolenopsisbotandCodex ddaada5bb9 perf: reuse shader pack page scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 18:31:27 +11:00
VaspyyyandClaude Opus 5.5 6b33c13380 Divide MenuTooltipPositioner's widget offset as floats like vanilla
getOffset divides the pointer's distance from the widget edge by the
widget height as floats: 26.3's bytecode is iload_3, i2f, iload_2, i2f,
fdiv before Mth.lerp and Math.round(float). The decompiled text without
the casts made widget_offset an int division, so a hovered widget's
tooltip stayed height - 3 below its edge until the pointer was a whole
height away, then jumped. On a 20 px button it sat up to 10 px lower than
vanilla's. The offset now follows the pointer as vanilla's does, rounded
with jmath::round_f32, and the test expectations are the values the real
26.3 MenuTooltipPositioner returns for the same inputs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:28:21 +01:00
VaspyyyandClaude Opus 5.5 b03828a569 Show the title screen icon buttons' tooltips while hovered like vanilla
The title screen's icon row (Friends, Language, Accessibility) drew its
sprites but no tooltip: the tooltip pass after the button loop only knew
Create World, Select World and Multiplayer. Vanilla gives these buttons a
Tooltip (CommonButtons.language and accessibility, FriendsButton while
friends are available), which WidgetTooltipHolder draws while the button
is hovered, placed by MenuTooltipPositioner and split at 170 px. The
accessibility onboarding screen builds its Accessibility Settings... and
Language... buttons with the same CommonButtons, so they lacked the same
tooltips.

Title::button_tooltip now names each screen's button tooltips, and both
the narration hints and the draw loop read it. The draw loop remembers
the hovered button's tooltip and draws it after the widgets (and after
the onboarding fade, which vanilla applies to widgets only) with the same
positioner (ui::tooltip_menu), before the toast so the toast stays on top.
Fixes #12067.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:28:21 +01:00
VaspyyyandClaude Opus 5.5 99bd306532 Scatter a destroyed chest boat's or container minecart's contents like vanilla
AbstractChestBoat.remove and AbstractMinecartContainer.remove spill the
container through Containers.dropContents for every removal reason that
destroys the vehicle: the kill of VehicleEntity.destroy and a creative
hit's discard alike. That spill has no entity_drops gate; only the hull
item and chestVehicleDestroyed's second, empty spill follow the rule.

Horizon's damage path got both vehicles wrong. A chest boat dropped each
stack whole at its position with an item's own motion and a pickup
delay of 10, and never drew chestVehicleDestroyed's per-slot spots. A
container minecart scattered its contents only with entity_drops on, so
with the rule off (or from a creative hit with it off) the contents
vanished.

Both now share one Containers.dropContents helper (the /kill path used a
second copy): the contents scatter from a spot in the vehicle's block in
pieces of 10 to 30 with no pickup delay, before the ENTITY_DIE event and
before a boat's lead, as Entity.kill and AbstractBoat.remove order them.
The hull item and the empty spill still need entity_drops and a
non-creative hit. Every damage source (players, LAN guests, explosions,
lightning, /damage) goes through these two functions, so single player,
LAN and dedicated servers all get it.

The vehicle_command_damage harness now breaks a chest boat with /damage,
and a chest minecart with entity_drops off both by /damage and by a
creative player's hit, against the real 26.3 server. Each break must
leave the 5 diamonds inside the vehicle's block with no pickup delay,
and the 40 dirt as piles in that block whose counts add up to 40. A
gameplay test seeds the level random, breaks a chest boat and a chest
minecart, and checks that the random ends where vanilla's two
Containers.dropContents calls leave it (the empty second spill's draws
included).

The port map rows for AbstractChestBoat.remove and
AbstractMinecartContainer.remove point at the shared helper instead of
the removed /kill copy. The chestVehicleDestroyed row now says what is
left there: a player's hit on a container minecart angers no piglins yet.

Fixes #12121

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:27:26 +01:00
Horizon-agent 2fae2a9101 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 08:21:34 +01:00
VaspyyyandClaude Opus 5.5 6d72b0375e Fire shulker bullets from the box centre and turn them at standable blocks
A shulker's bullet started at the centre of the shulker's block cell.
Vanilla's ShulkerBullet constructor starts it at
owner.getBoundingBox().getCenter(), and Shulker.makeBoundingBox grows
that box toward the open side by the lid's physical peek (times the
scale), so an open floor shulker fires from the top of its block
(#11974). shulker::bullet_origin now builds that box with
getProgressDeltaAabb at the shulker's scale, from the peek the goal
sees (the attack goal runs before Shulker.tick eases the peek, in both
games).

In flight, a bullet turned only before a full collision cube. Vanilla
asks Level.loadedAndEntityCanStandOn(next, bullet): the top face of the
block's collision shape, in the bullet's collision context, is full
(Block.isFaceFull). Top slabs, upside-down stairs, closed top trapdoors
and the like now turn it too, instead of the bullet flying into them
(#11970). collisions::projectile_can_stand_on implements the test, and
ProjectileWorld::solid becomes can_stand_on(pos, entity_y), since
scaffolding reads the bullet's height.

New harness shulker_bullet: the real ShulkerBullet constructor for all
six attach faces, every peek of an opening lid and four scales, and
loadedAndEntityCanStandOn over forty block states at four bullet
heights, compared bit for bit with the Rust probe.

Fixes #11974
Fixes #11970

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:17:17 +01:00
Horizon agentandClaude Opus 5.5 4f71890226 Ticket fast path: compare the portal tickets' chunks after the main merge
#12232's portal_matches read self.portal as (chunk, level) pairs; here it holds
PortalTicket, so it compares ticket.chunk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 08:16:49 +01:00
martipops 47a832db72 Merge remote-tracking branch 'upstream/main' into settings/shell-choice-step 2026-11-08 01:56:53 -05:00
Horizon agentandClaude Opus 5.5 3627361d25 Merge main into #12295
#12255 already refreshes Health Boost and clamps absorption in apply_death_effect
and the wandering trader's drink; main's blocks are kept, this PR's copies of them
dropped, and its clear-all-effects test kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:55:18 +01:00
Horizon agent db1e3398ce Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 07:54:46 +01:00
SolenopsisbotandCodex 5288273f01 perf: reuse vegetation placement scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:50:24 +11:00
SolenopsisbotandCodex e55b51a4b0 perf: reuse creature event scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:47:51 +11:00
SolenopsisbotandCodex b7468c0196 perf: fan out independent pack resource loads
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:43:18 +11:00
Horizon agent 282b7b3085 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 07:42:55 +01:00
Horizon agent 7d269ccea1 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/realms.rs
2026-11-08 07:42:42 +01:00
Horizon agent c072d21438 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 07:42:38 +01:00
SolenopsisbotandCodex 94bd299a9a perf: reuse displacement frame scratch
Co-Authored-By: Codex <[email protected]>
2026-11-08 17:39:59 +11:00
bf6a7d910c HDR float frame: rebuild when the HUD layering changes
Choose the frame format and HUD layering together. No-pack float HDR needs
the HUD layer, so devices without it keep the compatibility frame. A 0.2
pack still gets its float frame in SDR and HDR.

Rebuild when picking or dropping a pack changes the layering even if the
format stays float, so the renderer always has the layering a fresh start
would give it.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:39:39 +01:00
Horizon agentandClaude Opus 5.5 944a3e1c80 Keep the offline player files as backups when the owner moves
The migration renamed nothing back if it went wrong: it deleted the offline
.dat and .dat_old after writing the account's file. They now stay beside it as
.dat_offline and .dat_old_offline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:36:14 +01:00
Horizon agent f57866e72f Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 07:35:55 +01:00
k3yhandClaude Opus 5.5 8c8615875e A potion a loot table rolls with set_components gives its effect
StackData::set_loot_component kept every component but trim in other,
its saved form, so a potion a chest or a mob's loot rolled with a
set_components potion_contents gave no effect: brewing::contents_effects
reads potion and potion_extra. A modelled component is now read into
its field the way a saved stack's is (decode_components). Damage stays
in other, as the stack holds it, and custom_name keeps its own reading.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:34:11 -05:00
VaspyyyandClaude Opus 5.5 dde2b2abff Start the menu music while reconfiguring and test the #12148 path
Vanilla's ServerReconfigScreen is a plain Screen, not a
LevelLoadingScreen, so MusicManager.tick starts the MENU music that
getSituationalMusic picks once the player is gone. Horizon treated the
reconfiguration as loading (no player, no title, no credits) and stayed
silent until the next world; a reconfiguring connection no longer
counts as loading.

A Game test (prepared resources, ignored by default) puts a column
holding a biome id one past the table at the origin, reads it through
biome_at and biome_name_at (plains), then checks that a reconfiguring
client picks and starts the menu music and that the level probe after
it reads plains' music without a panic.

Biomes::name's doc now says that Biomes::get clamps such an id to the
last entry instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:31:01 +01:00
VaspyyyandClaude Opus 5.5 85b61b6f6e Stop the client crashing on a biome id past its table after a server switch
A proxy that moves the player to another server reconfigures the
connection. Horizon keeps the last server's columns until the next
Login clears them, but Reconfigured already swaps in the next server's
biome table. When the last server had a biome the game lacks (added to
the table as id 67) and the next one does not, those columns hold an id
past the 67-entry table. situational_music still probed the level at
the origin during the reconfiguration, and biome_at indexed the table
with that id: "index out of bounds: the len is 67 but the index is 67".

Vanilla never reads that level: handleConfigurationStart calls
clearClientLevel, which nulls the player, so getSituationalMusic
returns the menu music. The music probe now asks Remote::has_level
(Login seen, no reconfiguration since) instead of logged_in.

The three places that indexed the biome table directly (biome_at for
music, ambience and custom skies, biome_name_at for random textures and
fishing, and /fillbiome's filter) now go through Biomes::name, which
names plains for an id past the table, as biome_at already did for a
missing column and Biomes::id does for an unknown name.

Fixes #12148

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:30:57 +01:00
Horizon-agent 24c3cabed3 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-08 07:20:51 +01:00
Horizon agentandClaude Opus 5.5 61d43cf005 Skip the tamable panic test without extracted mob data
with_built_test_world runs nothing where the data is missing (CI), and the
test then compared None with Some(true); it now returns early there, as the
other with_built_test_world tests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 07:16:00 +01:00
Horizon agent b62553197b Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 07:15:37 +01:00
TheApple05andCodex GPT-6 911bab7e0f Preserve resource-pack parity paths with JSON headers
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-08 06:11:54 +00:00
Horizon-agent 030e7e6ff6 Merge commit '02ff63cf4bc4f243391974eb957afa247f4c5a01' into HEAD 2026-11-08 07:05:41 +01:00
masa-masa-0314andZCode 32cc9c56e0 Realms: Generate New World opens Create World and uploads what it makes
`RealmsResetWorldScreen`'s Generate New World tile is enabled: it opens the Create
New World screen (`RealmsCreateWorldFlow.createWorld`), and its Create builds the
temporary world the upload packs instead of entering a local one.

- The tile returns the Realm, slot and reset kind to the title, which shows Create New
  World with the default options. Create (`create_or_upload`) builds a `Level` from
  those options (name, seed, game type, difficulty, cheats, game rules, feature flags
  and data packs) and hands it back to the Realms screen; Back/Escape returns to the
  configuration screen (or the list for a new Realm).
- `realms::create_temporary_world` mirrors `createTemporaryWorldFolder`: a temporary
  folder with level.dat from `WorldData.createTag(null)` (no `singleplayer_uuid`), the
  world generation and game rules, and the Create World screen's data packs moved into
  `datapacks/`. No chunks: the Realm generates them.
- The upload runs the same `RealmCreationTask` → `SwitchSlotTask` → `packAndUpload`
  chain as a local-world upload, with `RealmsWorldOptions.createFromSettings` (slot
  name, game mode, difficulty, the game's version) and the hardcore setting. While it
  runs `AlertScreen` (`mco.create.world.reset.title`) shows a Cancel; success returns
  to the configuration screen, or plays the Realm just created
  (`RealmsMainScreen.play`); cancel returns to the reset screen; a failure shows
  `mco.create.world.failed`.
- `Level::write_upload` writes only the upload's files (no player file, weather or
  clocks), with `singleplayer_uuid` omitted as `createTag(null)` writes it.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 14:05:35 +08:00
Horizon-agent 02ff63cf4b Merge commit '1cccd4167d8525b125ac6cc74f0dcc235683e767' into HEAD 2026-11-08 07:04:51 +01:00
Horizon-agent 1cccd4167d Merge commit '849c2915611feced73975c8310f5d5f603f747af' into HEAD 2026-11-08 07:03:58 +01:00
Horizon-agent 849c291561 Merge commit 'ca441c3ea06bbff7c2cae41cd4b3f0cf3ea88813' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 07:03:46 +01:00
Horizon-agent ca441c3ea0 Merge commit '5ee77de8c82449a4830f716a1be8d7553fdd65d4' into HEAD 2026-11-08 07:02:48 +01:00
martipopsandClaude Opus 5.5 dba8453681 Settings: Left and Right step a focused choice back and on
On the settings shell (Extras, Graphics), a value between `<` `>` could
only be stepped back with Shift-click or, with an Extras setting on, the
wheel: a pad or keyboard player could only go forward. Left and Right on a
focused choice now step it back and on, as its arrows show, from the D-pad
and the arrow keys alike. They are a Shift press and a press, the same path
as a click, so Undo and saving follow.

They step at once rather than after A arms the row, as sliders do: A keeps
its step forward and there is no mode to find, and up and down passing over
a choice never change it. Up and down leave the row, and LB and RB reach the
page list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:02:15 -05:00
Horizon-agent 5ee77de8c8 Merge commit '35ab3f27b058be561260e09d9676d289cde52565' into HEAD 2026-11-08 07:01:55 +01:00
voidsip ba5f08c10c Keep respawn fallback and spawn rotations consistent with world data 2026-11-08 00:01:37 -06:00
Horizon-agent 35ab3f27b0 Merge commit 'ed1d96c9117c54ea7a6d74556f34fa08d05aeb8a' into HEAD 2026-11-08 07:00:52 +01:00
Horizon-agent ed1d96c911 Merge commit 'd7c560ec78d66a2c022a494be7c084f5aa7dfad9' into HEAD 2026-11-08 06:59:58 +01:00
Horizon-agent d7c560ec78 Merge commit 'dc41d4bc93996ede221a19266f924b30f683e39b' into HEAD 2026-11-08 06:59:13 +01:00
Horizon-agent dc41d4bc93 Merge commit '826fdf6306a88ad931520b0a9f34801675b76660' into HEAD 2026-11-08 06:57:09 +01:00
Horizon-agent 826fdf6306 Merge commit '1c878671de71a6827708ed1eb51141b32da22d2a' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 06:56:51 +01:00
Horizon-agent 1c878671de Merge commit '414fcee500187355e0a27c64819b2d9823a93a3f' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 06:56:24 +01:00
J5CaseyandClaude Opus 5.5 b1a75237d0 Native hook: fishing_medium, a bobber that floats and fishes outside water
A mod answers `native_fishing_medium(dimension, hook, position, owner, medium)`
for a bobber that is flying or floating, not in water and not hooked to an
entity. `Some(NativeFishingMedium)` makes the spot a medium: in the block of
its `surface` or below, the bobber floats, waits, bites and is reeled in as
in still water; the catch rolls its `loot_table` (vanilla's
`minecraft:gameplay/fishing` when None) and `lure_ticks` replaces vanilla's
100 to 600 tick wait when set. It is asked on the server and on the clients
that draw the bobber, so both move it alike. No answer keeps vanilla, and
water is never asked about, so vanilla fishing is unchanged.

`horizon-ctl status` shows the hook's loot table. Fixes #12300.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KtNXhvTSpY15Rjzwuqh8Nf
2026-11-07 23:53:12 -06:00
Horizon-agent 414fcee500 Merge commit '2d29c7f8e954fad2e3eafa4c18c35b4a1147c807' into HEAD 2026-11-08 06:53:10 +01:00
Horizon agent f62c4b546e Merge commit 'ff0ca9ba176d9a12a9d2d0fbf540446942315988' into HEAD 2026-11-08 06:41:09 +01:00
Horizon agent ff0ca9ba17 Merge commit 'c60ed93aff' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 06:40:57 +01:00
Horizon agentandClaude Opus 5.5 c60ed93aff Drop the duplicated orb_data_tests module line from the stack merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:37:45 +01:00
LokiandAntigravity 0538f95dd8 lan: clamp mob health and refresh max_health on effect changes (fixes #11854)
Co-Authored-By: Antigravity <[email protected]>
2026-11-08 14:32:01 +09:00
Horizon agentandClaude Opus 5.5 8f6ef43b0d Sort the test modules (rustfmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:32:00 +01:00
Horizon agent 8ee2a1ab6a Merge commit '13217d3b4c0bbaf153871a85477b4010e83ad1f5' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 06:30:06 +01:00
Horizon agentandClaude Opus 5.5 13217d3b4c Sort the test modules after the main merge (rustfmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 06:29:53 +01:00
Horizon agent 82ed0f3ac8 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 06:29:14 +01:00
masa-masa-0314andZCode 96f8110001 Worlds: the single-player owner is the account's UUID, old worlds migrate
`IntegratedServer`'s constructor sets `setSingleplayerProfile(minecraft.getGameProfile())`,
and `ServerLoginPacketListenerImpl.handleHello` gives a login whose name matches that
profile the profile itself; the local memory hello names the account's profile id
(`Minecraft.handleClientLogin`'s `ServerboundHelloPacket(getUser().getName(),
getUser().getProfileId())`). The integrated server's owner, its gate/op entry and its
login are now the account's UUID; an offline start keeps the offline profile.

`MinecraftServer.saveAllChunks` saves with `saveDataTag(worldData,
getSingleplayerProfile().id())`: level.dat's `singleplayer_uuid` follows the owner on
every save. A world without one (a Realms download; `FileDownload.untarGzipArchive`
drops it) then loads the owner's `players/data/<account uuid>.dat` instead of starting
a new player.

Worlds older Horizon builds made name their player by the offline UUID everywhere:
level.dat's `singleplayer_uuid`, the player file and its `UUID`, and entity references
(`TamableAnimal`/`AbstractHorse`/`Projectile`/`AreaEffectCloud`/`EvokerFangs` `Owner`,
`ItemEntity`'s `Owner`/`Thrower`, `Fox`'s `Trusted`, `NeutralMob`'s `angry_at`,
`Leashable`'s `leash.UUID`). The first open under the account moves the player file to
the account's UUID (the newer of the two when both exist), rewrites level.dat before the
first session save, and rewrites each entity as its chunk loads, so the next save keeps
the account's UUID. A world renamed since it was made stays as it is: the old name its
offline UUID came from is not stored, so it cannot be identified.

Co-Authored-By: ZCode <[email protected]>
2026-11-08 13:06:42 +08:00
Nikita Maximilian Grimm 89d7b8e65b Merge remote-tracking branch 'origin/main' into portal-ticket-persistence 2026-11-08 06:03:18 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 0f69036171 Stream restored portal tickets' chunks during the startup wait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:56:00 +01:00
voidsip 2ed136b137 Keep LAN default-spawn packets tied to the world spawn 2026-11-07 22:55:02 -06:00
Horizon agent e299e931f5 Merge commit '4cbca5702609168ee27fff1645b29b5fa196a974' into HEAD
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-08 05:54:38 +01:00
Horizon agent 4cbca57026 Merge commit '183513311337bd8c4d95f9f915f03084b58534c5' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:54:33 +01:00
Horizon agent 1835133113 Merge commit '1d533463ca8f620c87474a32d1f817e4c4f5489c' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:54:25 +01:00
Horizon agent 1d533463ca Merge commit '87dfb297ef73a16d1967e981f4732fda1c5cc0e2' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:54:15 +01:00
Horizon agentandClaude Opus 5.5 87dfb297ef Sort the test modules (rustfmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:54:04 +01:00
voidsip 45d46d46f4 Keep the panorama behind server configuration dialogs 2026-11-07 22:37:41 -06:00
Horizon agent 4635eed508 Merge commit '01c1e661687793318d5487918b18ae4dfc5b05c7' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 05:29:11 +01:00
Horizon agent 01c1e66168 Merge commit 'c7fc1c994f9569dc99674cdc40522f2b25826697' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:29:07 +01:00
Horizon agent c7fc1c994f Merge commit '4eba8a6991243314c98098e99f32ea0a929e387c' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:29:04 +01:00
Horizon agent 4eba8a6991 Merge commit '01fcfc484950ac5a1417be5b5f82488184a7785e' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:29:01 +01:00
Horizon agent 01fcfc4849 Merge commit '13dc865c9a591265d0a2d12bbd2845f813a6642a' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 05:28:51 +01:00
AkirabaneandClaude Opus 5.5 391da75bf3 Riders turn like vanilla's: vehicle body only where vanilla copies it
position_riders gave every mob rider of a mob its vehicle's body yaw each
tick. 26.3 does so only in AbstractHorse.positionRider and
Chicken.positionRider, and in AbstractSkeleton.rideTick and Drowned.rideTick
for a rider steering a PathfinderMob; any other rider's body turns on its
own. A rider of a living vehicle is drawn by LivingEntityRenderer.solveBodyRot
from the vehicle's body, which only the window's player model did: mobs now
go through the same function.

Fixes #9335.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 05:28:25 +01:00
Horizon agent 13dc865c9a Merge commit '80d929f7d64a7fd92ede64042392f676df4387b7' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-08 05:28:07 +01:00
Horizon agent 80d929f7d6 Merge commit '75cd96be536365ec99573e48e08411832ab01701' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 05:27:48 +01:00
Horizon agent 75cd96be53 Merge commit '5bf7ce508d86204c6fb42a528a2ee05ce85da43a' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 05:27:34 +01:00
Horizon agent 5bf7ce508d Merge commit '4dbb00502ff162b4ac3472a3e7c85c5d8d88883e' into HEAD 2026-11-08 05:27:16 +01:00
Horizon agent a869493ff4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-08 05:21:14 +01:00
voidsip ed656ba6cf Run block events on ticketed chunks before they start ticking 2026-11-07 22:21:02 -06:00
Horizon agent 1e743c812e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands.rs
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 04:59:32 +01:00
Horizon agent 7cec42ea0b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-08 04:58:44 +01:00
Horizon agent 75a2643501 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:58:37 +01:00
Horizon agent 6e4c79f33b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:57:46 +01:00
Horizon agent 65b2833bb9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 04:57:14 +01:00
Nikita Maximilian Grimm 0972bbea7b Merge remote-tracking branch 'origin/main' into portal-ticket-persistence
# Conflicts:
#	crates/horizon-client/src/game/levels.rs
2026-11-08 04:57:09 +01:00
Horizon agent 5452da616f Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 04:56:54 +01:00
Horizon agent c5b9f07b5d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/commands.rs
#	crates/horizon-client/src/game/commands/data.rs
#	crates/horizon-client/src/game/persist.rs
2026-11-08 04:56:48 +01:00
Horizon agent 64821ce0bb Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-11-08 04:56:42 +01:00
MSedgeMC eaceaeda73 A cat hit by a player panics, and a wolf freezes into one
`TamableAnimal.TamableAnimalPanicGoal` passes a damage tag to `PanicGoal`:
`Wolf` gives it `#panic_environmental_causes`, while `Cat` and `Parrot`
keep the default `#panic_causes`. Horizon's `TamePanic` gated all three on
`fire_ticks > 0`, so only a burning pet panicked: a cat hit by a player or
a wolf freezing stood still where vanilla flees.

`TamePanic` now delegates to the `Panic` it wraps. The wolf's panic is
marked environmental (fire, freezing, cactus, lightning, lava); the cat's
and parrot's take any panic-causing damage.
2026-11-08 03:56:11 +00:00
Horizon agent e67b56eec6 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:25:27 +01:00
Horizon agent f9b2840d07 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-08 04:24:42 +01:00
Nikita Maximilian Grimm 0b30eba4e0 Merge remote-tracking branch 'origin/main' into tnt-size-eye 2026-11-08 04:14:43 +01:00
Horizon agent 0270b0133d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 04:03:56 +01:00
Horizon agent 1b7cec46d9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:03:52 +01:00
Horizon agent 6922878d94 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:03:51 +01:00
Horizon agent 9a442e5382 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
#	tools/test_command_exec_parity.py
2026-11-08 04:03:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2294609404 TNT's rider seat test expects the float height
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:59:15 +01:00
Horizon agentandClaude Opus 5.5 092ae508ba rustfmt game.rs (verify failed on it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:45:11 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 44caa43238 Primed TNT's box and eye height are vanilla's floats
EntityTypes.TNT is sized 0.98F with an explicit eyeHeight(0.15F). The box
now widens the float size, so TNT stopped by a wall or ceiling ends where
Java's does, and the fluid eye height is the type's 0.15F instead of the
default 0.85 of the height.

Fixes #12020

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 03:30:52 +01:00
Horizon agent 325c627cdb Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-08 03:27:59 +01:00
Lyle ColeandClaude Opus 5.5 837f0410ea Far terrain: keep the generator's far samples with the world
Every session sampled each far tile again (LodSampler::grid): the noise,
biomes and surface rules for 16x16 columns. lod::cache keeps the chunks
generated for detail, but not these samples. lod::bake keeps them in the
dimension's horizon_lod/ folder (append-only bucket files, a record per
tile, zlib), so a reopened world reads them back instead.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SeonVnpkKZ9a5aRRAWs2Qb
2026-11-07 19:30:08 -06:00
Nikita Maximilian Grimm 5bea37b4be Merge remote-tracking branch 'origin/main' into portal-ticket-persistence
# Conflicts:
#	crates/horizon-client/src/game/levels.rs
2026-11-08 02:10:48 +01:00
Horizon agentandClaude Opus 5.5 4f56dfc42b Merge #11813 into #11831 (both change take_mob_passengers)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:52:32 +01:00
Horizon agentandClaude Opus 5.5 f1af021d5c Merge main into #11732
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:49:11 +01:00
Horizon agentandClaude Opus 5.5 fcd33abc9e Merge main into #12133
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:48:46 +01:00
Horizon agentandClaude Opus 5.5 8a218ed405 Merge main into #12127
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:47:55 +01:00
Horizon agentandClaude Opus 5.5 9b6074a363 Merge main into #12094
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:44 +01:00
Horizon agentandClaude Opus 5.5 8fd45d810e Merge main into #12041
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:46:31 +01:00
Horizon agentandClaude Opus 5.5 2183972c00 Merge main into #12031
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:45:32 +01:00
Horizon agentandClaude Opus 5.5 03df68c41d Merge main into #11991
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:45 +01:00
Horizon agentandClaude Opus 5.5 a77d401f53 Merge main into #12010
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:45 +01:00
Horizon agentandClaude Opus 5.5 1769fff834 Merge main into #11964
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:44:42 +01:00
Horizon agentandClaude Opus 5.5 e5d8856821 Merge main into #11831
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:42 +01:00
Horizon agentandClaude Opus 5.5 93bb7f3230 Merge main into #11813
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:24 +01:00
Horizon agentandClaude Opus 5.5 816d917134 Merge main into #11797
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 01:43:08 +01:00
Florian ErnstandClaude Opus 5.5 aee1b8b81c Adopt /data edits on a live interaction entity
EntityDataAccessor.setData loads the edited compound into the same entity,
and Interaction.readAdditionalSaveData reads its width, height, response
and the attack and interaction records. Horizon kept an interaction only
as a generic entity with its loaded compound, and its live data write
took markers alone, so every /data edit answered "Nothing changed".

A server interaction now holds its settings (InteractionState: width,
height, response and the two PlayerAction records) next to its position.
It reads them when it loads or is summoned, saves from them
(addAdditionalSaveData, a record only when set), and a /data edit decodes
the edited compound into the same entity, which keeps its id and UUID.
Every other kind is unchanged.

Fixes #12131.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:29:55 +01:00
Oloion 2a33c013d7 perf: branchless frustum culling, zero-copy Arc meshing regions and 3D upload priority 2026-11-08 02:24:31 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 b58576b523 Reload saved portal tickets and pause their expiry while their chunk loads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:24:26 +01:00
Florian ErnstandClaude Opus 5.5 31d39aec80 Count /loot kill under execute as a connected player as that player's kill
LootCommand.dropKillLoot takes the command source's entity as the killer:
a player is the last damage player, so killed_by_player holds, and its
own weapon counts for looting. Under `execute as <connected player>` from
another player, Horizon counted no player kill and used the issuer's
held stack.

For a connected player as the source, /loot kill now reads that player's
selected stack with it in place (for_player); an empty hand stays empty.
The loot roll, the output, the feedback and the result run as before,
after the issuer is back. Other sources keep their current rule.

Fixes #12122.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:11:21 +01:00
Florian ErnstandClaude Opus 5.5 8f9e984d63 Compare a mob's hurt cooldown on the incoming damage
LivingEntity.hurtServer compares a hit inside the hurt cooldown
(invulnerableTime > 10) with lastHurt by its incoming amount, before
armor and Resistance, and keeps that amount as lastHurt. actuallyHurt then
reduces only the difference. Horizon reduced the hit first, then compared
and stored the reduced amount. Under full Resistance every hit reduced to 0,
so a stronger hit inside the cooldown was refused. With armor, a second
hit dealt reduced(8) - reduced(4), not reduced(8 - 4).

The comparison now uses the amount after the helmet, freeze and infinity
changes. The existing armor, Resistance, protection, witch and wolf armor
steps get the accepted difference, and last_hurt keeps the incoming amount.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 00:07:47 +01:00
Florian ErnstandClaude Opus 5.5 39701a9334 Adopt /data edits on a live experience orb
EntityDataAccessor.setData keeps the entity's UUID around Entity.load of
the edited compound, and ExperienceOrb.readAdditionalSaveData reads its
Age, Health, Value and Count into the same orb. Horizon had no experience
orb case, so `data merge entity <orb> {Age:42s}` answered "Nothing
changed" and the orb kept its age.

The orb case decodes the edited compound with the existing orbs_from_nbt
and copies its saved fields into the resident orb, which keeps its
runtime id, join order, tick count and UUID. As Entity.load does, the
saved PortalCooldown is read while a live portal process stays. The
edited compound is kept like the other cases', so Tags follow it.

A falling block's Time is handled separately (#11424).

Fixes #12090.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 23:04:52 +01: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
Florian ErnstandClaude Opus 5.5 7339c9057a Run a /kill-ed mob's death during the command
LivingEntity.kill is a generic_kill hit that calls LivingEntity.die before
the command returns. Horizon set the mob's health to 0 and left the death
to the next tick's creature phase, so its entity_die vibration reached a
sculk sensor one tick late.

The /kill mob arm now runs the existing death routine (mob_died) at once
for a mob that this kill takes from alive to dead, behind the existing
`died` guard, as a frog's bite already does. The native living_death veto,
the vibration, the catalyst, loot, experience and kill credit now happen
during /kill instead of in the next creature phase. A second kill and the
next tick do not run it again. The body's removal timer is unchanged.

Fixes #12037.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 21:04:50 +01:00
Florian ErnstandClaude Opus 5.5 2979cf024d Resolve /clone's source and target dimensions
/clone from <sourceDimension> ... to <targetDimension> ignored both
arguments: an unknown name cloned in the current level, and a valid
other dimension wrote into the current level instead of its own world.

As CloneCommands does, sourceDimension is resolved as begin is read and
targetDimension as destination is read, each position checked loaded in
its own level; an unknown name fails with argument.dimension.invalid
(the built-in dimensions, as execute in checks them), the source first.
The overlap check applies within one level only. The blocks are read in
the source level and placed in the target level through the existing
level swap (enter_command_level), each entry checked; the coordinates
are resolved in the command's own context first. The command's own
level is put back on every path, before the command reports.

Fixes #11997

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:34:36 +01:00
Florian ErnstandClaude Opus 5.5 46c6bdc8ac Let /attribute add and remove see a baby zombie's minecraft:baby modifier
Zombie and Piglin.setBaby put a transient minecraft:baby speed modifier
on a baby. AttributeInstance refuses to add an id it already holds and
removes a modifier whatever its kind; the next setBaby removes
minecraft:baby again and, for a baby, adds the transient one back. The
transient modifier was computed from the baby state, so /attribute
modifier add stacked a permanent one on it and modifier remove found
nothing to remove.

The add now counts the transient modifier, and the remove takes it away
until the next setBaby (a flag on the mob's attributes, not saved: a
load runs setBaby again). setBaby clears that flag and removes a
permanent minecraft:baby, as vanilla's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 20:15:12 +01:00
Florian ErnstandClaude Opus 5.5 9f0b1d99c1 Save a mob's movement_speed instance only once it has ticked
AttributeMap.pack saves the attribute instances that exist. A fresh
living entity has none (attributes:[]); its first tick creates
movement_speed (LivingEntity.aiStep -> removeFrost -> getAttribute).
The mob writer added movement_speed to every save, so a mob that had
not ticked yet already saved it.

The writer's invented movement_speed entry now waits for the mob's first
tick (its tick count). The instances the load itself makes stay:
Zombie and Piglin setBaby the speed, Rabbit setVariant the attack damage.
Instances the mob was loaded with are kept as before, and the list is
written even when empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:34:14 +01:00
Florian ErnstandClaude Opus 5.5 08ce93b6f1 Keep the shared inventory helpers crate-private and test the delegation
add_to_container and load_container are only used inside the crate. Two
unit tests pin VillagerState::add_item as add_to_container with eight
slots, and a later same-kind stack merging while eight kinds fill the
slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 19:06:20 +01:00
Florian ErnstandClaude Opus 5.5 181ca3d833 Merge origin/main into fix/h052-vehicle-container-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:35:40 +01:00
Florian ErnstandClaude Opus 5.5 a75f3169eb Merge origin/main into fix/h112-guest-container-close-event
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:35:39 +01:00
Florian ErnstandClaude Opus 5.5 b2d87410fc Merge a Piglin's and a Villager's saved inventory stacks on load
InventoryCarrier.readInventoryFromTag loads the saved stacks with
SimpleContainer.fromItemList: clear, then addItem each one, which fills
earlier stacks of the same item and components up to the limit before it
takes an empty slot, and drops what does not fit in the eight. Piglins
and villagers took the first eight saved stacks as they were, so 2 + 3
stone stayed two stacks and 40 + 40 did not become 64 + 16.

The villager's existing SimpleContainer.addItem port becomes a free
add_to_container, unchanged; both loads add each decoded stack through it
at the type's stack limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 18:33:30 +01:00
Delete228andCodex 753477f5b4 Merge Horizon main into the state-dependent light PR
Co-authored-by: Codex <[email protected]>
2026-11-07 20:00:52 +03:00
Florian ErnstandClaude Opus 5.5 a191dcd2b2 Merge origin/main into fix/h052-vehicle-container-save
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:59 +01:00
Florian ErnstandClaude Opus 5.5 1bf526ca7f Merge origin/main into fix/h193-effective-component-presence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:48 +01:00
Florian ErnstandClaude Opus 5.5 635f14ea0e Merge origin/main into fix/h151-boat-passenger-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:14:47 +01:00
Horizon agentandClaude Opus 5.5 3dcb5cba92 Merge origin/main into #11570
command_exec_corpus.py: keeps this branch's isolated 'guest container
close event' group beside main's spectral duration and item Health groups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:13:29 +01:00
Florian ErnstandClaude Opus 5.5 4adf2afffb Drop passenger entries that load nothing, keep riders through /data merge
EntityType.loadPassengersRecursive mounts only the Passengers entries that
load, and Entity.saveWithoutId saves the riders the entity has.
take_mob_passengers kept every non-mob entry raw, so an unknown or missing
id, or a non-compound value, stayed in the saved Passengers. It now drops
an entry that is not a compound or has no registered type (entity-types,
EntityType.by), and keeps valid non-mob entries raw as before.

Entity.load does not read Passengers either. apply_entity_nbt stored the
whole merged compound as raw, so a mob, boat, minecart or TNT saved copies
of its live riders and any merged entries after /data merge. It now keeps
the Passengers the load kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 16:09:01 +01:00
Florian ErnstandClaude Opus 5.5 5440872e93 Refuse /summon trees that hold a live entity's UUID
ServerLevel.tryAddFreshEntityWithPassengers checks every UUID of the
summoned tree against the loaded entities before anything joins, and
fails with commands.summon.failed.uuid. summon_with_nbt now walks the
same tree the loaders admit (the root, then the mob riders that
take_mob_passengers takes under mob, boat, minecart and TNT roots) and
refuses it when an entity in the level holds one of its UUIDs.

Duplicate UUIDs inside the new tree are still admitted (vanilla skips
the later one but keeps it under its vehicle's Passengers); the
command harness keeps them as --summon-uuid-internal-diagnostic and
the Game test as a known-unfixed ignored test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:39:15 +01:00
TheMrZZandCodex gpt-6.1-sol ce3a7a7587 Project live boat passengers in command NBT
Reuse the existing runtime tree projection before the live leash writer. Preserve the original full-body diagnostic and add an independently admitted typed-tree command gate.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 15:07:30 +01:00
Horizon agent 9f6aa5f2f6 Merge origin/main into PR #11682
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/commands/terrain_cmds.rs
#	crates/horizon-items/src/items.rs
2026-11-07 14:19:54 +01:00
Horizon agent 37db8a337e Merge origin/main into PR #11570
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
2026-11-07 14:17:46 +01:00
Horizon agentandClaude Opus 5.5 c3f5770bb9 Merge origin/main into #11301 (main builds again after #11767)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 14:10:28 +01:00
Horizon agentandClaude Opus 5.5 5a07a7edfe Merge origin/main into #11301 (keep both data.rs test modules and command_exec groups)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:59:39 +01:00
Horizon agentandClaude Opus 5.5 3cbe777d44 Merge origin/main into fix/set-custom-model-data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:08:36 +01:00
Horizon agentandClaude Opus 5.5 28299018c7 Merge origin/main into fix/vindicator-johnny
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 13:02:07 +01:00
TheMrZZandCodex gpt-6.1-sol 7e8c7a27c1 Use effective stack prototypes for component presence
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 12:38:41 +01:00
Horizon agentandClaude Opus 5.5 08f2880b2a Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:52:14 +01:00
Horizon agentandClaude Opus 5.5 03d1f86f4a Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:50:56 +01:00
Horizon agentandClaude Opus 5.5 65d3e70f5d Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:47:54 +01:00
Horizon agentandClaude Opus 5.5 5547077993 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:47:00 +01:00
Horizon agentandClaude Opus 5.5 14428f3233 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:46:07 +01:00
Horizon agentandClaude Opus 5.5 4a697d3689 Merge main (clippy as_chunks lint fix)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:44:19 +01:00
Horizon agentandClaude Opus 5.5 baab15d42d Fail location_check on unloaded counterparts
Vanilla's LocationPredicate checks Level.isLoaded before the block predicate, so an offset into an unloaded chunk fails instead of holding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:35:59 +01:00
Horizon agentandClaude Opus 5.5 71c7dfaf97 Merge main; fail location_check on unloaded counterparts
Vanilla's LocationPredicate checks Level.isLoaded before the block predicate, so an offset into an unloaded chunk fails instead of holding. Drops the docs/parity run logs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:35:26 +01:00
Horizon agentandClaude Opus 5.5 d12d6262bb Drop the docs/parity run logs and their one-off capture script
Run logs are not docs; the PR body keeps the numbers. tools/inline_trim_packet_capture.py only produced the dropped packet log and nothing calls it; the protocol test keeps the captured payload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:02 +01:00
Horizon agentandClaude Opus 5.5 c36df3986f Merge origin/main into fix/inline-armor-trim-holders
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:25:01 +01:00
Horizon agentandClaude Opus 5.5 38f887eab2 Merge #11655 (with #11643) into #11654
The three loot functions add arms and tests at the same spots in loot.rs; all kept. Land #11643, then #11655, then this.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:15:02 +01:00
Horizon agentandClaude Opus 5.5 71c1240617 Drop the docs/parity run logs
Run logs are not docs; the PR body keeps the numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:40 +01:00
Horizon agentandClaude Opus 5.5 14100456d1 Merge origin/main into fix/named-predicate-context
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:40 +01:00
Horizon agentandClaude Opus 5.5 32109b87b6 Merge #11643 (set_book_cover) into #11655
Both add a set_loot_component arm, a loot.rs function arm and a loot test at the same spots; both kept. Land #11643 first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:14:10 +01:00
Horizon agentandClaude Opus 5.5 79fe52def4 Merge origin/main into fix/h112-guest-container-close-event
command_exec corpus: keep main's craft-map group in ISOLATED_GROUPS and its import beside guest_close_event. Oracle: keep main's merchant_saved_stats op; close_menu goes through handleContainerClose. The test's Joined literal gains main's new configured field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:11:48 +01:00
Horizon agentandClaude Opus 5.5 66ecf65d4e Merge origin/main into fix/h157-player-save-projection
Keep main's vibration_tests module beside the new test module; the test's Joined literal gains main's new configured field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:08:46 +01:00
Horizon agentandClaude Opus 5.5 1315c0e4db Vindicator: write Johnny only while the flag is set
Vindicator.addAdditionalSaveData writes Johnny: 1b only when isJohnny is true; a vindicator that is not Johnny saves no key (and drops a stale Johnny: 0b from its raw compound).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:07:23 +01:00
Horizon agentandClaude Opus 5.5 4169af3fa6 Merge origin/main into fix/h181-noai-trader-llama-delay
Keep main's craft_map_take import beside the trader llama corpus import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:01:55 +01:00
Horizon agentandClaude Opus 5.5 306f9425e8 Merge origin/main into xp-orbs-riding-merge-water
ordered_tick: main's obstacle grid for the orb move kept after the
merge scan and the followed-player pick. A kept followingPlayer now
stays at exactly 8 blocks: ExperienceOrb.followNearbyPlayer re-picks
only past distanceToSqr > 64. Orb merge fixture keeps merge_new_orb.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 11:01:54 +01:00
Horizon agentandClaude Opus 5.5 b0047b5518 Merge origin/main into #11301
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:51:22 +01:00
Horizon agentandClaude Opus 5.5 1990e4aecc Merge origin/main into #11301
Keep main's non_mob_tag base with the PR's class-owned container fields,
and both new test modules and corpus groups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 09:35:57 +01:00
Florian ERNSTandCodex 16d77f41d4 Bind loaded block locations in loot-mine conditions
Co-Authored-By: Codex <[email protected]>
2026-11-07 09:17:11 +01:00
Florian ERNSTandCodex 7f21b95989 Adapt new equipment wear test to named trim holders
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:15:48 +01:00
Florian ERNSTandCodex 76123f5638 Preserve inline armor-trim holders through item state and sync
Co-Authored-By: Codex <[email protected]>
2026-11-07 08:13:34 +01:00
MSedgeMC 3b8ac89d2c port_map: SetBannerPatternFunction.run is ported
The row said layers from loot are not set and no vanilla table uses the
function; a datapack table does, and the row now names the arm and the item-side
parse that puts the layers on the banner.
2026-11-07 06:49:34 +00:00
MSedgeMC f8aa8e0e0d test: the loot arm appends or replaces, and the item wears the layers
The loot table test fills a chest with a white banner through set_components
(a red stripe at the bottom) and then set_banner_pattern with append: the
component carries both layers, in that order; with append false it carries only
the function's blue border. Without the arm the append case keeps the single
red layer.

The item test hands set_loot_component a banner_patterns list and reads
`banner_patterns` back as two BannerLayers (pattern and dye), and a null
component empties them again. Without the arm the list is empty - the banner
wears nothing.
2026-11-07 06:49:34 +00:00
MSedgeMC 6ec1865ac1 fix: a looted banner_patterns component dresses the banner
set_loot_component only knew the trim, so a rolled banner_patterns list landed
in `other` as a raw tag: the renderer and the tooltip read
`StackData::banner_patterns`, which stayed empty, and the banner from a
datapack table (or a set_components function) came out undyed. The arm parses
the component with the same `layers_from_nbt` the item's own component uses, so
the layers - pattern and dye - reach the fields the game reads.
2026-11-07 06:49:12 +00:00
MSedgeMC 18cb5eb6d0 fix: set_banner_pattern writes a banner's layers
SetBannerPatternFunction sets the stack's banner_patterns to the function's
layers, or with append adds them to the end of the component's own list
(BannerPatternLayers.Builder.addAll twice). A layer is a pattern id and a dye
name, so the list the table wrote is the component's list as it stands. The arm
was missing, so a datapack's banner pattern changed nothing: the banner came out
bare, or with only the layers an earlier function wrote.
2026-11-07 06:49:12 +00:00
Riccardo GuerrieroandClaude Opus 5.5 bb9e6d6ebf Save a column again when an edit's light changes it
A block edit relit in the frame changes light up to 15 blocks into the
columns around it, but only the edited column was marked unsaved. With
eager saves a neighbour saved a moment earlier kept its old light on disk.
Vanilla's ChunkHolder.sectionLightChanged marks every chunk whose light
section changed unsaved; mark_light_changes now does the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 07:48:55 +01:00
MSedgeMC 66931c6958 port_map: the two set_custom_model_data rows are ported
apply names the ListOperation helper its four lists go through, and run names
the arm that draws the floats and colours and keeps the component the stack
already had. The rows said "no vanilla loot table uses it", which was true but
not a reason for a table that does to change nothing.
2026-11-07 06:46:00 +00:00
MSedgeMC 0ba45ef93e test: the four model-data lists each follow their own operation
A table writes custom_model_data through set_components (floats [1.0], strings
["old"]) and then set_custom_model_data: floats appended from a constant
provider, flags and strings replaced, a colour inserted first. The rolled stack
carries 1.0 and 2.0, [true], ["new"] and [16711680] - without the arm the
component keeps the first function's lists and has no colours at all.

The helper's own test covers what a table rarely reaches: a replace_section of
one element, one with no size (the new values' length), an insert past the end
that changes nothing, and a mode the codec would refuse reading as a
replacement.
2026-11-07 06:46:00 +00:00
MSedgeMC 68e1549ebd fix: set_custom_model_data writes the item's four model-data lists
SetCustomModelDataFunction reads the stack's custom_model_data (EMPTY when it
has none) and replaces each of its four lists the function names - floats and
colors from number providers drawn with the loot random, flags and strings as
written - by that list's ListOperation over what was there: replace_all,
replace_section (offset and an optional size), insert at an offset, or append.
The arm was missing, so a datapack's model data changed nothing at all.

ListOperation itself is a small helper beside the number readers; an offset
past the end leaves the list as it was, as vanilla logs and ignores it there.
The component then reaches the item through set_loot_component's generic arm,
which keeps it in `other` where the model selection, the CIT rules and the LAN
sync already read it - that half needed nothing new.
2026-11-07 06:46:00 +00:00
MSedgeMC b2c9470eb7 port_map: SetBookCoverFunction.apply is ported
The row said no vanilla loot table uses set_book_cover, so nothing was missing;
a datapack's table does reach it, and it now names the arm that applies the
cover and the item-side parse that reads the component back.
2026-11-07 06:22:12 +00:00
MSedgeMC dc8f47b2e8 test: a loot table's cover titles the book, and the item reads it
The loot table test fills a chest with a written book through two modifiers: one
set_components with a title, author and page, then set_book_cover with another
title, an author and generation 2. The cover replaces the title, author and
generation, and the page the other function wrote is still the book's. Without
the arm the component keeps the first title.

The item test hands set_loot_component a written_book_content as the loot engine
builds it and reads the book back: title, author, generation and both pages, and
a null component leaves none. Without the arm the book has no content at all.
2026-11-07 06:22:12 +00:00
MSedgeMC 02dc69348d fix: a looted written_book_content reaches the book's fields
A component a loot function writes goes through StackData::set_loot_component
when the rolled stack becomes an item, and only the trim had an arm: a written
book's content landed in `other` as a raw tag nobody reads, so set_book_cover
(or set_components) titled a book whose tooltip and reading screen still showed
nothing. The content is now parsed the way the item's own component is - the
parse moves out of from_nbt into written_book_from_tag, shared by both - so the
title, author, generation and pages come out of a rolled book.
2026-11-07 06:22:11 +00:00
MSedgeMC 65e6b5ce95 fix: set_book_cover writes a written book's title, author and generation
SetBookCoverFunction updates the stack's written_book_content, over the
component it already carries: the title as a Filterable (the raw text, and the
filtered text when a server filtered it), the author, and the generation, while
the pages and the resolved flag stay as the earlier functions left them. A loot
table's written book got none of that: the modifier ran and changed nothing, so
a datapack book came out with no cover at all.
2026-11-07 06:22:11 +00:00
Florian ERNSTandCodex 3db0da6dd2 fix: resolve named predicates through the Game context
Bind loaded predicate references without changing source identity or origin. Defensively reject invalid reference graphs before boolean evaluation while keeping registry admission separate.

Co-Authored-By: Codex <[email protected]>
2026-11-07 04:24:58 +01:00
TheMrZZandCodex gpt-6.1-sol 2aa319becc Emit the final ordinary guest container close game event
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 03:13:16 +01:00
TheMrZZandCodex gpt-6.1-sol bca4a22e08 fix: project live player motion fields into saved data
Carry the active Player grace time and fall-particle flag through the existing
save context, restore owner snapshots after guest calls, and write the official
empty player Brain schema. Keep tracker projection outside this change.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 02:51:56 +01:00
TheMrZZandCodex gpt-6.1-sol 8bb5a572ec Advance eligible TraderLlama despawn delay with NoAI
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-07 00:42:42 +01:00
baytyoneandZCode 91b3a7410e Experience orbs: pickup over the vehicle, a kept following player, merges before the move, eye-level water
Vanilla's Player.aiStep widens the pickup area to the union with the vehicle's box while riding; ExperienceOrb.tick keeps its followingPlayer until they leave 8 blocks, runs scanForMerges in its own turn before the move, and floats by the eye's fluid rather than the feet's block. Fixes #11389.

Co-Authored-By: ZCode (GLM) <[email protected]>
2026-11-07 04:12:29 +05:00
MSedgeMC b918309242 port_map: a vindicator's Johnny flag is latched, saved and read
`tools/port_map/game2-004.tsv` recorded
`Vindicator.addAdditionalSaveData` as `missing` and `Vindicator.setCustomName`'s
gap as "the Johnny goal reads the current custom name each tick; vanilla latches
the flag once a name is Johnny". Both rows now name what the port does, with a
row for `readAdditionalSaveData` beside them.
2026-11-06 23:04:15 +00:00
MSedgeMC 68f5909ef6 test: a vindicator's Johnny latch through a rename and a save
`vindicator_server_ai_step` is the AI step, and `mob_to_nbt`/`mob_from_nbt` are
the pair `Game::mob_tree_nbt` and `Game::load_mob` go through. A vindicator
named Bob is not Johnny; named Johnny it is; renamed to Bob it stays Johnny.
The save writes `Johnny: 1b`, the reloaded vindicator is still Johnny with the
name Bob, and a compound with no `Johnny` (its `CustomName` reading Johnny)
loads false and saves `Johnny: 0b`.

Without the latch the test stops at the first name: `left: false`, `right:
true`.
2026-11-06 23:04:15 +00:00
MSedgeMC 995a99b984 fix: a vindicator that was named Johnny stays Johnny
`Vindicator.setCustomName` latches a flag the first time the name it is given is
`Johnny`, and nothing ever clears it:

    public void setCustomName(@Nullable Component name) {
       super.setCustomName(name);
       if (!this.isJohnny && name != null && name.getString().equals("Johnny")) {
          this.isJohnny = true;
       }
    }

`VindicatorJohnnyAttackGoal` starts for `isJohnny` (`addAdditionalSaveData`
writes it, `readAdditionalSaveData` reads it back with `getBooleanOr("Johnny",
false)`), so a vindicator renamed from Johnny to Bob keeps attacking every
living entity, and one whose save says `Johnny: 1b` does so after a reload.

Horizon read the live custom name in the goal's `when` each tick and saved no
flag, so a renamed vindicator stopped attacking everything, and a reloaded
vindicator that had been named Johnny but is now Bob never started.

`extra.johnny` is the latch, taken from the live name in
`vindicator_server_ai_step` (the AI step is where a name set since the last step
is seen, and it never clears), read by the goal, written as `Johnny` and read
back from it. A save with no `Johnny` loads false, whatever the name in the
compound, as `getBooleanOr` does.
2026-11-06 23:04:14 +00:00
MSedgeMC 6a5fc04295 test: a re-saved section writes only the states a cell holds
Both cases read the emitted tag, so they fail on the live palette: 342 longs
of `data` where vanilla's `SimpleBitStorage(4, 4096)` is 256, a palette of
`[0, 1, … 16]` where first use is `[1, … 15, 0]`, and `(2, Some(256))` where a
section edited back to one state is `(1, None)`.
2026-11-06 22:49:30 +00:00
MSedgeMC 5dc59396d6 fix: save a chunk section's palette as vanilla packs it
`PalettedContainer.pack` re-encodes the cells into a fresh palette before a
section is written: a state no cell holds any more is dropped, the entries are
in first-use order, and the bit count is `getConfigurationForPaletteSize` of
the states that remain. `PalettedContainer::to_nbt` wrote the live palette
instead, which keeps the slot of every state a block edit replaced (the `free`
list needs it) and packs at that wider count: a section whose 17th state was
edited away saved 342 longs where vanilla writes 256, and a section edited back
to a single state saved a 2-entry palette with a 256-long `data` where vanilla
writes one entry and no `data`.
2026-11-06 22:47:00 +00:00
Delete228andCodex 5c591b13a4 Merge Horizon main into the PR branch
Co-authored-by: Codex <[email protected]>
2026-11-07 00:45:54 +03:00
137486c83a Save changed chunks while playing to reduce work when leaving a world
Follow vanilla's eager chunk queue, cooldown and per-tick/time/write limits.
Keep save state with its level and respect disabled saves and server pauses.
Save only lit (full) chunks eagerly and only once a world is open; an unlit
one keeps its place in the queue.
Move proto NBT encoding to the region writer without changing its encoder.
Cover selection regressions and repeated edits reaching real region files.

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:46:12 +01:00
TheMrZZandCodex gpt-6.1-sol fec7a5f9e6 Respect class-owned container fields in vehicle saves
Keep plain vehicles free of container fields and save pending boat tables
instead of Items. Normalize recognized pending keys while preserving the
existing cart loot restoration path.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 20:28:32 +01:00
m_uvexandPluto 41d699a458 packaging: include native mod build dependencies in launcher FHS
Adds gcc, glibc.dev, pkg-config, alsa-lib.dev, and udev.dev to the FHS
environment so native mod compilation works on NixOS without external shell.

Co-Authored-By: Pluto <[email protected]>
2026-11-06 22:10:15 +03:00
m_uvexandPluto 160e997e4a packaging: add flake.nix for Horizon launcher and devShell
Provides a Nix flake packaging the Horizon launcher in an FHS environment
along with a development shell containing dependencies for building the project.

Co-Authored-By: Pluto <[email protected]>
2026-11-06 18:21:05 +03:00
Delete228andCodex d2cb5a272b Compile state-dependent light emission from content declarations
Resolve typed state_light selectors once into ordinary gameplay emission rows while preserving scalar light and template defaults. Reject invalid and uncovered selector values. Verify native facts readback, cache invalidation, and real cross-chunk lighting transitions.

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

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

No files matched your search

+14 -2
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.
@@ -25,7 +36,8 @@
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". -->
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
the documentation pages and the packs you used. -->
## Self-check
+1 -1
View File
@@ -13,4 +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
+5
View File
@@ -291,6 +291,11 @@ jobs:
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
if [[ "${{ matrix.name }}" == windows ]]; then
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
fi
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
+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`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `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` |
+37 -2
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/`):
@@ -76,7 +86,18 @@ another project's source may also be read and used, **only when that project's l
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
names its sources: the sections of that specification, the documentation pages and the packs it ran.
## 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
@@ -114,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
@@ -137,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
+22
View File
@@ -1374,6 +1374,10 @@ dependencies = [
"digest",
]
[[package]]
name = "horizon-addresses"
version = "0.1.0"
[[package]]
name = "horizon-anticheat"
version = "0.1.0"
@@ -1588,9 +1592,11 @@ dependencies = [
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1640,6 +1646,7 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"glam",
"horizon-addresses",
"png",
"serde",
"serde_json",
@@ -1662,6 +1669,7 @@ dependencies = [
"chacha20poly1305",
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"if-addrs",
"serde",
"serde_json",
@@ -1771,10 +1779,18 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-system",
]
[[package]]
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"log",
"regex",
"serde_json",
]
@@ -1907,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)
+28 -1
View File
@@ -63,6 +63,32 @@ variable's array specifier does not change later variables.
It also reads a struct member outside a constant expression through a pointer to the member,
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
## DirectX Shader Compiler
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
- Only x64: the Windows SDK's list of distributable files
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
wgpu never does.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
@@ -312,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";
+53
View File
@@ -14,6 +14,8 @@ pub enum PistonPart {
Entity,
State,
Properties,
/// Any other block entity's whole compound (`BlockEntity.saveWithFullMetadata`).
BlockEntity,
}
struct Pretty {
@@ -255,6 +257,20 @@ fn piston_hash_order(
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
}
PistonPart::State => return java_hash_order(c),
PistonPart::BlockEntity => {
// saveAdditional's fields, then `components`, then saveMetadata's
// id and position; the fields keep the default order among them.
const METADATA: [&str; 5] = ["components", "id", "x", "y", "z"];
let mut entries = java_hash_order(c);
entries.sort_by_key(|(k, _)| {
let key = string_of(k);
(
hash_bucket(c.len(), k),
METADATA.iter().position(|m| *m == key).map_or(0, |i| i + 1),
)
});
return entries;
}
}
entries
}
@@ -313,6 +329,14 @@ pub fn java_hash_order(
"NoBasePlate",
"Marker",
"Pose",
// Mannequin.addAdditionalSaveData.
"profile",
"hidden_layers",
"main_hand",
"pose",
"immovable",
"description",
"hide_description",
];
// BlockEntity.saveMetadata writes the position after the entity's data.
const AFTER: &[&str] = &["x", "y", "z"];
@@ -397,6 +421,21 @@ mod tests {
assert_eq!(pretty(&tag).plain(), ordinary);
}
#[test]
fn mannequin_compound_orders_pose_before_immovable_in_shared_bucket() {
let tag = parse_snbt(
r#"{AbsorptionAmount:0.0f,Air:300s,Brain:{memories:{}},DeathTime:0s,FallFlying:0b,Fire:0s,Health:20.0f,HurtTime:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[2.5d,100.0d,2.5d],Rotation:[0.0f,0.0f],UUID:[I;369,0,0,1],attributes:[],current_impulse_context_reset_grace_time:0,description:{text:"Guard"},hide_description:1b,immovable:1b,pose:"crouching",profile:{name:"alex"}}"#,
)
.unwrap()
.0;
let rendered = pretty(&tag).plain();
let pose_idx = rendered.find("pose: \"crouching\"").unwrap();
let immovable_idx = rendered.find("immovable: 1b").unwrap();
assert!(
pose_idx < immovable_idx,
"expected `pose` before `immovable` in bucket 5, got: {rendered}"
);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
@@ -441,4 +480,18 @@ mod tests {
let long = parse_snbt("{l:[{a:1},{b:2}]}").unwrap().0;
assert_eq!(pretty(&long).plain(), "{l: [{a: 1}, {b: 2}]}");
}
#[test]
fn block_entity_metadata_follows_its_fields() {
// A conduit's `/data get block`, as vanilla 26.3 printed it: `Target`
// and `components` share a bucket, and saveAdditional wrote `Target` first.
let tag = parse_snbt(
r#"{id:"minecraft:conduit",x:4098,y:300,z:4098,components:{},Target:[I;1,2,3,4]}"#,
)
.unwrap()
.0;
assert_eq!(
pretty_piston(&tag, PistonPart::BlockEntity).plain(),
r#"{Target: [I; 1, 2, 3, 4], components: {}, x: 4098, y: 300, z: 4098, id: "minecraft:conduit"}"#,
);
}
}
+100 -54
View File
@@ -82,49 +82,6 @@ impl Button {
fn bit(self) -> u32 {
1 << self as u32
}
/// What is printed on the button for a pad family (`Auto` reads as Xbox).
pub fn label(self, style: GlyphStyle) -> &'static str {
let i = self as usize;
match style {
GlyphStyle::PlayStation => [
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑",
"↓", "←", "→",
][i],
GlyphStyle::Nintendo => [
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
][i],
GlyphStyle::Xbox | GlyphStyle::Auto => [
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓",
"←", "→",
][i],
}
}
/// The glyph's colour (RGB): face buttons in their family's colours,
/// the rest white.
pub fn color(self, style: GlyphStyle) -> [u8; 3] {
let face = match self {
Button::South => 0,
Button::East => 1,
Button::West => 2,
Button::North => 3,
_ => return [255, 255, 255],
};
match style {
GlyphStyle::PlayStation => [
[124, 178, 232],
[235, 110, 110],
[222, 145, 205],
[92, 205, 170],
][face],
GlyphStyle::Nintendo => [255, 255, 255],
GlyphStyle::Xbox | GlyphStyle::Auto => [
[110, 200, 90],
[235, 90, 80],
[80, 150, 240],
[240, 200, 60],
][face],
}
}
/// The face button that plays this one's part on a Nintendo pad going by its letters
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
@@ -427,6 +384,101 @@ impl HeldDrop {
}
}
/// A job a menu's fixed buttons do. Menus aren't rebindable: this is the one map from a job to
/// its button, and a glyph for a menu asks for the job (`GlyphOf::Menu`), never the button.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuJob {
/// A: press, take, type.
Confirm,
/// B: back, close.
Back,
/// X: the screen's second job (Apply, Options, half a stack, delete a letter).
Alternate,
/// Y: the screen's third job (the exit, a quick move, a space).
Extra,
/// LB: the page or tab before.
PagePrev,
/// RB: the page or tab after.
PageNext,
/// LT: the group before; held, the modifier (shift, direct craft).
GroupPrev,
/// RT: the group after.
GroupNext,
/// View: throw.
View,
/// Menu (Start): done, and cancelling a binding.
Start,
/// The left stick pressed in.
LeftStick,
}
impl MenuJob {
pub const ALL: [MenuJob; 11] = [
MenuJob::Confirm,
MenuJob::Back,
MenuJob::Alternate,
MenuJob::Extra,
MenuJob::PagePrev,
MenuJob::PageNext,
MenuJob::GroupPrev,
MenuJob::GroupNext,
MenuJob::View,
MenuJob::Start,
MenuJob::LeftStick,
];
/// The part that does the job (by place; a pad going by its letters is read so the part
/// still lands: `PadState::by_letter`).
pub const fn button(self) -> Button {
match self {
MenuJob::Confirm => Button::South,
MenuJob::Back => Button::East,
MenuJob::Alternate => Button::West,
MenuJob::Extra => Button::North,
MenuJob::PagePrev => Button::LeftBumper,
MenuJob::PageNext => Button::RightBumper,
MenuJob::GroupPrev => Button::LeftTrigger,
MenuJob::GroupNext => Button::RightTrigger,
MenuJob::View => Button::Select,
MenuJob::Start => Button::Start,
MenuJob::LeftStick => Button::LeftStick,
}
}
/// The job a part does in menus.
pub fn of(button: Button) -> Option<MenuJob> {
Self::ALL.into_iter().find(|job| job.button() == button)
}
}
/// What a glyph stands for: what the button does, never the button. `pad_glyph::Glyph` is built
/// from one, so a rebinding or a pad's letters reach every glyph.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GlyphOf {
/// A gameplay action (`key.jump`...), on the player's binding.
Bound(&'static str),
/// A menu's fixed job.
Menu(MenuJob),
}
impl From<MenuJob> for GlyphOf {
fn from(job: MenuJob) -> Self {
GlyphOf::Menu(job)
}
}
impl GlyphOf {
/// The part that does it under these settings; none while the action is unbound.
pub fn button(self, settings: &PadSettings) -> Option<Button> {
match self {
GlyphOf::Bound(action) => {
// A misspelt action would quietly have no glyph.
debug_assert!(
ACTIONS.iter().any(|a| a.0 == action),
"no pad action {action}"
);
settings.binding(action)
}
GlyphOf::Menu(job) => Some(job.button()),
}
}
}
/// Which button pictures prompts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GlyphStyle {
@@ -635,6 +687,10 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl PadSettings {
/// A pad of `family` goes by its letters: only a Nintendo pad has letters to go by.
pub fn by_letter(&self, family: GlyphStyle) -> bool {
self.nintendo_letters && family == GlyphStyle::Nintendo
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -1921,11 +1977,6 @@ bind_key.hotbar.2:north
assert!(parts.held(Button::LeftBumper));
assert_eq!(parts.left, [0.5, -0.25]);
assert_eq!(parts.by_letter(), pad);
// A part's glyph is the letter of the button playing it.
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
}
#[test]
@@ -2353,12 +2404,7 @@ bind_key.hotbar.2:north
}
#[test]
fn labels_and_conflicts() {
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
assert_eq!(Button::South.label(GlyphStyle::PlayStation), "×");
assert_eq!(Button::South.label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::RightTrigger.label(GlyphStyle::PlayStation), "R2");
assert_eq!(Button::DPadLeft.label(GlyphStyle::Xbox), "←");
fn styles_resolve_and_conflicts() {
assert_eq!(
GlyphStyle::Auto.resolve(GlyphStyle::PlayStation),
GlyphStyle::PlayStation
@@ -2,7 +2,7 @@
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
//! places the rows around what was drawn.
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
use crate::gamepad::{Button, GlyphOf, GuideDetail, MenuJob, PadSettings, Rect};
/// What a use does, by what takes it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -100,26 +100,27 @@ pub struct Facts {
pub off_hand_item: bool,
}
/// One button's line: the button and the translation key of what it does.
/// One button's line: what the button does (its glyph follows the binding and the pad's letters)
/// and the translation key of the caption.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Row {
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
pub with: Option<Button>,
pub button: Button,
/// Shown before `of`: held with it, or its other half (LB and RB for pages).
pub with: Option<GlyphOf>,
pub of: GlyphOf,
pub caption: &'static str,
}
impl Row {
fn new(button: Button, caption: &'static str) -> Self {
fn new(of: impl Into<GlyphOf>, caption: &'static str) -> Self {
Row {
with: None,
button,
of: of.into(),
caption,
}
}
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
fn pair(with: MenuJob, job: MenuJob, caption: &'static str) -> Self {
Row {
with: Some(with),
button,
with: Some(GlyphOf::Menu(with)),
of: GlyphOf::Menu(job),
caption,
}
}
@@ -155,11 +156,11 @@ impl Guide {
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
/// on Start, which is always pause.
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
let row = |action: &str, caption: Option<&'static str>| {
let button = settings
let row = |action: &'static str, caption: Option<&'static str>| {
settings
.binding(action)
.filter(|button| *button != Button::Start)?;
Some(Row::new(button, caption?))
Some(Row::new(GlyphOf::Bound(action), caption?))
};
let player = !facts.spectator;
let any_item = facts.main_hand_item || facts.off_hand_item;
@@ -308,10 +309,10 @@ pub struct MenuFacts {
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name menu jobs.
pub fn resolve_menu(f: &MenuFacts) -> Guide {
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
use Button::{Select, South, West};
use MenuJob::{Alternate, Back, Confirm, Extra, GroupNext, GroupPrev};
use MenuJob::{LeftStick, PageNext, PagePrev, View};
const TAKE: &str = "horizon.controller.guide.take";
const MOVE: &str = "horizon.controller.guide.quick_move";
let under = match f.under {
@@ -321,25 +322,25 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
result: true,
same,
..
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
} if !f.carrying || same => vec![Row::new(Confirm, TAKE), Row::new(Extra, MOVE)],
Under::Slot { result: true, .. } => Vec::new(),
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
Under::Slot { item, same, .. } if f.carrying => vec![
Row::new(
South,
Confirm,
if item && !same {
"horizon.controller.guide.swap_stack"
} else {
"horizon.controller.guide.put"
},
),
Row::new(West, "horizon.controller.guide.put_one"),
Row::new(Alternate, "horizon.controller.guide.put_one"),
],
Under::Slot { item: true, .. } => vec![
Row::new(South, TAKE),
Row::new(West, "horizon.controller.guide.take_half"),
Row::new(North, MOVE),
Row::new(Select, "horizon.controller.guide.drop"),
Row::new(Confirm, TAKE),
Row::new(Alternate, "horizon.controller.guide.take_half"),
Row::new(Extra, MOVE),
Row::new(View, "horizon.controller.guide.drop"),
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
],
Under::Nothing | Under::Slot { .. } => Vec::new(),
@@ -347,43 +348,44 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
direct_craft,
alternatives,
} => [
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
Some(Row::new(Confirm, "horizon.controller.guide.place_recipe")),
direct_craft.then_some(Row::pair(
LeftTrigger,
South,
GroupPrev,
Confirm,
"horizon.controller.direct_craft",
)),
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
Some(Row::new(Extra, "horizon.controller.guide.place_recipe_all")),
alternatives.then_some(Row::new(Alternate, "horizon.controller.guide.alternatives")),
]
.into_iter()
.flatten()
.collect(),
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
Under::Control => vec![Row::new(Confirm, "horizon.controller.guide.select")],
};
let screen = [
f.book.then_some(Row::pair(
LeftBumper,
RightBumper,
PagePrev,
PageNext,
"horizon.controller.guide.pages",
)),
f.book.then_some(Row::pair(
LeftTrigger,
RightTrigger,
GroupPrev,
GroupNext,
"horizon.controller.guide.categories",
)),
(f.tabs && !f.book).then_some(Row::pair(
LeftBumper,
RightBumper,
PagePrev,
PageNext,
"horizon.controller.guide.tabs",
)),
];
let close = Row::new(East, "horizon.controller.guide.close");
let close = Row::new(Back, "horizon.controller.guide.close");
let rows = std::iter::once(close)
.chain(under)
.chain(screen.into_iter().flatten());
let (right, left) =
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
let (right, left) = rows.partition(|r| {
r.with.is_none() && matches!(r.of, GlyphOf::Menu(Confirm | Back | Alternate | Extra))
});
Guide { left, right }
}
@@ -495,8 +497,11 @@ mod tests {
..f
}
}
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
rows.iter().map(|r| (r.button, r.caption)).collect()
/// Each row's button on these settings, and its caption.
fn rows(rows: &[Row], settings: &PadSettings) -> Vec<(Button, &'static str)> {
rows.iter()
.map(|r| (r.of.button(settings).unwrap(), r.caption))
.collect()
}
fn captions(rows: &[Row]) -> Vec<&'static str> {
rows.iter().map(|r| r.caption).collect()
@@ -509,9 +514,10 @@ mod tests {
item_use: Some(Verb::Place),
..on_block(walking(), true, None)
};
let guide = resolve(&facts, &PadSettings::default());
let settings = PadSettings::default();
let guide = resolve(&facts, &settings);
assert_eq!(
rows(&guide.right),
rows(&guide.right, &settings),
[
(Button::RightTrigger, "horizon.controller.guide.break"),
(Button::LeftTrigger, "horizon.controller.guide.place"),
@@ -519,7 +525,7 @@ mod tests {
]
);
assert_eq!(
rows(&guide.left),
rows(&guide.left, &settings),
[
(Button::South, "key.jump"),
(Button::RightStick, "key.sneak"),
@@ -543,7 +549,7 @@ mod tests {
};
let guide = resolve(&facts, &settings);
assert_eq!(
rows(&guide.right),
rows(&guide.right, &settings),
[(Button::North, "horizon.controller.guide.break")]
);
// Start only ever pauses.
@@ -845,8 +851,8 @@ mod tests {
["close", "place_recipe", "place_recipe_all", "alternatives"]
);
assert_eq!(recipe.left.len(), 1);
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
assert_eq!(recipe.left[0].button, Button::South);
assert_eq!(recipe.left[0].with, Some(GlyphOf::Menu(MenuJob::GroupPrev)));
assert_eq!(recipe.left[0].of, GlyphOf::Menu(MenuJob::Confirm));
let plain = on(
Under::Recipe {
direct_craft: false,
@@ -892,8 +898,8 @@ mod detail_tests {
fn rows() -> Guide {
Guide {
left: vec![Row::new(Button::South, "left")],
right: vec![Row::new(Button::East, "right")],
left: vec![Row::new(MenuJob::Confirm, "left")],
right: vec![Row::new(MenuJob::Back, "right")],
}
}
@@ -927,7 +933,7 @@ mod detail_tests {
mod slot_tests {
use super::*;
/// The rows for the slot: South or West, whichever column they're in.
/// The rows for the slot: Confirm or Alternate, whichever column they're in.
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
let guide = resolve_menu(&MenuFacts {
under: Under::Slot {
@@ -942,7 +948,7 @@ mod slot_tests {
[guide.left, guide.right]
.iter()
.flatten()
.filter(|row| matches!(row.button, Button::South | Button::West))
.filter(|row| matches!(row.of, GlyphOf::Menu(MenuJob::Confirm | MenuJob::Alternate)))
.count()
}
+2 -2
View File
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
+2
View File
@@ -23,9 +23,11 @@ horizon-narrator = { path = "../horizon-narrator" }
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
# (connected.rs) and custom item texture rules (cit.rs).
horizon-optifine = { path = "../horizon-optifine" }
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(
@@ -0,0 +1,480 @@
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
use client::{
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
graphics::GraphicsApi,
hud::HudBatch,
mesher::{SectionMesh, TerrainVertex, Vertex},
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
/// What the mod below gives the renderer each frame.
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
struct Mod;
impl native_render::RenderHooks for Mod {
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
if let Some(warp) = WARP.lock().unwrap().as_ref() {
*event.warp = warp.clone();
}
}
}
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let here = pos.x + warp.camera.x + warp.camera_rest.x;
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
return Warped(pos, vec3(1.0));
}
";
/// Cuts every face.
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(-1.0));
}
";
/// Paints the terrain green by its pixels and moves nothing.
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos, vec3(1.0));
}
// [fragments]
fn world_fragment(f: Fragment) -> Painted {
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
}
";
/// Moves entities ten blocks right: off the screen's middle.
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
}
";
fn read(path: &Path) -> Vec<[u8; 4]> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes.as_chunks::<4>().0.to_vec()
}
/// Pixels like `want` (within 40 a channel), of an image.
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
pixels
.iter()
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
.count()
}
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
fn wall() -> SectionMesh {
let mut mesh = SectionMesh::default();
let corners = [
[0., 0., 12.],
[0., 16., 12.],
[16., 16., 12.],
[16., 0., 12.],
];
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
for i in order {
mesh.solid
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
}
}
mesh.sort_solid();
mesh
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
renderer.fullbright = true;
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
renderer.upload((0, 10, 0), &wall());
// an entity quad in front of the wall, middle of the screen
let entity: Vec<Vertex> = [
[7.5, 103.5, 6.0],
[7.5, 104.5, 6.0],
[8.5, 104.5, 6.0],
[8.5, 103.5, 6.0],
]
.into_iter()
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
.map(|(p, uv)| Vertex {
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
uv,
layer: 0,
color: 0xff40_40ff,
light: 0xf0,
normal: [0, 0, 0, 0],
glint_uv: [0.0; 2],
})
.collect();
if std::env::args().any(|a| a == "--bench") {
return bench(&mut renderer, out);
}
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.entities = &entity;
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
let path = out.join(format!("{name}.png"));
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
read(&path)
};
let warp = |source: &str, on: bool, lift: f32| {
let mut w = native_render::WorldWarp::default();
w.source = Some(source.into());
w.on = on;
w.params[0] = lift;
Some(w)
};
let plain = shot("plain", None);
let wall_px = count(&plain, [255, 255, 255]);
let entity_px = count(&plain, [255, 64, 64]);
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
assert!(
entity_px > 50,
"the entity shows without a warp: {entity_px}"
);
let off = shot("off", warp(LIFT, false, 4.0));
assert!(off == plain, "a warp that is off draws as no warp");
let lifted = shot("lift", warp(LIFT, true, 4.0));
let red = count(&lifted, [255, 0, 0]);
assert!(red > 500, "the warp tints the wall's left half red: {red}");
assert!(lifted != plain);
let cut = shot("cut", warp(CUT, true, 0.0));
let left = count(&cut, [255, 255, 255]);
assert!(
left < wall_px / 20,
"a cut leaves the wall out: {left} of {wall_px}"
);
let painted = shot("paint", warp(PAINT, true, 0.0));
let green = count(&painted, [0, 255, 0]);
assert!(
green > wall_px / 2,
"the warp's own pixels paint the wall: {green}"
);
let shifted = shot("shift", warp(SHIFT, true, 0.0));
let entity_left = count(&shifted[..], [255, 64, 64]);
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
(60..120)
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
.collect()
};
assert!(
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
);
let broken = shot(
"broken",
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
);
assert!(broken == plain, "a warp that doesn't compile is left out");
let back = shot("after", None);
assert!(back == plain, "with the warp gone the frame is as before");
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
"cut_wall_pixels_left": left, "painted_green_pixels": green,
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
}))?,
)?;
println!(
"world warp check passed: {}",
out.join("result.json").display()
);
Ok(())
}
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
fn hills(cx: i32, cz: i32) -> SectionMesh {
let height = |x: i32, z: i32| {
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
};
let mut mesh = SectionMesh::default();
let mut quad = |c: [[f32; 3]; 4]| {
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
mesh.solid
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
}
};
for x in 0..16 {
for z in 0..16 {
let h = height(x, z);
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
quad([
[fx, fh, fz],
[fx, fh, fz + 1.],
[fx + 1., fh, fz + 1.],
[fx + 1., fh, fz],
]);
// the step down to a lower neighbour, toward +x and +z
for (dx, dz) in [(1, 0), (0, 1)] {
let n = height(x + dx, z + dz);
if n < h {
let (lo, hi) = (n as f32, fh);
if dx == 1 {
quad([
[fx + 1., lo, fz],
[fx + 1., hi, fz],
[fx + 1., hi, fz + 1.],
[fx + 1., lo, fz + 1.],
]);
} else {
quad([
[fx, lo, fz + 1.],
[fx + 1., lo, fz + 1.],
[fx + 1., hi, fz + 1.],
[fx, hi, fz + 1.],
]);
}
}
}
}
}
mesh.sort_solid();
mesh
}
/// `--bench`: milliseconds a frame without a warp and with one.
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
const R: i32 = 24;
renderer.resize(1920, 1080);
let mut sections = 0;
for cx in -R..=R {
for cz in -R..=R {
// (keys count sections from y -64: 8 is y 64 to 80)
renderer.upload((cx, 8, cz), &hills(cx, cz));
sections += 1;
}
}
let hud = HudBatch::new([1920, 1080]);
let mut frame = fixture_frame(&hud);
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
frame.eye = frame.camera;
frame.pitch = 15.0;
frame.eye_rotation = [0.0, 15.0];
frame.distance = 16.0 * R as f32;
frame.detail_distance = frame.distance;
frame.depth_far = 2000.0;
frame.cave_culling = true;
let mut time = |warp: Option<native_render::WorldWarp>| {
*WARP.lock().unwrap() = warp;
for _ in 0..30 {
renderer.render(&frame).unwrap();
}
let started = std::time::Instant::now();
const FRAMES: u32 = 240;
for _ in 0..FRAMES {
renderer.render(&frame).unwrap();
}
// the last frame's picture, so the GPU has caught up
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("bench.png"),
size: None,
scaled: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
};
let mut wave = native_render::WorldWarp::default();
wave.source = Some(
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let x = pos.x + warp.camera.x + warp.camera_rest.x;
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
}\n"
.into(),
);
wave.on = true;
let plain = time(None);
let warped = time(Some(wave));
let again = time(None);
let result = serde_json::json!({
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
"frame_ms_without_warp_after": again,
"graphics_adapter": client::crash::system_details("bench").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
});
println!("{result}");
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("world warp check failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(8.0, 104.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(8.0, 104.0, 0.5),
eye_rotation: [0.0, 0.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 128.0,
detail_distance: 128.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: false,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
boss_world_fog: false,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
+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
+6 -1
View File
@@ -191,13 +191,18 @@ pub fn discovery_url() -> String {
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
}
fn agent() -> ureq::Agent {
pub(crate) fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build()
}
/// Whether `HORIZON_API_DISCOVERY` names another services environment.
pub fn discovery_overridden() -> bool {
discovery_url() != DEFAULT_DISCOVERY
}
/// The discovery document, fetched once per game (a failure is retried next time).
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
+36 -2
View File
@@ -17,11 +17,15 @@ pub struct BanDetails {
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen reads it.
/// authlib's `UserProperties`, as far as the title screen and the friends service read it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `UserFlag.FRIENDS_ENABLED` (`friendsPreferences.friends`).
pub friends_enabled: bool,
/// `UserFlag.ACCEPT_FRIEND_INVITES` (`friendsPreferences.acceptInvites`).
pub accept_friend_invites: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
@@ -30,6 +34,8 @@ impl Default for UserProperties {
fn default() -> Self {
Self {
servers_allowed: true,
friends_enabled: false,
accept_friend_invites: false,
multiplayer_ban: None,
}
}
@@ -209,6 +215,13 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let toggle = |name: &str| {
v.pointer(&format!("/friendsPreferences/{name}"))
.and_then(|f| f.as_str())
.is_some_and(|f| f.eq_ignore_ascii_case("ENABLED"))
};
let friends_enabled = toggle("friends");
let accept_friend_invites = toggle("acceptInvites");
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
@@ -222,6 +235,8 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
});
Some(UserProperties {
servers_allowed,
friends_enabled,
accept_friend_invites,
multiplayer_ban,
})
}
@@ -275,6 +290,24 @@ mod tests {
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn friends_preferences_read_their_two_flags() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":true}},
"friendsPreferences":{"friends":"ENABLED","acceptInvites":"DISABLED"}}"#;
let p = parse_properties(body).unwrap();
assert!(p.friends_enabled);
assert!(!p.accept_friend_invites);
let other = parse_properties(
r#"{"friendsPreferences":{"friends":"DISABLED","acceptInvites":"ENABLED"}}"#,
)
.unwrap();
assert!(!other.friends_enabled);
assert!(other.accept_friend_invites);
// The offline fallback has neither flag, as `OFFLINE_PROPERTIES` does.
assert!(!UserProperties::default().friends_enabled);
assert!(!UserProperties::default().accept_friend_invites);
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
@@ -297,6 +330,7 @@ mod tests {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
..Default::default()
},
actions: ProfileActions {
forced_name_change: true,
@@ -317,7 +351,7 @@ mod tests {
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
multiplayer_ban: None,
..Default::default()
},
..Default::default()
};
+62
View File
@@ -112,6 +112,9 @@ pub struct BiomeInfo {
/// `BiomeGenerationSettings.features`: the placed feature ids of each
/// `GenerationStep.Decoration` step, in order.
pub features: Vec<Vec<String>>,
/// The biome's environment attributes (`Biome.getAttributes`): its layer in the
/// environment attribute stack.
pub attributes: horizon_world::environment_attributes::AttributeMap,
}
impl BiomeInfo {
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
@@ -277,6 +280,11 @@ impl BiomeInfo {
.collect()
})
.collect(),
attributes: horizon_world::environment_attributes::AttributeMap::parse(attributes)
.unwrap_or_else(|e| {
log::warn!("biome attributes: {e}");
Default::default()
}),
name,
}
}
@@ -393,6 +401,17 @@ impl Biomes {
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
/// The name of biome `id`, plains (as for an unknown name) when the id is
/// past this table: a column a server sent under its earlier, longer
/// registry (a proxy's previous server) keeps its ids until the next
/// login replaces it. `get` clamps such an id to the last entry instead,
/// so for that stale column the colours and this name can come from
/// different biomes until the login.
pub fn name(&self, id: u16) -> &str {
self.list
.get(usize::from(id))
.map_or("minecraft:plains", |b| b.name.as_str())
}
pub fn grass(&self, id: u16, x: f64, z: f64) -> u32 {
let b = self.get(id);
match b.modifier {
@@ -624,6 +643,49 @@ mod pack_tests {
assert_eq!(other.get(2).water, 0x3F76E4);
assert!(!other.has_packs());
}
/// A column keeps the ids of the table it was decoded with. When a
/// proxy's next server lacks a biome the last one added, that id is past
/// the next table, and it names plains instead of indexing past the end
/// (#12148: "the len is 67 but the index is 67").
#[test]
fn an_id_past_the_table_names_plains() {
let next = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
let last = next.with_packs([("limbo:void", &json!({}))]);
let stale = last.id("limbo:void");
assert_eq!((stale, last.name(stale)), (3, "limbo:void"));
assert_eq!(next.name(stale), "minecraft:plains");
assert_eq!(next.name(u16::MAX), "minecraft:plains");
assert_eq!(next.name(2), "minecraft:river");
}
}
#[cfg(test)]
impl Biomes {
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
let maps = Colormaps {
grass: Vec::new(),
foliage: Vec::new(),
dry: Vec::new(),
};
let list: Vec<_> = entries
.iter()
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
.collect();
Self {
builtin: Arc::new(list.clone()),
index: list
.iter()
.enumerate()
.map(|(i, b)| (b.name.clone(), i as u16))
.collect(),
list,
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
colormaps: Arc::new(maps),
packs: BTreeMap::new(),
}
}
}
#[cfg(test)]
@@ -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;
};
@@ -37,6 +37,37 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
format!("item.minecraft.{prefix}.effect.{suffix}")
}
/// `PotionItem.getName` and `TippedArrowItem.getName`: a potion or a tipped
/// arrow is named by its contents (`PotionContents.getName`: their own
/// `custom_name`, else the potion's name, else `empty`), whatever its
/// `item_name`. None for other items, and for a stack whose contents were
/// taken off (`!minecraft:potion_contents`), which takes the item's name.
pub fn contents_name_key(stack: &ItemStack) -> Option<String> {
use horizon_world::nbt::Tag;
let item = &*stack.item;
let named = matches!(
item,
"minecraft:potion"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:tipped_arrow"
);
let data = stack.data();
if !named || data.is_some_and(|d| d.other("!minecraft:potion_contents").is_some()) {
return None;
}
let prefix = item.trim_start_matches("minecraft:");
let own = data
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_name"))
.and_then(|(_, name)| match name {
Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
Some(match own {
Some(suffix) => format!("item.minecraft.{prefix}.effect.{suffix}"),
None => potion_name_key(item, stack.potion()),
})
}
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
@@ -469,6 +500,40 @@ mod tests {
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
"item.minecraft.splash_potion.effect.swiftness"
);
// A potion or a tipped arrow is named by its contents, over its
// item_name; one whose contents were taken off is not.
use horizon_world::nbt::Tag;
let mut swift =
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:long_swiftness");
swift
.data_mut()
.set_other("minecraft:item_name", Some(Tag::String("Bottle".into())));
assert_eq!(
contents_name_key(&swift).as_deref(),
Some("item.minecraft.potion.effect.swiftness")
);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:splash_potion", 1)).as_deref(),
Some("item.minecraft.splash_potion.effect.empty")
);
let mut own = ItemStack::new("minecraft:tipped_arrow", 1).with_potion("minecraft:poison");
own.data_mut()
.potion_extra
.push(("custom_name".into(), Tag::String("luck".into())));
assert_eq!(
contents_name_key(&own).as_deref(),
Some("item.minecraft.tipped_arrow.effect.luck")
);
let mut taken = ItemStack::new("minecraft:potion", 1);
taken.data_mut().set_other(
"!minecraft:potion_contents",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(contents_name_key(&taken), None);
assert_eq!(
contents_name_key(&ItemStack::new("minecraft:stone", 1)),
None
);
assert_eq!(format_duration(9600), "08:00");
assert_eq!(format_duration(450), "00:22");
assert_eq!(format_duration(140), "00:07");
+29 -13
View File
@@ -357,6 +357,24 @@ impl Light {
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
match Self::classify(&light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(&light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(&light, 0), first >> 4),
(true, true, _) => Self::Full(light),
}
}
/// The smallest form of packed light per block, allocating a 4 KiB box only
/// when both sky and block light vary within the section.
pub fn from_slice(light: &[u8; 4096]) -> Self {
match Self::classify(light) {
(false, false, first) => Self::Uniform(first),
(true, false, first) => Self::Sky(Self::pack_layer(light, 4), first & 15),
(false, true, first) => Self::Block(Self::pack_layer(light, 0), first >> 4),
(true, true, _) => Self::Full(Box::new(*light)),
}
}
fn classify(light: &[u8; 4096]) -> (bool, bool, u8) {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
@@ -364,19 +382,14 @@ impl Light {
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
(sky_varies, block_varies, first)
}
fn pack_layer(light: &[u8; 4096], shift: u32) -> Box<[u8; 2048]> {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
@@ -858,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,11 +80,18 @@ 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,
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
Bell,
/// `ConduitBlockEntity`: the UUID of its `destroyTarget` (`Target`). Its
/// shape and animation are kept separately (`block_entity_anim`).
Conduit(Option<[i32; 4]>),
/// `EnchantingTableBlockEntity`: its custom name is the world's block name.
EnchantingTable,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
@@ -515,6 +522,11 @@ pub struct Info {
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
/// `uuid`: the source's saved identity (`source`), taken when the event
/// was posted. `source` is the live entity and comes back `None` on load.
pub source_uuid: Option<[i32; 4]>,
/// `projectileOwnerUuid` (`projectile_owner`), likewise beside `owner`.
pub owner_uuid: Option<[i32; 4]>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
+170 -49
View File
@@ -356,6 +356,38 @@ where
)
.unwrap_or(false)
}
/// `Level.loadedAndEntityCanStandOn(pos, projectile)` with the projectile's
/// feet at `entity_y` (`loadedAndEntityCanStandOnFace` with `UP`):
/// `Block.isFaceFull(state.getCollisionShape(level, pos,
/// CollisionContext.of(projectile)), UP)`. A projectile is not living and never
/// falls (it moves by `setPos`), so powder snow and liquids hold nothing up
/// and only scaffolding reads the height. An unloaded or out-of-world block
/// reads as air.
pub fn projectile_can_stand_on(
world: &World,
binding: &BlockCollisionRegistry<'_>,
pos: BlockPos,
entity_y: f64,
) -> bool {
use horizon_core::geometry::{Direction, ShapeOp};
let mut scene = SceneCollisions {
world,
binding,
subject: None,
};
let state = world.block(pos.x, pos.y, pos.z);
let context = CollisionContext::position(entity_y);
binding
.collision_shape_with_context(&mut scene, state, pos, &context)
.is_ok_and(|shape| {
shape.is_block()
|| shape.face_shape(Direction::Up).is_ok_and(|face| {
VoxelShape::block()
.join_is_not_empty(&face, ShapeOp::XOR)
.is_ok_and(|differs| !differs)
})
})
}
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
@@ -391,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;
}
}
}
}
}
}
@@ -441,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 {
@@ -471,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 {
@@ -967,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
@@ -1024,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,
+63 -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),
@@ -444,9 +447,23 @@ impl BlockEntity {
if name == "minecraft:bell" {
return Some(Self::Bell);
}
if name == "minecraft:conduit" {
return Some(Self::Conduit(None));
}
if name == "minecraft:enchanting_table" {
return Some(Self::EnchantingTable);
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
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()));
}
@@ -540,6 +557,9 @@ impl BlockEntity {
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::Bell => name == "minecraft:bell",
Self::Conduit(_) => name == "minecraft:conduit",
Self::EndPortal => name == "minecraft:end_portal",
Self::EnchantingTable => name == "minecraft:enchanting_table",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
@@ -579,6 +599,9 @@ impl BlockEntity {
Self::EndGateway(_) => "minecraft:end_gateway",
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",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
@@ -614,6 +637,9 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -645,6 +671,9 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
@@ -681,6 +710,9 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Bell
| Self::Conduit(_)
| Self::EndPortal
| Self::EnchantingTable
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
@@ -933,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());
}
}
File diff suppressed because it is too large. Load diff
+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()
};
+20 -6
View File
@@ -33,7 +33,7 @@ struct Globals {
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
// w: this entire frame writes light, so alpha is coverage rather than a mark.
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_shares: vec4<f32>,
@@ -175,7 +175,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
// [world warp entity]
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
let at = position;
// [end world warp entity]
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = (overlay >> 16u) & 0xffu;
return out;
@@ -279,10 +283,11 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_written_color(in, front);
return entity_written_color(in, entity_color(in, front));
}
fn entity_written_color(in: Out, front: bool) -> vec4<f32> {
let color = entity_color(in, front);
// Takes entity_color's result rather than calling it: FXC (DX12) fails with "argument pulled into
// unrelated predicate" when entity_color's discards sit a function call below the entry point.
fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
@@ -427,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);
@@ -455,6 +468,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
if !full { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
File diff suppressed because it is too large. Load diff
+17 -19
View File
@@ -5,7 +5,7 @@ use crate::{
lang,
options_ui::{Event, Page},
settings::Settings,
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
};
const TITLE: &str = "horizon.pages.extras";
@@ -36,7 +36,7 @@ pub(crate) enum ExtraRow {
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
let e = &s.extras;
let value = match (item.key_name(), item.switch(e)) {
(Some(key), _) => Value::Key(key, item.value(e)),
(Some(key), _) => Value::Binding(Binding::key(key, item.value(e))),
(None, Some(on)) => Value::Switch(on),
(None, None) => Value::Choice(item.value(e)),
};
@@ -107,22 +107,14 @@ 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 {
ExtraRow::Item(item) => match item.key_name() {
Some(key) => vec![Output::BindKey(key)],
None => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
},
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
vec![Output::Event(Event::Changed)]
}
ExtraRow::Zoom(i) => {
s.mods.toggle(i);
vec![Output::Event(Event::Changed)]
@@ -208,15 +200,21 @@ mod tests {
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
use crate::settings_shell::Capture;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
s.extras.sort = true;
let mut shell = Shell::<ExtrasScreen>::default();
let row = ExtraRow::Item(Item::SortKey);
let key = s.extras.sort_key.clone();
let at = shell.point_at(&s, SIZE, row);
assert_eq!(
screen.press(&mut s, row, false),
vec![Output::BindKey(extras::SORT_KEY)]
shell.click(&mut s, at, SIZE, false, false, false),
vec![
Output::Event(Event::ButtonClick),
Output::Bind(Capture::Key(extras::SORT_KEY))
]
);
screen.wheel_step(&mut s, row, true);
ExtrasScreen.wheel_step(&mut s, row, true);
assert_eq!(s.extras.sort_key, key);
}
+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
+103 -1
View File
@@ -5,6 +5,9 @@
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::{BTreeMap, VecDeque};
#[cfg(test)]
#[path = "arena/source_tests.rs"]
mod source_tests;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
@@ -82,6 +85,8 @@ impl FreeList {
/// One vertex buffer of the arena; only the newest page grows.
struct Page {
buffer: wgpu::Buffer,
/// Optional shader-pack source records, indexed by the vertex index / 4.
sources: Option<wgpu::Buffer>,
/// Capacity in vertices.
capacity: u64,
free: FreeList,
@@ -116,6 +121,7 @@ pub struct Arena {
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
sources_enabled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
#[cfg(test)]
video_memory_full: bool,
@@ -200,6 +206,7 @@ impl Arena {
usage,
closing: None,
spilled: false,
sources_enabled: false,
#[cfg(test)]
video_memory_full: false,
}
@@ -249,7 +256,11 @@ impl Arena {
}
/// Bytes of every page's buffer.
pub fn bytes(&self) -> u64 {
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
self.pages
.iter()
.flatten()
.map(|p| p.capacity * self.stride + p.sources.as_ref().map_or(0, wgpu::Buffer::size))
.sum()
}
/// The most vertices a page holds.
pub fn page_vertices(&self) -> u64 {
@@ -281,6 +292,65 @@ impl Arena {
.expect("live arena page")
.buffer
}
pub fn sources(&self, page: u32) -> Option<&wgpu::Buffer> {
self.pages[page as usize].as_ref()?.sources.as_ref()
}
fn source_buffer(device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
if capacity / 4 * 8 > device.limits().max_storage_buffer_binding_size {
return None;
}
try_create(
device,
"Terrain quad sources",
capacity / 4 * 8,
wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
)
}
/// Native meshes allocate no sidecar. Once needed, existing pages also get a zeroed
/// sidecar so moves can retain sources. Failure leaves geometry intact and attributes
/// unknown; a later source upload may retry its page.
pub fn write_sources(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
page: u32,
start: u64,
vertices: u64,
sources: &[crate::mesher::TerrainSource],
) {
if !sources.is_empty() && !self.sources_enabled {
self.sources_enabled = true;
for p in self.pages.iter_mut().flatten() {
p.sources = Self::source_buffer(device, p.capacity);
}
}
let p = self.pages[page as usize].as_mut().expect("live arena page");
if p.sources.is_none() && !sources.is_empty() {
p.sources = Self::source_buffer(device, p.capacity);
}
if let Some(buffer) = &p.sources {
let bytes = vertices / 4 * 8;
if bytes > 0 {
// Recycled ranges must never keep another mesh's identity, even for a
// missing or incomplete sidecar. Zero records have no valid bit.
encoder.clear_buffer(buffer, start / 4 * 8, Some(bytes));
if sources.len() as u64 == vertices / 4 {
uploads.buffer(
encoder,
buffer,
start / 4 * 8,
bytemuck::cast_slice(sources),
);
}
}
}
}
/// Bytes of one vertex (or index).
pub fn stride(&self) -> u64 {
self.stride
@@ -336,6 +406,10 @@ impl Arena {
let start = free.take(len).expect("a new page fits one section");
let page = Page {
buffer,
sources: self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten(),
capacity,
free,
host,
@@ -451,6 +525,10 @@ impl Arena {
let free = FreeList::new(capacity);
self.pages.push(Some(Page {
buffer,
sources: self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten(),
capacity,
free,
host,
@@ -483,6 +561,21 @@ impl Arena {
to.1 * self.stride,
vertices * self.stride,
);
if let Some(to_sources) = self.sources(to.0) {
let bytes = vertices / 4 * 8;
if bytes > 0 {
match self.sources(from.0) {
Some(from_sources) => encoder.copy_buffer_to_buffer(
from_sources,
from.1 / 4 * 8,
to_sources,
to.1 / 4 * 8,
bytes,
),
None => encoder.clear_buffer(to_sources, to.1 / 4 * 8, Some(bytes)),
}
}
}
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
@@ -540,9 +633,17 @@ impl Arena {
let Some((buffer, host)) = self.create(device, capacity, spill) else {
return false;
};
let sources = self
.sources_enabled
.then(|| Self::source_buffer(device, capacity))
.flatten();
let old = self.pages[page].as_mut().expect("live arena page");
// Copies recorded for the old buffer run before this one.
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
if let (Some(from), Some(to)) = (&old.sources, &sources) {
encoder.copy_buffer_to_buffer(from, 0, to, 0, from.size());
}
old.sources = sources;
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
@@ -1447,6 +1548,7 @@ mod tests {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
arena.pages = vec![Some(Page {
sources: None,
buffer,
capacity,
free,
@@ -0,0 +1,145 @@
//! Original sidecar lifetime controls, executed explicitly on an offscreen adapter.
use super::*;
use crate::mesher::{TerrainSource, TerrainVertex};
fn read(
device: &wgpu::Device,
queue: &wgpu::Queue,
buffer: &wgpu::Buffer,
offset: u64,
size: u64,
) -> Vec<u8> {
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, offset, &out, 0, size);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
out.slice(..).get_mapped_range().unwrap().to_vec()
}
#[test]
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
fn terrain_source_arena_gpu_grow_move_recycle_and_refusal() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("GPU control requires an adapter");
eprintln!("TERRAIN_SOURCE_ARENA_ADAPTER {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let mut arena = Arena::new(&device, 24, GRAIN, true);
arena.page_limit = GRAIN * 2;
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
let first = arena.alloc(&device, &mut encoder, 4).unwrap();
assert!(
arena.sources(first.0).is_none(),
"default geometry allocates no source buffer"
);
let geometry = [TerrainVertex::new([2.25, 3.75, 4.5], [0.25, 0.75], 7, 0xff123456, 0x8070); 4];
uploads.buffer(
&mut encoder,
arena.buffer(first.0),
first.1 * 24,
bytemuck::cast_slice(&geometry),
);
let source = TerrainSource::new(71, [2, 3, 4], 13, false);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
first.0,
first.1,
4,
&[source],
);
let second = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_eq!(second, (first.0, GRAIN), "grew the first page");
let third = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_ne!(third.0, first.0, "move crosses pages");
arena.copy(&mut encoder, first, third, 4);
arena.release(first.0, first.1, 4);
let reused = arena.alloc_existing(4).unwrap();
assert_eq!(reused, first);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
reused.0,
reused.1,
4,
&[],
);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
assert_eq!(
read(
&device,
&queue,
arena.sources(third.0).unwrap(),
third.1 / 4 * 8,
8
),
bytemuck::bytes_of(&source)
);
assert_eq!(
read(
&device,
&queue,
arena.sources(first.0).unwrap(),
first.1 / 4 * 8,
8
),
[0; 8]
);
assert_eq!(
read(&device, &queue, arena.buffer(third.0), third.1 * 24, 96),
bytemuck::cast_slice::<_, u8>(&geometry)
);
// A real requested binding limit refuses metadata/table allocation. The native
// vertex allocation still fits; this is a controlled refusal, not a real OOM.
let limits = wgpu::Limits {
max_storage_buffer_binding_size: 256,
..Default::default()
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
.unwrap();
let mut arena = Arena::translucent(&device, 24, GRAIN);
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
let at = arena.alloc(&device, &mut encoder, 4).unwrap();
uploads.buffer(
&mut encoder,
arena.buffer(at.0),
at.1 * 24,
bytemuck::cast_slice(&geometry),
);
arena.write_sources(
&device,
&mut encoder,
&mut uploads,
at.0,
at.1,
4,
&[source],
);
assert!(
arena.sources(at.0).is_none(),
"sidecar refused without losing vertices"
);
assert!(super::super::pack_post::block_ids(&device, &queue, &[0; 128], &[0, 1]).is_none());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
assert_eq!(
read(&device, &queue, arena.buffer(at.0), at.1 * 24, 96),
bytemuck::cast_slice::<_, u8>(&geometry)
);
}
@@ -64,8 +64,10 @@ impl Copies {
fn copy(mesh: &SectionMesh) -> SectionMesh {
SectionMesh {
solid: mesh.solid.clone(),
solid_sources: mesh.solid_sources.clone(),
sides: mesh.sides,
translucent: mesh.translucent.clone(),
translucent_sources: mesh.translucent_sources.clone(),
packed: mesh.packed.clone(),
packed_far: mesh.packed_far,
solid_tints: Vec::new(),
+11 -7
View File
@@ -27,11 +27,13 @@
//! as many draws as without culling: drivers that unroll an indirect
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
//! counts come from the GPU, which reads them from the draws themselves:
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
//! indirect draw per entry. Translucent draws are not culled.
//! Vulkan's native multi-draw, DirectX 12's `ExecuteIndirect`, and Metal's,
//! which wgpu replays as one indirect draw per entry. Translucent draws are
//! not culled.
//!
//! Metal cannot force the depth test before a fragment shader that writes
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
//! storage (naga writes no `[[early_fragment_tests]]`), nor can DirectX 12
//! (`[earlydepthstencil]`, which wgpu doesn't offer there), so there the boxes
//! are drawn in a pass of their own between the phases, which reads phase
//! 1's depth as a texture and makes the same comparison in the shader: the
//! fragment's depth against the stored one, greater or equal passing. The
@@ -224,9 +226,9 @@ impl Cull {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
/// with the instance counts the GPU wrote, with fragment shaders that
/// write storage.
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
/// drawing an indirect multi-draw's entries with the instance counts the
/// GPU wrote, with fragment shaders that write storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let depth_test = match adapter.get_info().backend {
wgpu::Backend::Vulkan | wgpu::Backend::Gl
@@ -236,7 +238,9 @@ impl Cull {
{
DepthTest::Early
}
wgpu::Backend::Metal => DepthTest::InShader,
// DirectX 12 has no forced early depth test in wgpu either: the test runs in the
// shader there too.
wgpu::Backend::Metal | wgpu::Backend::Dx12 => DepthTest::InShader,
_ => return None,
};
(device
@@ -554,14 +554,22 @@ mod tests {
});
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug) in MODES
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
for (mode_index, mode, debug, written) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false))
.chain([(5, Blend::Screen, true)])
.map(|(i, m)| (i, m, false, true))
.chain([(5, Blend::Screen, true, true)])
.chain(
MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, false)),
)
{
let gain = if written { hdr_gain } else { 1.0 };
let uniform = Uniform {
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
brightness: 0.7,
@@ -569,7 +577,7 @@ mod tests {
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -704,6 +712,12 @@ mod tests {
let data = readback.get_mapped_range(..).unwrap();
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
let alpha = half::f16::from_le_bytes([data[6], data[7]]).to_f32();
let coverage = if mode == Blend::Alpha { k } else { 1.0 };
assert!(
(alpha - coverage).abs() < 0.001,
"{mode:?}: alpha {alpha}, expected {coverage}"
);
let expected = if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
@@ -728,6 +742,8 @@ mod tests {
"{mode:?} debug {debug}: {got} expected {expected}"
);
}
eprintln!("HDR custom sky GPU ran all nine modes and debug Screen over above-white light");
eprintln!(
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
);
}
}
@@ -12,7 +12,7 @@ use crate::mesher::TerrainVertex;
/// The solid quads the grid can draw a frame, and the sections they come from.
const MAX_QUADS: u64 = 1 << 16;
const MAX_SECTIONS: usize = 64;
pub(crate) const MAX_SECTIONS: usize = 64;
/// Bytes of one quad's corners.
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
/// How far past the flat face the relief may reach, in blocks, for the view test.
@@ -413,6 +413,8 @@ pub struct Displacement {
pub shadow: Option<Picks>,
/// Group 1's layout for the grid's pipelines.
pub draw_layout: wgpu::BindGroupLayout,
/// CPU-side section table reused between displacement preparation passes.
table: Vec<Section>,
/// Sections the pick ran over this frame (0: nothing to draw).
pub drawn: usize,
}
@@ -598,6 +600,7 @@ impl Displacement {
view,
shadow,
draw_layout,
table: Vec::new(),
drawn: 0,
}
}
@@ -610,7 +613,8 @@ impl Displacement {
near: &[Near<'_>],
view_proj: glam::Mat4,
) {
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
self.table.clear();
self.table.reserve(near.len().min(MAX_SECTIONS));
let mut first = 0u64;
let mut most = 0u32;
for n in near.iter().take(MAX_SECTIONS) {
@@ -626,7 +630,7 @@ impl Displacement {
first * QUAD_BYTES,
quads * QUAD_BYTES,
);
table.push(Section {
self.table.push(Section {
origin: [
n.origin[0],
n.origin[1],
@@ -640,11 +644,11 @@ impl Displacement {
first += quads;
most = most.max(quads as u32);
}
self.drawn = table.len();
if table.is_empty() {
self.drawn = self.table.len();
if self.table.is_empty() {
return;
}
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&self.table));
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Displacement pick"),
@@ -655,7 +659,7 @@ impl Displacement {
let params = Params {
view_proj: view_proj.to_cols_array_2d(),
distance: self.distance,
sections: table.len() as u32,
sections: self.table.len() as u32,
margin: RELIEF_MARGIN,
capacity: MAX_QUADS as u32,
shadow,
@@ -665,7 +669,7 @@ impl Displacement {
let vertices = grid_vertices(picks.cells, picks.steps);
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
pass.set_bind_group(0, &picks.pick_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
pass.dispatch_workgroups(most.div_ceil(64), self.table.len() as u32, 1);
}
}
@@ -0,0 +1,132 @@
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
//! GPUs, FXC on every other adapter (#8513).
//!
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
//! beside the game: `tools/ci/fetch_dxc.py`).
//!
//! wgpu chooses the compiler for a whole instance, before any adapter is
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
//! instance with DXC. That happens once, when the device is opened; nothing
//! here runs per frame. Without the DLL, or when it does not load, the game
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
use std::path::PathBuf;
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
pub(super) const DLL: &str = "dxcompiler.dll";
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
/// set, so its compiler is already the one every instance uses).
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
!forced
&& info.backend == wgpu::Backend::Dx12
&& info.device_type == wgpu::DeviceType::IntegratedGpu
}
/// Whether `WGPU_DX12_COMPILER` names the compiler.
pub(super) fn forced() -> bool {
std::env::var_os("WGPU_DX12_COMPILER").is_some()
}
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
/// one loaded.
pub(super) fn bundled_dll() -> Option<PathBuf> {
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
dll.is_file().then_some(dll)
}
/// The compiler setting that loads `dll`, and fails the instance (rather
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
/// then keeps the FXC instance it already has.
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
wgpu::Dx12Compiler::DynamicDxc {
dxc_path: dll.to_string_lossy().into_owned(),
}
}
/// Whether the DXC instance offered the adapter the FXC instance had: on a
/// laptop with two GPUs, the same request should pick the same one, and the
/// game must not switch GPUs because the compiler changed.
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
fxc.backend == dxc.backend
&& fxc.vendor == dxc.vendor
&& fxc.device == dxc.device
&& fxc.device_type == dxc.device_type
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
}
#[cfg(test)]
mod tests {
use super::*;
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "Intel(R) Iris(R) Xe Graphics".into(),
vendor: 0x8086,
device: 0x9a49,
device_type,
device_pci_bus_id: "0000:00:02.0".into(),
driver: String::new(),
driver_info: String::new(),
backend,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
}
}
#[test]
fn only_integrated_gpus_on_directx_12_get_dxc() {
use wgpu::{Backend, DeviceType};
assert!(wants_dxc(
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
false
));
for kind in [
DeviceType::DiscreteGpu,
DeviceType::VirtualGpu,
DeviceType::Cpu,
DeviceType::Other,
] {
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
}
// Other backends have no shader compiler to choose.
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
assert!(
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
"{backend:?}"
);
}
}
#[test]
fn wgpu_dx12_compiler_overrides_the_choice() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(!wants_dxc(&igpu, true));
}
#[test]
fn the_dxc_instance_must_offer_the_same_gpu() {
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
assert!(same_adapter(&igpu, &igpu.clone()));
let dgpu = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
vendor: 0x10de,
device: 0x25a0,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
..igpu.clone()
};
assert!(!same_adapter(&igpu, &dgpu));
}
}
@@ -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: (
+38 -2
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
@@ -174,11 +175,22 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
/// Whether frames without a shader pack's terrain draw flat: where the backend
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
/// Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|| info.backend == wgpu::Backend::Metal
|| is_broken_intel_vulkan(info)
|| is_adreno_dx12(info)
}
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
@@ -814,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)| {
@@ -1044,6 +1056,30 @@ impl Flat {
mod tests {
use super::*;
#[test]
fn adreno_draws_flat_under_dx12_only() {
let mut info = wgpu::AdapterInfo {
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
vendor: 0x4d4f_4351,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "31.0.128.0".into(),
driver_info: String::new(),
backend: wgpu::Backend::Dx12,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
assert!(is_adreno_dx12(&info));
info.backend = wgpu::Backend::Vulkan;
assert!(!is_adreno_dx12(&info));
info.backend = wgpu::Backend::Dx12;
info.vendor = 0x10de;
assert!(!is_adreno_dx12(&info));
}
/// Draws as `render` writes them: solid (page, quads at a base vertex)
/// first, then translucent (page, sorted page, count, first sorted index,
/// base vertex).
@@ -2735,8 +2735,9 @@ mod tests {
/// `test` on each device here that draws counts with first instances:
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
/// compiles with FXC, part of Windows: wgpu takes DXC only where
/// `dxcompiler.dll` loads, and the game ships none.
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
/// beside the game in the Windows x64 zip) on integrated GPUs
/// (`gfx/dx12_compiler.rs`).
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
let mut tested = false;
for (backends, compiler, name) in [
+451 -67
View File
@@ -72,32 +72,30 @@ fn named(name: Option<&str>) -> Option<Output> {
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
/// The world as `keep_world` kept it; on the layered frame, the frame as `resolve` (the
/// world and the hand marked it) or `tonemap` found it.
/// The world as `keep_world` kept it; also the tone curve's input scratch on a layered frame.
world: wgpu::Texture,
coverage: Option<wgpu::Texture>,
/// The layered frame's HUD target: what the HUD draws on.
hud: Option<wgpu::Texture>,
group: wgpu::BindGroup,
/// The layered frame's passes' bindings: `resolve` (and `tonemap`), `keep_world` and
/// `composite`.
/// The layered frame's bindings for `tonemap`, `keep_world` and `composite`.
passes: Option<[wgpu::BindGroup; 3]>,
}
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
/// "The HUD and menus"): the default world draws write light directly; marked fallback frames
/// turn their marks into light after the world and the hand (`resolve`); the tone curve eases it into the peak after the post effects (`tonemap`); the
/// "The HUD and menus"): the default world draws write light directly; the tone curve eases
/// it into the peak after the post effects (`tonemap`); the
/// camera overlays draw on that light; the HUD draws on a target of its own,
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
/// (`composite`).
struct Layered {
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// Reads what `resolve` reads.
tonemap: wgpu::RenderPipeline,
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
/// What `resolve` and `composite` read, written before they run (`begin`).
/// 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,
}
@@ -191,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.
@@ -332,17 +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 resolve = with("Horizon HDR marks to light", &[1], true, "fs_resolve");
let hud = super::hud_coverage::HUD_FORMAT;
Layered {
tonemap: pass("Horizon HDR tone curve", &resolve.0, "fs_tonemap", format),
resolve,
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(),
}
});
@@ -451,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,
)
});
@@ -492,7 +496,7 @@ impl Hdr {
})
};
[
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
group(&l.tonemap.0, &[entry(1, &world), params.clone()]),
group(&l.keep.0, &[entry(0, &frame)]),
group(
&l.composite.0,
@@ -518,18 +522,111 @@ 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()
}
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
/// 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) {
if let Some(l) = &self.layered {
@@ -541,34 +638,12 @@ impl Hdr {
}
}
/// On the layered frame, after the world and the hand drew and marked it: the marks
/// become light in the frame (`fs_resolve`).
pub fn resolve(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.resolve.1, "HDR marks to light", timing);
}
/// On the layered frame, after the post effects and before the camera overlays: the tone
/// curve eases the light into the peak (`fs_tonemap`).
pub fn tonemap(
&self,
encoder: &mut wgpu::CommandEncoder,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.redraw(encoder, |l| &l.tonemap, "HDR tone curve", timing);
}
/// The layered frame drawn again by `pipeline`, which reads it from a copy in the world
/// target.
fn redraw(
&self,
encoder: &mut wgpu::CommandEncoder,
pipeline: impl Fn(&Layered) -> &wgpu::RenderPipeline,
label: &'static str,
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
return;
@@ -582,7 +657,14 @@ impl Hdr {
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
Self::draw(encoder, pipeline(l), group, &view, label, timing);
Self::draw(
encoder,
&l.tonemap.1,
group,
&view,
"HDR tone curve",
timing,
);
}
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
@@ -622,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.
@@ -981,6 +1077,10 @@ var<private> params: ReferenceParams;
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
// Gain stays above the tone knee until the separate tone curve runs.
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
@@ -1133,7 +1233,14 @@ var<private> params: ReferenceParams;
pub(super) fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
eprintln!("HDR GPU {:?}", adapter.get_info());
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: adapter.features()
& (wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES),
..Default::default()
}))
.ok()
}
/// Clears all of `texture` to `color`.
@@ -1152,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,
@@ -1190,40 +1297,153 @@ 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())
}
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
// Float targets in these tests only receive the resulting colour and coverage.
pub(super) fn legacy_light(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
hdr: &Hdr,
source: &wgpu::Texture,
target: &wgpu::Texture,
) {
use wgpu::util::DeviceExt;
assert_eq!(source.format(), F::Rgba16Unorm);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("legacy non-float light oracle"),
source: wgpu::ShaderSource::Wgsl(
(r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
@group(0) @binding(0) var<uniform> params: ReferenceParams;
@group(0) @binding(1) var source: texture_2d<f32>;
"#
.to_owned()
+ include_str!("hdr_light_reference.txt")
+ r#"
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let c = textureLoad(source, vec2(0), 0);
let mark = round(saturate(c.a) * 255.0) / 255.0;
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
}
"#)
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(F::Rgba16Float.into())],
}),
multiview_mask: None,
cache: None,
});
let (settings, shares) = hdr.draw_params(true);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&[settings, shares]),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = source.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
],
});
Hdr::draw(
encoder,
&pipeline,
&group,
&target.create_view(&Default::default()),
"legacy oracle",
None,
);
}
#[test]
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
let Some((device, queue)) = gpu() else {
return;
};
// What the last pass shows of a world pixel nothing covers, from today's frame
// (`layered` false) or the layered one, whose marks turn into light before it, then
// pass the tone curve.
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
return;
}
// Compare the compatibility display lift with direct colour under the layered HUD.
// The frozen oracle supplies the writer's light from a non-float attachment.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr =
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
let mut hdr = Hdr::with_debug(
&device,
&queue,
if layered {
F::Rgba16Float
} else {
F::Rgba16Unorm
},
Output::Pq,
layered,
None,
);
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, world);
if layered {
let mut legacy =
Hdr::with_debug(&device, &queue, F::Rgba16Unorm, Output::Pq, false, None);
let marked = legacy.frame(&device, [1, 1]);
fill(&mut encoder, &marked, world);
if world[3] == 1.0 {
fill(&mut encoder, &frame, world);
} else {
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
}
hdr.begin(&queue, true);
hdr.resolve(&mut encoder, None);
hdr.tonemap(&mut encoder, None);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
} else {
fill(&mut encoder, &frame, world);
hdr.keep_world(&mut encoder, None);
}
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
texel(&device, &queue, encoder, &capture)
};
// Unmarked, below white and above it; HDR lighting; light sources of each tier: all up to
// Unmarked, HDR lighting and light sources of each tier: all up to
// the tone curve's knee, which the curve leaves as they are.
for world in [
[0.5, 0.4, 0.3, 1.0],
[1.5, 1.2, 0.6, 1.0],
[0.8, 0.7, 0.6, 0.88],
[0.9, 0.8, 0.4, 0.05],
[0.9, 0.8, 0.4, 0.2],
@@ -1240,6 +1460,10 @@ var<private> params: ReferenceParams;
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
}
}
// Above-white unmarked colour belongs to float storage, not the UNORM oracle.
let above = shown(true, [1.5, 1.2, 0.6, 1.0]);
let white = shown(true, [1.0, 1.0, 0.6, 1.0]);
assert!(above[0] > white[0] && above[1] > white[1]);
}
#[test]
@@ -1306,13 +1530,173 @@ var<private> params: ReferenceParams;
hdr.peak = 150.0;
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
// HDR lighting's gain stays past the knee until the curve: the marks become light
// without easing.
hdr.peak = 1000.0;
let resolve = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.resolve(encoder, None);
let [r, ..] = after(&mut hdr, [1.0, 1.0, 1.0, 0.76], &resolve);
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
assert!(near(r.powf(2.2), gain), "{r}");
}
/// 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]
@@ -1,6 +1,6 @@
use super::{
Hdr, Output,
tests::{gpu, texel},
tests::{gpu, legacy_light, texel},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
@@ -46,6 +46,29 @@ override TEST_OVERLAY: u32 = 0u;
}
"#;
const TERRAIN_EXTRA_VERTICES: &str = r#"
@vertex fn test_model(@builtin(vertex_index) i: u32) -> NativeModelOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: NativeModelOut;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.color = vec4(0.8, 0.9, 0.7, 1.0);
return o;
}
@vertex fn test_fluid(@builtin(vertex_index) i: u32) -> NativeFluidOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
var o: NativeFluidOut;
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.color = vec4(0.8, 0.9, 0.7, 1.0);
return o;
}
@vertex fn test_sunrise(@builtin(vertex_index) i: u32) -> SunriseOut {
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
return SunriseOut(vec4(p * 2.0 - 1.0, 0.0, 1.0), 0.5);
}
"#;
struct Draw<'a> {
device: &'a wgpu::Device,
queue: &'a wgpu::Queue,
@@ -54,6 +77,9 @@ struct Draw<'a> {
layout: wgpu::PipelineLayout,
bindings: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
raw: bool,
hdr_lighting: bool,
material_frame: Option<super::super::native_post::NativeFrame>,
}
impl<'a> Draw<'a> {
fn render_oit(
@@ -316,7 +342,7 @@ impl<'a> Draw<'a> {
alpha: u8,
) -> Self {
let source = if terrain {
super::super::terrain_shader() + TERRAIN_VERTEX
super::super::terrain_shader() + TERRAIN_VERTEX + TERRAIN_EXTRA_VERTICES
} else {
super::super::entity_shader() + ENTITY_VERTEX
};
@@ -432,8 +458,43 @@ impl<'a> Draw<'a> {
layout,
bindings,
group,
raw: false,
hdr_lighting: true,
material_frame: None,
}
}
fn material(&mut self, api: horizon_shader_api::Api) {
let source = super::super::native_material::coverage_test_shader(api);
self.shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual assembled material wrappers"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let frame = super::super::native_post::NativeFrame::new(
self.device,
None,
Vec::new(),
Default::default(),
None,
api,
);
self.queue.write_buffer(
&frame.buffer,
0,
bytemuck::bytes_of(&horizon_shader_api::Frame::default()),
);
self.layout = self
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&self.bindings), None, None, Some(&frame.layout)],
..Default::default()
});
self.material_frame = Some(frame);
self.raw = true;
}
fn render(
&self,
hdr: &mut Hdr,
@@ -443,11 +504,50 @@ impl<'a> Draw<'a> {
overlay: u32,
background: [f64; 4],
) -> [f32; 4] {
let constants = [
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
let mut constants = vec![
(
if self.material_frame.is_some() {
"horizon_terrain_HDR_WRITE_LIGHT"
} else {
"HDR_WRITE_LIGHT"
},
f64::from(u8::from(written)),
),
("TEST_TIER", f64::from(tier)),
("TEST_OVERLAY", f64::from(overlay)),
];
if self.terrain {
constants.push((
if self.material_frame.is_some() {
"horizon_terrain_HDR_LIGHTING"
} else {
"HDR_LIGHTING"
},
f64::from(u8::from(self.hdr_lighting)),
));
}
if self.material_frame.is_some() {
constants.push((
"horizon_displace_distance",
if entry == "horizon_material_fs_displaced" {
10.0
} else {
0.0
},
));
}
let vertex = match entry {
"fs_sky" | "fs_sky_base" => "vs_sky",
"fs_sunrise" => "test_sunrise",
"fs_native_model" => "test_model",
"fs_native_fluid" => "test_fluid",
_ => "test_vertex",
};
let format = if !written && !self.raw {
F::Rgba16Unorm
} else {
F::Rgba16Float
};
let pipeline = |entry, mark| {
self.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -455,7 +555,7 @@ impl<'a> Draw<'a> {
layout: Some(&self.layout),
vertex: wgpu::VertexState {
module: &self.shader,
entry_point: Some("test_vertex"),
entry_point: Some(vertex),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -473,12 +573,14 @@ impl<'a> Draw<'a> {
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: Some(if mark || (self.terrain && entry != "fs_translucent") {
wgpu::BlendState::REPLACE
} else {
wgpu::BlendState::ALPHA_BLENDING
}),
format,
blend: Some(
if self.raw || mark || (self.terrain && entry != "fs_translucent") {
wgpu::BlendState::REPLACE
} else {
wgpu::BlendState::ALPHA_BLENDING
},
),
write_mask: if mark {
wgpu::ColorWrites::ALPHA
} else {
@@ -491,7 +593,7 @@ impl<'a> Draw<'a> {
})
};
let color = pipeline(entry, false);
let mark = (!self.terrain && !written && entry == "fs_entity").then(|| {
let mark = (!self.raw && !self.terrain && !written && entry == "fs_entity").then(|| {
pipeline(
if overlay == 4 {
"fs_hdr_cloud_mark"
@@ -501,7 +603,22 @@ impl<'a> Draw<'a> {
true,
)
});
let frame = hdr.frame(self.device, [1, 1]);
let frame = self.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,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = frame.create_view(&Default::default());
let mut encoder = self.device.create_command_encoder(&Default::default());
{
@@ -519,6 +636,9 @@ impl<'a> Draw<'a> {
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
if let Some(frame) = &self.material_frame {
pass.set_bind_group(3, &frame.group, &[]);
}
pass.set_pipeline(&color);
pass.draw(0..3, 0..1);
if let Some(mark) = &mark {
@@ -526,20 +646,266 @@ impl<'a> Draw<'a> {
pass.draw(0..3, 0..1);
}
}
if !written {
hdr.begin(self.queue, true);
hdr.resolve(&mut encoder, None);
if !written && !self.raw {
let light = hdr.frame(self.device, [1, 1]);
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
return texel(self.device, self.queue, encoder, &light);
}
texel(self.device, self.queue, encoder, &frame)
}
}
#[test]
fn actual_opaque_writers_match_marks_then_resolve() {
fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
let Some((device, queue)) = gpu() else {
eprintln!("plain float GPU unavailable");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let mut cases = 0;
for hdr_on in [false, true] {
let globals = super::super::Globals {
inverse_view: glam::Mat4::IDENTITY.to_cols_array_2d(),
fog_color: [0.2, 0.3, 0.4, 1.0],
sky_color: [0.7, 0.8, 0.9, 1.0],
sunrise: [1.2, 0.7, 0.4, 0.6],
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
misc: [0.0, 1.0, 0.0, 0.0],
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
hdr_settings: settings,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
let mut draw = Draw::new(&device, &queue, terrain, &globals, 180);
draw.raw = true;
let coverage = 180.0 / 255.0;
let entries = if terrain {
vec![
("fs_opaque", 1.0),
("fs_solid", 1.0),
("fs_solid_mirror", 1.0),
("fs_translucent", coverage),
("fs_native_model", 1.0),
("fs_native_fluid", coverage),
("fs_sky", 1.0),
("fs_sky_base", 1.0),
("fs_sunrise", 0.3),
]
} else {
vec![
("fs_entity", coverage),
("fs_celestial", coverage),
("fs_sky_celestial", coverage),
]
};
for plain in [false, true] {
draw.hdr_lighting = !plain;
for &(entry, alpha) in &entries {
for tier in [0, 3, 4] {
for overlay in if terrain { vec![0, 1] } else { vec![0, 6] } {
let old = draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
let float = draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
assert_eq!(
&float[..3],
&old[..3],
"{hdr_on} {entry} {tier} {overlay} {plain}"
);
assert!(
(float[3] - alpha).abs() < 0.001,
"{entry}: {float:?}, expected alpha {alpha}"
);
assert!(
float[..3].iter().any(|&c| c > 0.0),
"empty fixture: {entry}"
);
cases += 1;
}
}
}
}
}
}
eprintln!("plain float GPU ran {cases} colour/coverage comparisons");
}
#[test]
fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("stars.wgsl")
+ r#"
@vertex fn fixture(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn reference() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
"#;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("actual stars with original RGB reference"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
for gain in [0.0, 1.0] {
let mut data = [0.0_f32; 32];
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
data[20..24].copy_from_slice(&settings);
data[24..28].copy_from_slice(&shares);
data[31] = gain;
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&data),
usage: wgpu::BufferUsages::UNIFORM,
});
let mut draw = |entry| {
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("fixture"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[("HDR_WRITE_LIGHT", 1.0)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: F::Rgba16Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let frame = hdr.frame(&device, [1, 1]);
let view = frame.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
texel(&device, &queue, encoder, &frame)
};
let old = draw("reference");
let float = draw("fs_stars");
assert_eq!(&float[..3], &old[..3]);
assert!((float[3] - 0.35).abs() < 0.001, "{float:?}");
}
}
#[test]
fn float_material_wrappers_keep_rgb_and_coverage_including_displacement() {
let instance = super::super::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
// The real material layout binds the terrain arrays and the pack's frame together.
if adapter.limits().max_sampled_textures_per_shader_stage < 19 {
return;
}
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: wgpu::Limits {
max_sampled_textures_per_shader_stage: 19,
..Default::default()
},
..Default::default()
})) else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let globals = super::super::Globals {
filtering: [0.0, 1.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
..bytemuck::Zeroable::zeroed()
};
for api in [horizon_shader_api::Api::V0_1, horizon_shader_api::Api::V0_2] {
let mut draw = Draw::new(&device, &queue, true, &globals, 255);
draw.material(api);
for entry in [
"horizon_material_fs_solid",
"horizon_material_fs_displaced",
"horizon_material_fs_translucent",
] {
let old = draw.render(&mut hdr, false, entry, 3, 0, [0.0; 4]);
let float = draw.render(&mut hdr, true, entry, 3, 0, [0.0; 4]);
assert_eq!(&float[..3], &old[..3], "{api} {entry}");
let alpha = if entry.ends_with("translucent") {
0.7
} else {
1.0
};
assert!((float[3] - alpha).abs() < 0.001, "{api} {entry}: {float:?}");
assert!(
float[0] > 1.0,
"{api} {entry}: clipping or an empty fixture: {float:?}"
);
if alpha == 1.0 {
assert_ne!(
old[3], float[3],
"the legacy source mark must distinguish the paths"
);
}
}
}
eprintln!("material GPU ran both API lines through solid, displaced and translucent wrappers");
}
#[test]
fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR draw GPU unavailable");
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("opaque oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, shares) = hdr.draw_params(true);
@@ -576,8 +942,8 @@ fn actual_opaque_writers_match_marks_then_resolve() {
draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
let written =
draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
// The marked path stores half floats before conversion; the written
// one stores after it. Allow two half-float ULPs in gamma light.
// The oracle stores UNORM before conversion; the direct writer
// stores half floats after it. Allow two half-float ULPs in gamma light.
for i in 0..3 {
assert!(
(marked[i] - written[i]).abs() <= 0.002 * marked[i].max(1.0),
@@ -630,6 +996,10 @@ fn actual_translucent_writers_blend_their_own_light() {
let dst = [2.5, 1.8, 0.9, 1.0];
let got = draw.render(&mut hdr, true, entry, tier, overlay, dst);
let alpha = f32::from(texel_alpha) / 255.0 * tint_alpha;
assert!(
(src[3] - alpha).abs() < 0.001,
"{entry}: {src:?}, coverage {alpha}"
);
if terrain {
// Full block light leaves mark 194/255 whatever the texel's alpha, even when
// vertex coverage is halved.
@@ -657,6 +1027,13 @@ fn unmarked_entity_users_keep_their_source_gain() {
let Some((device, queue)) = gpu() else {
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
@@ -694,6 +1071,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
let Some((device, queue)) = gpu() else {
return;
};
if !device.features().contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) {
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
@@ -1,19 +1,17 @@
//! 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(
lane: (bool, bool, bool),
hdr_offered: bool,
attachments: u32,
flags: wgpu::DownlevelFlags,
format: wgpu::TextureFormatFeatures,
) -> bool {
!lane.0
&& lane.1
&& lane.2
&& hdr_offered
&& attachments >= 2
attachments >= 2
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
&& format
.allowed_usages
@@ -79,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,
@@ -94,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),
@@ -345,37 +345,17 @@ mod tests {
use super::*;
#[test]
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
fn coverage_requires_two_attachments_and_independent_r8_blending() {
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
let format = wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
};
let lane = (false, true, true);
assert!(supported(lane, true, 2, flags, format));
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
// pack's float frame, and unsupported devices never allocate coverage.
for other in [
(false, false, true),
(true, false, true),
(true, true, true),
(false, true, false),
] {
assert!(!supported(other, true, 2, flags, format));
}
assert!(!supported(lane, false, 2, flags, format));
assert!(!supported(lane, true, 1, flags, format));
assert!(supported(2, flags, format));
assert!(!supported(1, flags, format));
assert!(!supported(2, wgpu::DownlevelFlags::empty(), format));
assert!(!supported(
lane,
true,
2,
wgpu::DownlevelFlags::empty(),
format
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
@@ -384,8 +364,6 @@ mod tests {
}
));
assert!(!supported(
lane,
true,
2,
flags,
wgpu::TextureFormatFeatures {
@@ -393,6 +371,14 @@ mod tests {
..format
}
));
assert!(!supported(
2,
flags,
wgpu::TextureFormatFeatures {
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
..format
}
));
}
#[test]
@@ -149,6 +149,21 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["terrain.wgsl", "visbuffer.wgsl"],
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
(
"world warp terrain",
vec![
"terrain.wgsl",
"world_warp.wgsl",
"world_warp_fragment.wgsl",
],
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"world warp entities",
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
+21 -15
View File
@@ -50,8 +50,10 @@ impl Pending {
}
}
/// Small, its meshes boxed: `draw_frame` walks every tile each frame (thousands at long far
/// distances), and that walk is bound by the memory it reads.
pub(super) struct Tile {
pub mesh: SectionGpu,
pub mesh: Box<SectionGpu>,
bounds: Bounds,
/// When it began to fade in; none while the rest of its job uploads.
shown: Option<Instant>,
@@ -62,7 +64,7 @@ pub(super) struct Tile {
/// Its mesh before near chunks took some of its columns, drawn in its
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
previous: Option<Box<(SectionGpu, Option<Instant>)>>,
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
/// meshes of the same tile replace one another.
pub cull_slot: u32,
@@ -155,10 +157,12 @@ pub(super) struct Visible {
impl Tile {
/// The mesh drawn this frame: the previous one while it stands in.
pub fn drawn(&self) -> &SectionGpu {
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
self.previous
.as_deref()
.map_or(&self.mesh, |(mesh, _)| mesh)
}
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
match &mut self.previous {
match self.previous.as_deref_mut() {
Some((mesh, _)) => mesh,
None => &mut self.mesh,
}
@@ -177,7 +181,7 @@ impl Tile {
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
std::iter::once(&mut *self.mesh).chain(self.previous.as_deref_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
@@ -214,12 +218,12 @@ impl Renderer {
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
self.release_mesh(old.mesh);
self.release_mesh(*old.mesh);
(old.shown, Some(previous))
}
None if keep => (old.shown, Some((old.mesh, None))),
None if keep => (old.shown, Some(Box::new((*old.mesh, None)))),
None => {
self.release_mesh(old.mesh);
self.release_mesh(*old.mesh);
(old.shown, None)
}
},
@@ -240,7 +244,7 @@ impl Renderer {
self.lods.insert(
key,
Tile {
mesh,
mesh: Box::new(mesh),
bounds,
shown,
dying: None,
@@ -251,7 +255,7 @@ impl Renderer {
);
}
None => {
if let Some((previous, _)) = previous {
if let Some((previous, _)) = previous.map(|p| *p) {
self.release_mesh(previous);
}
self.lod_cull_free.extend(old_slot);
@@ -260,8 +264,8 @@ impl Renderer {
}
fn release_tile(&mut self, tile: Tile) {
self.release_mesh(tile.mesh);
if let Some((previous, _)) = tile.previous {
self.release_mesh(*tile.mesh);
if let Some((previous, _)) = tile.previous.map(|p| *p) {
self.release_mesh(previous);
}
}
@@ -337,8 +341,10 @@ impl Renderer {
let stood_in: Vec<_> = self
.lods
.values_mut()
.filter_map(|tile| match tile.previous {
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
.filter_map(|tile| match tile.previous.as_deref() {
Some((_, Some(since))) if start - *since >= FADE => {
tile.previous.take().map(|p| *p)
}
_ => None,
})
.collect();
@@ -440,7 +446,7 @@ impl Renderer {
if tile.shown.is_none() {
tile.shown = Some(if over_replaced(key) { start } else { whole });
}
if let Some((_, since)) = &mut tile.previous {
if let Some((_, since)) = tile.previous.as_deref_mut() {
since.get_or_insert(start);
}
}
@@ -195,6 +195,7 @@ struct HorizonMaterialSolid {
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if horizon_terrain_HDR_WRITE_LIGHT { return 1.0; }
let code = in.hdr_code & 15u;
if code > 0u {
let whole = code > 4u;
@@ -432,6 +433,10 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
constants.push((
"horizon_terrain_HDR_WRITE_LIGHT",
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
@@ -580,6 +585,39 @@ impl NativeMaterial {
}
}
#[cfg(test)]
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
);
let material = "fn material(input: MaterialInput) -> Surface { return Surface(vec4<f32>(1.4, 0.3, 0.1, 0.7)); }\nfn displacement(input: DisplacementInput) -> f32 { return 0.0; }\n";
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", material)]
.map(|(n, text)| (n.to_owned(), text.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
shader_source(&stage.source, &[], &[], (false, true), api)
+ r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> HorizonMaterialOut {
var o: HorizonMaterialOut;
o.clip = vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
o.uv = vec2(0.5);
o.tint = vec4(1.0);
o.lightmap = vec3(1.0);
o.hdr_code = TEST_TIER;
o.base = vec3(1.0);
return o;
}
"#
}
#[cfg(test)]
mod tests {
use super::*;
@@ -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}");
}
}
}
@@ -7,14 +7,16 @@ use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::{
OnceLock,
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
@@ -178,6 +180,38 @@ pub fn render_needs() -> RenderNeeds {
needs
}
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
#[derive(Clone, Debug, Default, PartialEq)]
#[non_exhaustive]
pub struct WorldWarp {
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
/// pixel of the terrain. `None`: no warp.
pub source: Option<Arc<str>>,
/// Whether this frame draws with the warp. A source given while this is false is still
/// built, so a mod can pay for its pipelines before the frame that needs them.
pub on: bool,
/// What the warp reads as `warp.params`: four `vec4<f32>`.
pub params: [f32; 16],
}
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
#[inline(never)]
pub fn native_world_warp(warp: &mut WorldWarp) {
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
}
/// This frame's world warp ([`native_world_warp`]).
pub fn world_warp() -> WorldWarp {
let mut warp = WorldWarp::default();
native_world_warp(&mut warp);
warp
}
#[cfg(test)]
mod tests {
use super::*;
@@ -378,6 +412,33 @@ mod tests {
assert_eq!(texels, expected);
}
struct Bend(&'static str);
impl RenderHooks for Bend {
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
event.warp.source = Some(self.0.into());
event.warp.on = true;
event.warp.params[0] += 1.0;
}
}
#[test]
fn the_last_mod_to_set_a_warp_draws() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
assert_eq!(warp.source.as_deref(), Some("b"));
assert!(warp.on);
// each saw what the one before left
assert_eq!(warp.params[0], 2.0);
}
#[test]
fn without_mods_there_is_no_warp() {
let mut warp = WorldWarp::default();
generated::dispatch_world_warp(&[], &mut warp);
assert_eq!(warp, WorldWarp::default());
assert!(warp.source.is_none() && !warp.on);
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
@@ -630,13 +630,12 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
// Source metadata follows each quad in its arena page. The immutable table
// holds sprite rectangles and the current pack's ids of source states.
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
"layout(set = 3, binding = 0) readonly buffer HzQuadSources { uvec2 hz_sources[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
);
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
@@ -649,7 +648,8 @@ impl PackAtlas {
let columns = self.columns;
let (cell, side, scale) = if flat {
(
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
"uvec2 hz_rect = uvec2(0u, 1u);\n \
if (2u * hz_layer + 1u < uint(hz_tables[0])) hz_rect = uvec2(hz_tables[2u + 2u * hz_layer], hz_tables[3u + 2u * hz_layer]);\n \
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
.to_owned(),
"float(hz_rect.y)",
@@ -701,20 +701,27 @@ impl PackAtlas {
let mut body = body;
if flat {
body.push_str(FACE);
body.push_str(
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
uvec2 hz_source = uvec2(0u);\n \
if (hz_quad < uint(hz_sources.length())) hz_source = hz_sources[hz_quad];\n \
bool hz_known_source = (hz_source.y & 8192u) != 0u;\n \
int hz_material = -1;\n \
if (hz_known_source && hz_source.x < uint(hz_tables[1])) hz_material = hz_tables[2u + uint(hz_tables[0]) + 2u * hz_source.x + ((hz_source.y >> 12u) & 1u)];\n",
);
}
declarations.push_str(
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
);
let entity = if flat {
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
"vec4(float(hz_material), hz_known_source ? float((hz_source.y >> 12u) & 1u) : 0.0, 0.0, 0.0)"
} else {
"vec4(-1.0, 0.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
// The mesher's owning cell, even for inset, offset, multipart or extended faces;
// w is the source's emitted light, independent of sprite and smooth light.
let mid_block = if flat {
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
float(hz_block_ids[3u * hz_layer + 2u]))"
"(hz_known_source ? vec4((vec3(float(hz_source.y & 15u), float((hz_source.y >> 4u) & 15u), float((hz_source.y >> 8u) & 15u)) + 0.5 - hz_v_pos) * 64.0, float((hz_source.y >> 14u) & 15u)) : vec4(0.0))"
} else {
"vec4(0.0)"
};
@@ -758,6 +765,8 @@ impl PackAtlas {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
}
}
}
@@ -1154,3 +1163,7 @@ mod tests {
);
}
}
#[cfg(test)]
#[path = "pack_atlas/source_ownership_test.rs"]
mod source_ownership_test;
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
// The guard, initializer, varying and integer output are independent probe code.
// The initializer, varying and integer output are independent probe code. The program reads
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
let vertex = "#version 330\nout vec4 observation;\n\
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
float first = mc_chunkFade;\n\
void main() {\n\
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }";
@@ -159,7 +159,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
origin,
0x80ffffff,
);
let fade = if requested { expected } else { 1.0 };
// The fade reaches the program whether the pack requested it or not.
let fade = expected;
let expected = [
fade,
if geometry { 0.8125 } else { fade },
@@ -307,9 +308,12 @@ fn sample(
bytemuck::cast_slice(&vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
&[],
&atlas.rects_table(),
)),
wgpu::BufferUsages::STORAGE,
);
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
@@ -326,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 {
@@ -363,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();
@@ -406,13 +406,16 @@ fn sample_probe(
bytemuck::bytes_of(&[0., 0., 0., scale]),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let data = buffer(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
&[],
&atlas.rects_table(),
)),
wgpu::BufferUsages::STORAGE,
);
let bindings = [
@@ -0,0 +1,424 @@
//! Original numeric controls for the production terrain attribute expressions.
//! A compute invocation stands for a terrain vertex; it runs the actual input body.
use super::*;
use wgpu::util::DeviceExt;
fn dispatch(
device: &wgpu::Device,
queue: &wgpu::Queue,
pipeline: &wgpu::ComputePipeline,
groups: &[wgpu::BindGroup],
result: &wgpu::Buffer,
) -> Vec<[f32; 4]> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: result.size(),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(pipeline);
for (i, g) in groups.iter().enumerate() {
pass.set_bind_group(i as u32, g, &[]);
}
pass.dispatch_workgroups((result.size() / 32) as u32, 1, 1);
}
encoder.copy_buffer_to_buffer(result, 0, &read, 0, result.size());
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
}
#[test]
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
fn terrain_source_ownership_gpu() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("executed GPU control requires an adapter");
eprintln!("TERRAIN_SOURCE_ADAPTER {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 1).unwrap();
let inputs = atlas.terrain_inputs(true);
// Inset faces of two source states sharing sprite zero, an extended multipart face,
// and the second state's fluid face. Ownership is authored
// fixture data, not inferred from normal, sprite, position or diagonal.
let mut vertices = Vec::new();
for (x, y) in [(2.0, 3.25), (6.0, 3.25), (7.25, 3.75), (6.0, 3.875)] {
for [dx, dz] in [[0.0, 0.0], [0.0, 0.5], [0.5, 0.5], [0.5, 0.0]] {
vertices.push(crate::mesher::TerrainVertex::new(
[x + dx, y, 4.0 + dz],
[0.0; 2],
0,
u32::MAX,
0,
));
}
}
let declarations = inputs
.declarations
.lines()
.filter(|l| !l.starts_with("layout(location"))
.collect::<Vec<_>>()
.join("\n");
let attribute = |name: &str| {
inputs
.attributes
.iter()
.find(|(n, _)| n == name)
.unwrap()
.1
.as_str()
};
let source = format!(
"#version 450\n{declarations}\n\
layout(local_size_x=1) in;\n\
layout(set=0,binding=0) buffer Results {{ vec4 result[]; }};\n\
void main() {{\n\
uint hz_index=gl_GlobalInvocationID.x;\n\
vec3 hz_v_pos=uintBitsToFloat(uvec3(hz_vertices[6u*hz_index],hz_vertices[6u*hz_index+1u],hz_vertices[6u*hz_index+2u]));\n\
uvec2 hz_v_uv=uvec2(0); uint hz_v_layer=0u; vec4 hz_v_color=vec4(1); uvec2 hz_v_light=uvec2(0);\n\
vec4 hz_Vertex, hz_MultiTexCoord0, hz_MultiTexCoord1, hz_MultiTexCoord2, hz_Color; vec3 hz_Normal;\n\
{}\nresult[2u*hz_index]={}; result[2u*hz_index+1u]={};\n}}",
inputs.body.replace("gl_VertexIndex", "hz_index"),
attribute("mc_Entity"),
attribute("at_midBlock")
);
let mut frontend = naga::front::glsl::Frontend::default();
let module = frontend
.parse(
&naga::front::glsl::Options::from(naga::ShaderStage::Compute),
&source,
)
.unwrap();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Original terrain numeric control"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let layouts: Vec<_> = [1, 2, 0, 3]
.into_iter()
.map(|count| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &(0..count)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: if count == 1 {
wgpu::ShaderStages::COMPUTE
} else {
wgpu::ShaderStages::COMPUTE | wgpu::ShaderStages::VERTEX
},
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage {
read_only: binding != 0 || count != 1,
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect::<Vec<_>>(),
})
})
.collect();
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
..Default::default()
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: Some(&layout),
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let storage = |bytes: &[u8]| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytes,
usage: wgpu::BufferUsages::STORAGE,
})
};
let result = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * 32,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let slots = storage(bytemuck::cast_slice(&[0u32]));
let origins = storage(bytemuck::cast_slice(&[[128.0f32, -32.0, -256.0, -1.0]]));
let ids = storage(bytemuck::cast_slice(&[
crate::mesher::TerrainSource::new(0, [2, 3, 4], 0, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, true),
]));
let vertex_storage = storage(bytemuck::cast_slice(&vertices));
let rects = storage(bytemuck::cast_slice(
&super::super::pack_post::terrain_tables(&[31, -1, 47, 59], &[0, 1]),
));
let group = |set: u32, buffers: &[&wgpu::Buffer]| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(set),
entries: &buffers
.iter()
.enumerate()
.map(|(i, b)| wgpu::BindGroupEntry {
binding: i as u32,
resource: b.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
};
let groups = [
group(0, &[&result]),
group(1, &[&slots, &origins]),
group(2, &[]),
group(3, &[&ids, &vertex_storage, &rects]),
];
queue.submit([encoder.finish()]);
let actual = dispatch(&device, &queue, &pipeline, &groups, &result);
let mut mismatches = Vec::new();
for (i, vertex) in vertices.iter().enumerate() {
let q = i / 4;
let expected_id = [31.0, 47.0, 47.0, 59.0][q];
let expected_light = [0.0, 13.0, 13.0, 13.0][q];
let owner = [
[2.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
[6.5, 3.5, 4.5],
][q];
let expected_mid = [
(owner[0] - vertex.pos[0]) * 64.0,
(owner[1] - vertex.pos[1]) * 64.0,
(owner[2] - vertex.pos[2]) * 64.0,
expected_light,
];
if actual[i * 2] != [expected_id, if q == 3 { 1.0 } else { 0.0 }, 0.0, 0.0]
|| actual[i * 2 + 1] != expected_mid
{
mismatches.push((
i,
actual[i * 2],
actual[i * 2 + 1],
expected_id,
expected_mid,
));
}
}
assert!(
mismatches.is_empty(),
"terrain source mismatches: {mismatches:?}"
);
// Rasterize the same native vertices and production input body, with a diagnostic
// projection placing one face in each strip. The fragment reports id, emission and
// reconstructed owner Y/X after a nonzero section translation; no texture color
// can hide a wrong ownership value.
let vertex_source = format!(
"#version 450\n{}\nlayout(location=0) flat out vec4 control;\n\
void main(){{vec4 hz_Vertex,hz_MultiTexCoord0,hz_MultiTexCoord1,hz_MultiTexCoord2,hz_Color;vec3 hz_Normal;\n\
{}\nvec4 entity={};vec4 mid={};\n\
control=vec4(entity.x,mid.w,hz_Vertex.y+mid.y/64.0,hz_Vertex.x+mid.x/64.0);\n\
uint corner=uint(gl_VertexIndex)%4u;float q=float(uint(gl_VertexIndex)/4u);\n\
gl_Position=vec4(-1.0+(q+float(corner>=2u))*0.5,(corner==1u||corner==2u)?1.0:-1.0,0.5,1.0);}}",
inputs.declarations,
inputs.body,
attribute("mc_Entity"),
attribute("at_midBlock")
);
let vertex_module = frontend
.parse(
&naga::front::glsl::Options::from(naga::ShaderStage::Vertex),
&vertex_source,
)
.unwrap();
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(vertex_module)),
});
let fragment_shader=device.create_shader_module(wgpu::ShaderModuleDescriptor { label:None,source:wgpu::ShaderSource::Wgsl(
"@fragment fn fs(@location(0) @interpolate(flat) control:vec4<f32>)->@location(0) vec4<f32>{return control;}".into()) });
let raster = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vertex_shader,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::super::TERRAIN_ATTRIBUTES,
})],
},
fragment: Some(wgpu::FragmentState {
module: &fragment_shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let indices: Vec<u32> = (0..4)
.flat_map(|q| [4 * q, 4 * q + 1, 4 * q + 2, 4 * q, 4 * q + 2, 4 * q + 3])
.collect();
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let pixels = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&raster);
for (i, g) in groups.iter().enumerate() {
pass.set_bind_group(i as u32, g, &[]);
}
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..24, 0, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &pixels,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
target.size(),
);
queue.submit([encoder.finish()]);
pixels
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = pixels.slice(..).get_mapped_range().unwrap();
let pixels: &[[f32; 4]] = bytemuck::cast_slice(&pixels);
for (i, pixel) in pixels.iter().enumerate() {
assert_eq!(
*pixel,
[
[31.0, 0.0, -28.5, 130.5],
[47.0, 13.0, -28.5, 134.5],
[47.0, 13.0, -28.5, 134.5],
[59.0, 13.0, -28.5, 134.5]
][i / 4],
"diagnostic raster pixel {i}"
);
}
// Reload changes only the immutable mapping; the same raw state/owner records
// resolve to new material ids without baking a previous pack's ids into a mesh.
let changed = storage(bytemuck::cast_slice(
&super::super::pack_post::terrain_tables(&[211, -1, 223, 59], &[0, 1]),
));
let changed_groups = [
groups[0].clone(),
groups[1].clone(),
groups[2].clone(),
group(3, &[&ids, &vertex_storage, &changed]),
];
let reloaded = dispatch(&device, &queue, &pipeline, &changed_groups, &result);
for i in 0..vertices.len() {
assert_eq!(
reloaded[2 * i],
[
if i < 4 {
211.0
} else if i < 12 {
223.0
} else {
59.0
},
if i >= 12 { 1.0 } else { 0.0 },
0.0,
0.0
]
);
assert_eq!(reloaded[2 * i + 1], actual[2 * i + 1]);
}
let missing = storage(&[0; 8]);
let missing_groups = [
groups[0].clone(),
groups[1].clone(),
groups[2].clone(),
group(3, &[&missing, &vertex_storage, &rects]),
];
let neutral = dispatch(&device, &queue, &pipeline, &missing_groups, &result);
for i in 0..vertices.len() {
assert_eq!(neutral[2 * i], [-1.0, 0.0, 0.0, 0.0]);
assert_eq!(neutral[2 * i + 1], [0.0; 4]);
}
}
@@ -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).
@@ -247,7 +254,7 @@ impl Casters {
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
match program {
ENTITY => self.entities && (!self.only_player || span.player),
BLOCK => self.blocks && (!self.only_lit || span.emissive),
BLOCK | BLOCK_TRANSLUCENT => self.blocks && (!self.only_lit || span.emissive),
_ => false,
}
}
@@ -440,7 +447,7 @@ impl PackEntities {
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
let program = program_of(&span).expect("tagged skin stage");
let program = material_program(&span, overlay, drawn).expect("tagged skin stage");
let pack = drawn[program] && !span.hidden;
let casts = shadow.cast(&span, program);
if !pack && !casts {
@@ -455,14 +462,16 @@ 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) in runs(entities.len(), spans) {
for (span, range, material) in material_runs(entities, spans, drawn) {
let run_start = range.start as u32;
let whole = &entities[range];
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
@@ -490,18 +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 = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
let split = ((matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|| span.stage == render_stage("NONE"))
&& whole
.chunks(4)
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
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),
}
}
@@ -509,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() {
@@ -520,7 +544,9 @@ impl PackEntities {
let program = match part {
Part::Portal => Some(BLOCK),
Part::Beam => Some(BEAM),
Part::Plain => program_of(&span).filter(|&p| {
// 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)))
}),
@@ -528,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);
}
@@ -540,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 {
@@ -562,6 +594,7 @@ impl PackEntities {
casts,
overlay,
portal,
part == Part::WaterMask,
);
}
}
@@ -633,9 +666,10 @@ impl PackEntities {
casts: bool,
overlay: u32,
portal: bool,
water_mask: bool,
) {
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
@@ -657,6 +691,7 @@ impl PackEntities {
shadow: casts,
slot: self.draws.len() as u32,
portal,
water_mask,
});
}
}
@@ -691,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
@@ -782,7 +819,76 @@ pub const PARTICLE: usize = 3;
pub const DESTROY: usize = 4;
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
pub const BEAM: usize = 5;
pub const PROGRAMS: usize = 6;
pub const BLOCK_TRANSLUCENT: usize = 6;
pub const PARTICLE_TRANSLUCENT: usize = 7;
pub const PROGRAMS: usize = 8;
/// Translucent blocks retain the ordinary block shadow program and blockEntityId.
pub fn shadow_program(program: usize) -> usize {
if program == BLOCK_TRANSLUCENT {
BLOCK
} else {
program
}
}
fn material_program(span: &EntitySpan, layer: u32, drawn: [bool; PROGRAMS]) -> Option<usize> {
Some(match program_of(span)? {
BLOCK
if drawn[BLOCK_TRANSLUCENT]
&& (span.translucent
|| layer & super::OIT_ENTITY != 0
|| matches!(
layer & 0xffff,
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
)) =>
{
BLOCK_TRANSLUCENT
}
PARTICLE
if drawn[PARTICLE_TRANSLUCENT]
&& layer & 0xffff == super::TRANSLUCENT_PARTICLE_OVERLAY =>
{
PARTICLE_TRANSLUCENT
}
p => p,
})
}
/// Split only where an available variant changes a quad's program. Keep contiguous runs,
/// their vertex order and the original single draw when no material variant is usable.
fn material_runs<'a>(
vertices: &'a [Vertex],
spans: &'a [EntitySpan],
drawn: [bool; PROGRAMS],
) -> impl Iterator<Item = (EntitySpan, Range<usize>, Option<usize>)> + 'a {
runs(vertices.len(), spans).flat_map(move |(span, range)| {
let split = match program_of(&span) {
Some(BLOCK) => drawn[BLOCK_TRANSLUCENT],
Some(PARTICLE) => drawn[PARTICLE_TRANSLUCENT],
_ => false,
};
let mut start = range.start;
std::iter::from_fn(move || {
if start >= range.end {
return None;
}
let program = material_program(&span, vertices[start].layer, drawn);
let end = if split {
(start..range.end)
.step_by(4)
.skip(1)
.find(|&i| material_program(&span, vertices[i].layer, drawn) != program)
.unwrap_or(range.end)
} else {
range.end
};
let run = start..end;
start = end;
Some((span, run, program))
})
})
}
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -940,11 +1046,17 @@ mod tests {
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
drawn_by_pack(
&spans[1],
[true, true, true, false, true, true, false, false]
),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
drawn_by_pack(
&spans[2],
[true, false, true, true, true, true, false, false]
),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
@@ -1370,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();
@@ -1676,3 +1861,6 @@ mod tier_translate_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod material_route_tests;
@@ -0,0 +1,62 @@
use super::*;
#[test]
fn optional_variants_split_contiguous_materials_without_guessing_from_alpha() {
let mut vertices = Vec::new();
for layer in [
0,
super::super::OIT_ENTITY,
0,
super::super::TRANSLUCENT_PARTICLE_OVERLAY,
0,
] {
vertices.extend(
[Vertex {
layer: layer | 3 << 16,
color: 0x40808080,
..bytemuck::Zeroable::zeroed()
}; 4],
);
}
let spans = [
EntitySpan {
stage: render_stage("BLOCK_ENTITIES"),
id: 71,
..Default::default()
},
EntitySpan {
start: 8,
stage: render_stage("PARTICLES"),
..Default::default()
},
];
let collect = |drawn| {
material_runs(&vertices, &spans, drawn)
.map(|(_, range, p)| (range, p))
.collect::<Vec<_>>()
};
let mut legacy = [true; PROGRAMS];
legacy[BLOCK_TRANSLUCENT] = false;
legacy[PARTICLE_TRANSLUCENT] = false;
assert_eq!(
collect(legacy),
[(0..8, Some(BLOCK)), (8..20, Some(PARTICLE))]
);
assert_eq!(
collect([true; PROGRAMS]),
[
(0..4, Some(BLOCK)),
(4..8, Some(BLOCK_TRANSLUCENT)),
(8..12, Some(PARTICLE)),
(12..16, Some(PARTICLE_TRANSLUCENT)),
(16..20, Some(PARTICLE)),
]
);
let casters = Casters {
blocks: true,
..Default::default()
};
assert!(casters.cast(&spans[0], BLOCK_TRANSLUCENT));
assert_eq!(shadow_program(BLOCK_TRANSLUCENT), BLOCK);
assert!(!casters.cast(&spans[1], PARTICLE_TRANSLUCENT));
}
@@ -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()
+236 -53
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.
@@ -533,8 +543,7 @@ pub(super) struct World<'a> {
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
/// (its block's light): three values a layer.
/// Per source block state: its model material id, then its fluid material id.
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
@@ -649,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`).
@@ -667,10 +681,10 @@ pub struct PackPost {
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
/// The per-draw uniforms of gbuffers programs.
draws: DrawUniforms,
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
/// for each arena page, with the page buffer it was made for.
/// Set 3 of flat terrain: an unknown-source fallback and immutable state/sprite tables.
/// Each page's bind group tracks both its vertex buffer and optional source sidecar.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
page_groups: HashMap<u32, (wgpu::Buffer, Option<wgpu::Buffer>, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
@@ -908,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),
}
}
@@ -1210,30 +1225,44 @@ pub(super) struct ShadowView {
pub depth_planes: bool,
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
/// atlas (`rects`; zeros for a grid, which doesn't read them).
fn block_ids(
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
/// Header (sprite-rect words, state count), sprite rectangles, then model/fluid ids.
/// Keeping immutable tables together leaves the existing three storage bindings intact.
pub(super) fn terrain_tables(table: &[i32], rects: &[u32]) -> Vec<i32> {
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
[rects.len() as i32, (table.len() / 2) as i32]
.into_iter()
.chain(rects.iter().map(|&v| v as i32))
.chain(table.iter().copied())
.collect()
}
pub(super) fn block_ids(
device: &wgpu::Device,
queue: &wgpu::Queue,
table: &[i32],
rects: &[u32],
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
let empty = [-1, -1, 0];
let table = if table.is_empty() { &empty[..] } else { table };
) -> Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)> {
let buffer = |label, contents: &[u8]| {
wgpu::util::DeviceExt::create_buffer_init(
if contents.len() as u64 > device.limits().max_storage_buffer_binding_size {
return None;
}
let buffer = super::arena::try_create(
device,
&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents,
usage: wgpu::BufferUsages::STORAGE,
},
)
label,
contents.len() as u64,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
)?;
queue.write_buffer(&buffer, 0, contents);
Some(buffer)
};
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
let ids = buffer("Unknown terrain sources", &[0; 8])?;
let rects = buffer(
"Shader pack terrain tables",
bytemuck::cast_slice(&terrain_tables(table, rects)),
)?;
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -1248,7 +1277,7 @@ fn block_ids(
label: Some("Shader pack terrain"),
entries: &[storage(0), storage(1), storage(2)],
});
(layout, ids, rects)
Some((layout, ids, rects))
}
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
@@ -1634,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,
@@ -1920,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.
@@ -2134,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));
@@ -2176,6 +2231,7 @@ fn build_pass(
group: p.group,
pipeline: Pipeline::Render(pipeline),
sky_parts,
water_mask,
layout,
samplers,
writes,
@@ -2315,6 +2371,7 @@ fn build_compute(
local,
},
sky_parts: None,
water_mask: None,
layout,
samplers,
writes: Vec::new(),
@@ -2464,6 +2521,12 @@ impl PackPost {
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
}
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
@@ -2577,6 +2640,22 @@ impl PackPost {
.fold(0, |m, n| m | 1 << n),
jobs: defer.then(Default::default),
};
// Flat terrain resolves each quad's source state against the current pack.
let block_ids = world
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| {
block_ids(device, queue, world.block_ids, world.sprite_rects).ok_or_else(|| {
Refused {
reason: "terrain source tables could not be allocated".into(),
notes: notes.clone(),
}
})
})
.transpose()?;
// 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) {
@@ -2627,6 +2706,7 @@ impl PackPost {
&custom_names,
&draws.layout,
&pack,
water_mask_next.get(),
)?
};
pass.compute.append(&mut computes);
@@ -2688,12 +2768,6 @@ impl PackPost {
}
Some(pass)
};
// Flat terrain reads `mc_Entity` from a table of the sprite layers.
let block_ids = world
.terrain
.as_ref()
.filter(|g| g.table.is_some())
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
buffers: g.buffers,
table: g.table,
@@ -2773,14 +2847,18 @@ impl PackPost {
post.particles,
post.damaged_block,
post.beacon_beam,
post.block_translucent,
post.particles_translucent,
];
let late = [
let mut late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
true,
post.translucent_late,
true,
];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
@@ -2804,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 {
@@ -2822,6 +2904,12 @@ impl PackPost {
}
})
});
// Translation alone is insufficient: a refused GPU pipeline must retain the
// legacy single particle pass and its timing as well as its geometry grouping.
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
late[super::pack_entities::PARTICLE] = false;
}
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))))
@@ -2845,10 +2933,12 @@ impl PackPost {
cull: false,
});
let hides_clouds = post.clouds_off || post.clouds.is_some() && cloud_geometry.is_some();
// Rain and snow test depth but never write it: Iris 1.11.7 left none of either in
// `depthtex0`, whatever `rain.depth` said (probe packs of ours, #10492).
let weather_geometry = world.weather.then(|| Geometry {
buffers: &entity_buffers,
table: None,
depth_write: post.rain_depth,
depth_write: false,
atlas: None,
material_maps: false,
ids: None,
@@ -3047,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()
@@ -3302,6 +3395,8 @@ impl PackPost {
hand_water,
depth1,
depth2,
water_mask,
water_mask_dirty: true,
shadow_targets,
shadow_terrain,
portal_rect: [0.0; 4],
@@ -3443,6 +3538,7 @@ impl PackPost {
let mut u = FrameUniforms::default();
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
u.shadow = shadow_config;
u.half_lives = half_lives;
u
},
shadow_cache: Default::default(),
@@ -3632,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)
@@ -3825,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 {
@@ -3838,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 {
@@ -3859,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,
}),
@@ -3931,7 +4042,7 @@ impl PackPost {
}
let (groups, ids) = (&self.page_groups, pass.ids);
let page = |rp: &mut wgpu::RenderPass<'_>, page: u32| {
if let Some((_, group)) = groups.get(&page).filter(|_| ids) {
if let Some((_, _, group)) = groups.get(&page).filter(|_| ids) {
rp.set_bind_group(3, group, &[]);
}
};
@@ -4042,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;
}
@@ -4085,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);
@@ -4149,7 +4292,7 @@ impl PackPost {
continue;
};
let mut draws = (geometry.draws.iter())
.filter(|d| d.shadow && d.program == program)
.filter(|d| d.shadow && super::pack_entities::shadow_program(d.program) == program)
.peekable();
if draws.peek().is_none() {
continue;
@@ -4161,7 +4304,11 @@ impl PackPost {
encoder,
pass,
size,
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
(geometry.draws.iter()).any(|d| {
d.shadow
&& super::pack_entities::shadow_program(d.program) == program
&& d.portal
}),
);
let mut on_portal = false;
let mut rp = self.begin(encoder, pass);
@@ -4862,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.
@@ -4896,6 +5053,7 @@ impl PackPost {
depth_texture,
depth1,
depth2,
water_mask,
mips,
});
self.forget_groups();
@@ -4995,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 {
@@ -5024,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;
}
@@ -5146,10 +5306,28 @@ impl PackPost {
inputs: &PackInputs,
size: [u32; 2],
window: [u32; 2],
pages: &[(u32, wgpu::Buffer)],
pages: &[TerrainPage],
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) {
@@ -5182,8 +5360,8 @@ impl PackPost {
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
for (page, buffer) in pages {
.retain(|p, (b, s, _)| pages.iter().any(|(q, c, t)| q == p && c == b && s == t));
for (page, buffer, sources) in pages {
self.page_groups.entry(*page).or_insert_with(|| {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack terrain"),
@@ -5191,7 +5369,7 @@ impl PackPost {
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ids.as_entire_binding(),
resource: sources.as_ref().unwrap_or(ids).as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
@@ -5203,7 +5381,7 @@ impl PackPost {
},
],
});
(buffer.clone(), group)
(buffer.clone(), sources.clone(), group)
});
}
}
@@ -5497,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() {
@@ -6169,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.
@@ -6406,3 +6586,6 @@ mod mipmap_box_tests;
#[cfg(test)]
mod colortex_mip_blend_tests;
#[cfg(test)]
mod material_route_tests;
@@ -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()
};
@@ -0,0 +1,134 @@
//! Original end-to-end material routing, timing and blending control; no pack assets.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::gfx::{OIT_ENTITY, TRANSLUCENT_PARTICLE_OVERLAY, Vertex, pack_draw::EntitySpan};
use crate::hud::HudBatch;
use horizon_shaders::render_stage;
use wgpu::TextureFormat as T;
#[test]
#[ignore = "requires an offscreen graphics adapter; run explicitly"]
fn authored_material_programs_render_and_legacy_draws_stay_unsplit() {
let gpu = Gpu::new().expect("an executed adapter is required for this control");
let mut vertices = Vec::new();
for (i, layer) in [0, OIT_ENTITY, 0, TRANSLUCENT_PARTICLE_OVERLAY]
.into_iter()
.enumerate()
{
let x = -1.0 + i as f32 * 0.5;
for pos in [
[x, -1.0, 0.0],
[x + 0.5, -1.0, 0.0],
[x + 0.5, 1.0, 0.0],
[x, 1.0, 0.0],
] {
vertices.push(Vertex {
pos,
layer: layer | 2 << 16,
color: 0xffff_ffff,
..bytemuck::Zeroable::zeroed()
});
}
}
let spans = [
EntitySpan {
stage: render_stage("BLOCK_ENTITIES"),
id: 17,
..Default::default()
},
EntitySpan {
start: 8,
stage: render_stage("PARTICLES"),
..Default::default()
},
];
for variants in [false, true] {
let vertex = "#version 120\nvoid main(){gl_Position=gl_Vertex;}";
let mut files = vec![
("final.vsh".into(), VERTEX.into()),
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}".into()),
("deferred.vsh".into(), VERTEX.into()),
("deferred.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragData[0]=vec4(texture2D(colortex0,uv).rgb*0.5,1.0);}".into()),
("shaders.properties".into(), "particles.ordering=mixed\nseparateEntityDraws=true\nblend.gbuffers_block=off\nblend.gbuffers_particles=off\nblend.gbuffers_block_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\nblend.gbuffers_particles_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\n".into()),
];
let programs = [
("gbuffers_block", "vec4(1.0,0.0,0.0,1.0)"),
("gbuffers_particles", "vec4(0.0,0.0,1.0,1.0)"),
("gbuffers_block_translucent", "vec4(0.0,1.0,0.0,0.5)"),
("gbuffers_particles_translucent", "vec4(1.0,1.0,0.0,0.5)"),
];
for (name, color) in programs.into_iter().take(if variants { 4 } else { 2 }) {
let expected_id = if name.starts_with("gbuffers_block") {
17
} else {
0
};
files.push((format!("{name}.vsh"), vertex.into()));
files.push((format!("{name}.fsh"), format!("#version 120\nuniform int blockEntityId; void main(){{gl_FragData[0]={color}; if (blockEntityId != {expected_id}) gl_FragData[0]=vec4(1.0);}}")));
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let mut input = super::super::pack_entities::inputs();
input.body = "hz_Vertex=vec4(hz_v_pos,1.0); hz_Color=vec4(1.0);".into();
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
entities: Some(input),
..Default::default()
},
);
let hud = HudBatch::new(SIZE);
for frame in 0..2 {
gpu.clear(&color, &depth, 0.0);
let mut rest = Vec::new();
post.prepare_entities(
&gpu.device,
&gpu.queue,
&vertices,
&spans,
[0.; 4],
[0.; 4],
&mut rest,
None,
(&[], &[]),
);
assert!(rest.is_empty());
let entities = post.entities.as_ref().unwrap();
assert_eq!(entities.geometry.draws.len(), if variants { 4 } else { 2 });
let pixels = gpu.render(
&mut post,
&color,
&depth,
&fixture_frame(&hud),
&mut |_, _, _, _| {},
);
// Early opaque draws are halved by deferred. Late variants blend alpha 1/2
// over world clear 1/16 halved by deferred, giving 1/64 + color/2.
let expected = if variants {
[[128, 0, 0], [4, 132, 4], [0, 0, 128], [132, 132, 4]]
} else {
[[128, 0, 0], [128, 0, 0], [0, 0, 255], [0, 0, 255]]
};
for (i, rgb) in expected.into_iter().enumerate() {
let pixel = pixels[(SIZE[1] / 2 * SIZE[0] + i as u32 * 4 + 2) as usize];
eprintln!(
"MATERIAL_ROUTE variants={variants} frame={frame} strip={i} actual={pixel:?} expected={rgb:?}"
);
assert!(pixel[..3].iter().zip(rgb).all(|(&a, b)| a.abs_diff(b) <= 2));
}
}
}
}
@@ -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);\
@@ -5,7 +5,7 @@ use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
@@ -16,14 +16,10 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
}))
.unwrap();
let mut depth_failures = Vec::new();
// Iris 1.11.7 left no rain or snow in `depthtex0` with `rain.depth` absent, true or false,
// drawn by `gbuffers_weather` or its fallback (probe packs of ours, #10492).
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
for (property, writes) in [
("rain.depth=true", true),
("rain.depth=false", false),
// An evidence-limited compatibility choice: preserve accepted main here.
// The permitted packs do not establish the external omitted-value default.
("", true),
] {
for property in ["rain.depth=true", "rain.depth=false", ""] {
let mut post = pack(&device, &queue, &adapter, program, property);
// Retain the pipelines for another frame, then resize their targets.
for size in [[6, 1], [6, 1], [12, 2]] {
@@ -90,7 +86,7 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
}
let depth =
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
let expected_depth = 0.5;
if depth != expected_depth {
depth_failures.push(format!(
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
@@ -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",
];
@@ -405,6 +440,8 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
impl Default for FrameUniforms {
@@ -422,6 +459,7 @@ impl Default for FrameUniforms {
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
half_lives: [None; 4],
}
}
}
@@ -734,6 +772,7 @@ impl FrameUniforms {
*self = Self {
atlas: self.atlas,
shadow: std::mem::take(&mut self.shadow),
half_lives: self.half_lives,
..Self::default()
};
}
@@ -798,10 +837,13 @@ impl FrameUniforms {
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
// centerDepthSmooth 0.1 s).
// The pack's `wetnessHalflife`/`drynessHalflife` when it sets them (OptiFine's defaults
// otherwise), as for `eyeBrightnessHalflife` and `centerDepthHalflife` below.
let [wet, dry, eye_half, depth_half] = self.half_lives;
let half_life = if inputs.rain > self.wetness {
600.0
wet.unwrap_or(600.0)
} else {
200.0
dry.unwrap_or(200.0)
};
self.wetness = smooth(self.wetness, inputs.rain, half_life * SECONDS_A_TICK, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
@@ -810,13 +852,18 @@ impl FrameUniforms {
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 * SECONDS_A_TICK, dt);
*smoothed = smooth(
*smoothed,
target,
eye_half.unwrap_or(10.0) * SECONDS_A_TICK,
dt,
);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, SECONDS_A_TICK, dt),
Some(v) => smooth(v, raw, depth_half.unwrap_or(1.0) * SECONDS_A_TICK, dt),
None => raw,
});
}
@@ -858,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());
@@ -948,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)))
@@ -1260,10 +1313,12 @@ mod tests {
format!(r#"{{"attributes": {attributes}}}"#),
)
.unwrap();
let environment =
crate::environment::Environment::load_dimension(&root, name, false).unwrap();
let environment = crate::environment::Environment::load_dimension(&root, name).unwrap();
std::fs::remove_dir_all(&root).unwrap();
environment.sample(6000, None, None)
environment.sample(&crate::environment::Sample {
day_time: 6000,
..Default::default()
})
}
#[test]
@@ -1833,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]);
@@ -2218,22 +2304,33 @@ pub(super) mod initial_history_tests {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
// The next frame comes `seconds` after the last (and a few microseconds of the test).
let after = |u: &mut FrameUniforms, seconds: f32| {
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs_f32(seconds));
// A scripted clock: the next frame comes exactly `seconds` after the last, however long
// the test itself takes (a wall-clock gap of a millisecond moved centerDepthSmooth by
// more than the tolerance on a busy CI runner).
let mut counter = 1;
let mut elapsed = 0.0;
let mut after = |input: &mut PackInputs, seconds: f32| {
counter += 1;
elapsed += seconds;
input.frame_timing = Some(FrameTiming {
counter,
elapsed,
delta: seconds,
});
};
let mut input = PackInputs {
eye_light: [0, 15],
frame_timing: Some(FrameTiming::nth(1, 0.0)),
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.eye_light = [0, 0];
u.center_depth_raw = Some(0.0);
after(&mut u, 0.1);
after(&mut input, 0.1);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "centerDepthSmooth")[0] - 0.5).abs() < 1e-3);
after(&mut u, 0.6);
after(&mut input, 0.6);
u.update(&f, &input, [800, 450]);
// 240 falling toward 0 for 0.7 s: 147.8, read as 147.
match u.values[index("eyeBrightnessSmooth").unwrap()] {
@@ -2241,11 +2338,46 @@ pub(super) mod initial_history_tests {
_ => panic!("integer uniform expected"),
}
input.rain = 1.0;
after(&mut u, 60.0);
after(&mut input, 60.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
after(&mut u, 20.0);
after(&mut input, 20.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
/// A pack's `const float wetnessHalflife = 30.0;` (and its dryness) replace the defaults:
/// wetness reaches half the rain in 3 s instead of 60 s.
#[test]
fn a_pack_s_half_lives_replace_the_defaults() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms {
half_lives: [Some(30.0), Some(10.0), None, None],
..Default::default()
};
// A scripted clock, as in `smoothed_uniforms_take_iris_half_lives`: the wall clock's gap
// between two updates is not exact on a busy runner.
let mut input = PackInputs {
frame_timing: Some(FrameTiming::nth(1, 0.0)),
..Default::default()
};
u.update(&f, &input, [800, 450]);
input.rain = 1.0;
input.frame_timing = Some(FrameTiming {
counter: 2,
elapsed: 3.0,
delta: 3.0,
});
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
input.frame_timing = Some(FrameTiming {
counter: 3,
elapsed: 4.0,
delta: 1.0,
});
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
@@ -2582,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,
}
}
@@ -274,7 +274,13 @@ impl Panorama {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_panorama"),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
@@ -1,4 +1,5 @@
// CubeMap's position-only cube and panorama.vsh/fsh: sample its untransformed direction.
override HDR_WRITE_LIGHT: bool = false;
struct Projection { view_proj: mat4x4<f32> }
@group(0) @binding(0) var<uniform> projection: Projection;
@group(0) @binding(1) var panorama: texture_cube<f32>;
@@ -26,5 +27,6 @@ struct Out {
return out;
}
@fragment fn fs_panorama(in: Out) -> @location(0) vec4<f32> {
return textureSample(panorama, panorama_sampler, in.direction);
let color = textureSample(panorama, panorama_sampler, in.direction);
return vec4(color.rgb, select(color.a, 1.0, HDR_WRITE_LIGHT));
}
@@ -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());
}
}
}
@@ -439,17 +439,18 @@ impl<T> Resident<T> {
if list.is_empty() {
continue;
}
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
let max = top + Vec3::splat(16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
let ox = f64::from(column.x * 16) - camera.x;
let oz = f64::from(column.z * 16) - camera.z;
let min_x = ox as f32;
let min_z = oz as f32;
let low_oy = (f64::from(self.min_y + column.low * 16) - camera.y) as f32;
let high_oy = (f64::from(self.min_y + column.high * 16) - camera.y) as f32;
let min = Vec3::new(min_x, low_oy, min_z);
let max = Vec3::new(min_x + 16.0, high_oy + 16.0, min_z + 16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
let dx8 = ox + 8.0;
let dz8 = oz + 8.0;
let xz_sq = dx8 * dx8 + dz8 * dz8;
@@ -22,5 +22,6 @@ struct Stars {
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
}
@@ -79,7 +79,7 @@ pub struct Post {
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
taa_previous: Option<(DMat4, DVec3)>,
taa_this_frame: TaaUniform,
light_key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
light_key: Option<(bool, [f32; 4], [f32; 4], [[f32; 4]; 2])>,
}
impl Post {
@@ -263,12 +263,21 @@ impl Post {
}
}
// History stores the baked gain as well as the picture. Reject it before jitter records
// the next frame whenever that gain or the frame's representation changes.
pub(super) fn light_representation(
// History stores baked light, so a change must reject it before jitter records the next frame.
pub(super) fn baked_light(
&mut self,
key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
gain: bool,
settings: [f32; 4],
shares: [f32; 4],
windows: [[f32; 4]; 2],
) {
// These uniforms do not change baked colour without gain. Keep compatibility
// history stable when only its final display settings change.
let key = Some(if gain {
(true, settings, shares, windows)
} else {
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
});
if self.light_key != key {
self.taa_previous = None;
self.light_key = key;
@@ -745,13 +754,15 @@ mod tests {
}
#[test]
fn written_light_changes_reject_history_before_the_next_jitter() {
fn baked_light_changes_and_returning_from_a_pack_reject_taa_history() {
let Some((device, _)) = test_device() else {
eprintln!("TAA baked-light GPU skipped: no test device");
return;
};
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba16Float);
post.taa = true;
if !post.supports_taa() {
eprintln!("TAA baked-light GPU skipped: TAA unsupported");
return;
}
let key = (
@@ -759,11 +770,11 @@ mod tests {
[0.2, 0.5, 0.6, 1.0],
[[0.1; 4], [0.4; 4]],
);
for key in [Some(key), None, Some(key)] {
post.light_representation(key);
for gain in [true, false, true] {
post.baked_light(gain, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.light_representation(key);
post.baked_light(gain, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
}
@@ -774,10 +785,26 @@ mod tests {
4..=7 => changed.1[field - 4] += 0.01,
_ => changed.2[(field - 8) / 4][(field - 8) % 4] += 0.01,
}
post.light_representation(Some(changed));
post.baked_light(true, changed.0, changed.1, changed.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
}
post.baked_light(false, key.0, key.1, key.2);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.baked_light(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2]);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
// A pack disables TAA even when the light settings stay the same. Its first
// world frame after the pack is dropped must not reuse the pre-pack history.
post.jitter([32, 32], false, Mat4::IDENTITY, DVec3::ZERO);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
eprintln!(
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
);
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
@@ -0,0 +1,697 @@
//! A native mod's world warp ([`native_world_warp`](super::native_render::native_world_warp),
//! docs/native-mods.md "World warps"): WGSL that every terrain vertex, far terrain's tiles
//! included, and entity vertex goes through, so a mod can bend the drawn world itself (its real
//! blocks, light and depth, seen from any side) rather than a picture of it.
//!
//! The stock shaders hold only comment markers where a warp goes in (terrain.wgsl's
//! `[world warp ...]`, entity.wgsl's and static_entity.wgsl's): a frame without a warp draws
//! with exactly the shaders it always did. For a warp, copies of them get the markers filled,
//! the declarations of `world_warp.wgsl`, the mod's WGSL and, for the terrain, the pixel stage of
//! `world_warp_fragment.wgsl` with the mod's `world_fragment` or [`DEFAULT_FRAGMENT`]; the
//! pipelines built from them read the warp's uniform at group 2.
//!
//! Those pipelines don't cull back faces (a warp may turn a face over), and a frame drawn with
//! them leaves nothing out by where it was (`Renderer::render`): what is out of view, hidden or
//! facing away may be brought into view. It also keeps to the per-section draws, which these
//! pipelines are built for.
use super::native_render::WorldWarp;
use super::{SolidPipeline, TERRAIN_ATTRIBUTES, TerrainVertex, Vertex};
use glam::DVec3;
use std::sync::Arc;
/// The line in a warp's source after which the terrain's pixel stage begins.
const FRAGMENTS: &str = "// [fragments]";
/// The pixel stage of a warp without one: the terrain as the game draws it, less faces it cut.
pub const DEFAULT_FRAGMENT: &str = "fn world_fragment(f: Fragment) -> Painted {
if f.cut > 0.0 { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
return Painted(sprite(f.uv, f.layer) * f.color, 1.0);
}
";
/// What terrain.wgsl's markers are filled with: the warp's two values in `TerrainOut`, and how
/// they cross to the pixel stage in `TerrainWire`, after its five locations.
const VARYINGS: &str = "warp_home: vec4<f32>,
warp_at: vec3<f32>,";
const WIRE: &str = "@location(5) warp_home: vec4<f32>,
@location(6) warp_at: vec3<f32>,";
const TO_WIRE: &str = "w.warp_home = o.warp_home;
w.warp_at = o.warp_at;";
const FROM_WIRE: &str = "o.warp_home = w.warp_home;
o.warp_at = w.warp_at;";
const VERTEX: &str = "let warp_home = position + origin;
let warped = world_warp(warp_home, origin);
let pos = warped.pos;";
const VERTEX_OUT: &str = "out.color = out.color * vec4(max(warped.tint, vec3(0.0)), 1.0);
out.warp_home = vec4(warp_home, select(0.0, 1.0, warped.tint.r < 0.0));
out.warp_at = pos;";
/// And entity.wgsl's and static_entity.wgsl's: an entity's vertex is its own `section`; a block
/// entity's is its section's corner.
const ENTITY: &str = "let at = world_warp(position, position).pos;";
const STATIC_ENTITY: &str =
"let section_corner = vec3<f32>(block_delta) - static_entity.camera_fraction.xyz;
let camera_relative = world_warp(section_corner + position, section_corner).pos;";
/// The plainest warp, for the tests of the shaders it makes.
#[cfg(test)]
pub(super) const IDENTITY: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped { return Warped(pos, vec3(1.0)); }\n";
/// `shader` with what stands between the markers `// [name]` and `// [end name]` replaced.
fn fill(shader: &str, name: &str, with: &str) -> Result<String, String> {
let (from, to) = (format!("// [{name}]"), format!("// [end {name}]"));
let a = shader
.find(&from)
.ok_or_else(|| format!("no `{from}` in the shader"))?;
let b = shader[a..]
.find(&to)
.ok_or_else(|| format!("no `{to}` in the shader"))?
+ a;
Ok(format!("{}{with}{}", &shader[..a], &shader[b + to.len()..]))
}
/// A warp's source split into what every warped shader takes and the terrain's pixel stage.
fn split(source: &str) -> (&str, Option<&str>) {
match source.find(FRAGMENTS) {
Some(at) => (&source[..at], Some(&source[at + FRAGMENTS.len()..])),
None => (source, None),
}
}
/// The warp's copy of terrain.wgsl.
pub(super) fn terrain_shader(source: &str) -> Result<String, String> {
let (vertex, fragment) = split(source);
let fragment = fragment
.filter(|f| f.contains("fn world_fragment"))
.unwrap_or(DEFAULT_FRAGMENT);
let shader = fill(&super::terrain_shader(), "world warp varyings", VARYINGS)?;
let shader = fill(&shader, "world warp wire", WIRE)?;
let shader = fill(&shader, "world warp to wire", TO_WIRE)?;
let shader = fill(&shader, "world warp from wire", FROM_WIRE)?;
let shader = fill(&shader, "world warp vertex", VERTEX)?;
let shader = fill(&shader, "world warp out", VERTEX_OUT)?;
Ok(format!(
"{shader}\n{}\n{vertex}\n{}\n{fragment}",
include_str!("world_warp.wgsl"),
include_str!("world_warp_fragment.wgsl"),
))
}
/// The warp's copy of entity.wgsl with static_entity.wgsl, for every entity pipeline it draws.
pub(super) fn entity_shader(source: &str) -> Result<String, String> {
let (vertex, _) = split(source);
let shader = fill(&super::static_entity_shader(), "world warp entity", ENTITY)?;
let shader = fill(&shader, "world warp static entity", STATIC_ENTITY)?;
Ok(format!(
"{shader}\n{}\n{vertex}",
include_str!("world_warp.wgsl")
))
}
fn validate(source: &str) -> Result<(), String> {
let module = naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?;
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.map_err(|e| e.emit_to_string(source))?;
Ok(())
}
/// The warp's uniform (`world_warp.wgsl`'s `WorldWarpUniform`): the mod's sixteen floats, then
/// the camera split into f32 and what f32 left out of it, with the frame's partial tick.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
params: [[f32; 4]; 4],
camera: [f32; 4],
camera_rest: [f32; 4],
}
fn uniform(warp: &WorldWarp, camera: DVec3, partial_tick: f32) -> Uniform {
let (params, _) = warp.params.as_chunks::<4>();
let params: [[f32; 4]; 4] = params.try_into().expect("sixteen floats");
let at = camera.as_vec3();
let rest = camera - at.as_dvec3();
Uniform {
params,
camera: [at.x, at.y, at.z, partial_tick],
camera_rest: [rest.x as f32, rest.y as f32, rest.z as f32, 0.0],
}
}
/// What the warp's pipelines are built with: the renderer's own layouts and formats.
pub(super) struct Kit {
/// terrain.wgsl's group 0, and with flat draws the per-section draws' page group 1
/// (`vs_section` reads its vertices from it).
pub terrain: wgpu::BindGroupLayout,
pub pages: Option<wgpu::BindGroupLayout>,
/// entity.wgsl's group 0 and static_entity.wgsl's group 1.
pub entity: wgpu::BindGroupLayout,
pub static_entity: wgpu::BindGroupLayout,
pub format: wgpu::TextureFormat,
pub sprite_constants: [(&'static str, f64); 2],
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
pub hdr_output: bool,
}
/// The warp's pipelines, in place of the renderer's while it is on.
pub(super) struct Pipelines {
pub solid: SolidPipeline,
pub translucent: wgpu::RenderPipeline,
pub entities: wgpu::RenderPipeline,
pub static_entities: wgpu::RenderPipeline,
pub translucent_items: wgpu::RenderPipeline,
pub text: wgpu::RenderPipeline,
pub entity_shadows: wgpu::RenderPipeline,
pub hdr_mark: wgpu::RenderPipeline,
pub hdr_entity_mark: wgpu::RenderPipeline,
}
/// The warp's uniform, and what binds it.
struct Bound {
layout: wgpu::BindGroupLayout,
buffer: wgpu::Buffer,
group: wgpu::BindGroup,
}
/// The renderer's world warp: nothing made until a mod gives one.
pub(super) struct WorldWarps {
kit: Kit,
bound: Option<Bound>,
built: Option<(Arc<str>, Pipelines)>,
/// The last source that didn't compile, so it is tried once.
refused: Option<Arc<str>>,
/// Whether the log said a warp went undrawn under a shader pack drawing the terrain.
told_undrawable: bool,
}
impl WorldWarps {
pub fn new(kit: Kit) -> Self {
Self {
kit,
bound: None,
built: None,
refused: None,
told_undrawable: false,
}
}
/// Readies this frame's warp: builds its pipelines when its source is new and writes its
/// uniform. Whether the frame draws with it: not when it is off, didn't compile, or the
/// frame can't take it (`drawable`: a shader pack draws the terrain).
pub fn frame(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
warp: &WorldWarp,
camera: DVec3,
partial_tick: f32,
drawable: bool,
) -> bool {
let Some(source) = &warp.source else {
return false;
};
let same = |s: &Arc<str>| Arc::ptr_eq(s, source) || **s == **source;
if !self.built.as_ref().is_some_and(|(s, _)| same(s)) {
if self.refused.as_ref().is_some_and(same) {
return false;
}
match self.build(device, source) {
Ok(pipelines) => self.built = Some((source.clone(), pipelines)),
Err(e) => {
log::warn!("Horizon: a mod's world warp did not compile: {e}");
self.refused = Some(source.clone());
self.built = None;
return false;
}
}
}
if !warp.on {
return false;
}
if !drawable {
if !self.told_undrawable {
log::info!(
"Horizon: a mod's world warp isn't drawn while a shader pack draws the terrain"
);
self.told_undrawable = true;
}
return false;
}
let bound = self.bound(device);
uploads.buffer(
encoder,
&bound.buffer,
0,
bytemuck::bytes_of(&uniform(warp, camera, partial_tick)),
);
true
}
/// The pipelines of the warp `frame` readied.
pub fn pipelines(&self) -> Option<&Pipelines> {
self.built.as_ref().map(|(_, p)| p)
}
/// The warp's group 2, once a frame has drawn with it.
pub fn group(&self) -> Option<&wgpu::BindGroup> {
self.bound.as_ref().map(|b| &b.group)
}
fn bound(&mut self, device: &wgpu::Device) -> &Bound {
self.bound.get_or_insert_with(|| bind(device))
}
fn build(&mut self, device: &wgpu::Device, source: &str) -> Result<Pipelines, String> {
let started = std::time::Instant::now();
let terrain = terrain_shader(source)?;
let entity = entity_shader(source)?;
validate(&terrain)?;
validate(&entity)?;
// Validation failures above are the mod's; past here wgpu's are the game's.
let warp = &self.bound.get_or_insert_with(|| bind(device)).layout;
let kit = &self.kit;
let layout = |label, groups: &[Option<&wgpu::BindGroupLayout>]| {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: groups,
..Default::default()
})
};
let terrain_layout = layout(
"Warped terrain layout",
&[Some(&kit.terrain), None, Some(warp)],
);
let pages_layout = kit.pages.as_ref().map(|pages| {
layout(
"Warped terrain layout (pulled)",
&[Some(&kit.terrain), Some(pages), Some(warp)],
)
});
let entity_layout = layout(
"Warped entity layout",
&[Some(&kit.entity), None, Some(warp)],
);
let static_layout = layout(
"Warped static block-entity layout",
&[Some(&kit.entity), Some(&kit.static_entity), Some(warp)],
);
let module = |label, source: String| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Wgsl(source.into()),
})
};
let terrain = module("Warped terrain", terrain);
let entity = module("Warped entities", entity);
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
};
let buffered = [Some(vertices), Some(instances.clone())];
let pulled = [None, Some(instances)];
// As gfx.rs's: a float frame takes light where others take marks.
let write_light = (
"HDR_WRITE_LIGHT",
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
);
let lit = [
kit.sprite_constants[0],
kit.sprite_constants[1],
write_light,
];
let plain = [
kit.sprite_constants[0],
kit.sprite_constants[1],
("HDR_LIGHTING", 0.0),
write_light,
];
let depth = |write| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
};
// As gfx.rs's terrain pipelines, without back-face culling.
let terrain_pipeline = |label,
layout: &wgpu::PipelineLayout,
vertex,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
fragment,
constants: &[(&str, f64)],
blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &terrain,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(depth(true)),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &terrain,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: kit.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
// The per-section solid draws read the arena page with flat draws (`vs_section`), and
// vertex buffers without.
let (solid_layout, solid_vertex, solid_buffers): (_, _, &[_]) = match &pages_layout {
Some(pages) => (pages, "vs_section", &pulled),
None => (&terrain_layout, "vs_terrain", &buffered),
};
let solid = |constants: &[(&str, f64)]| {
terrain_pipeline(
"Warped terrain solid",
solid_layout,
solid_vertex,
solid_buffers,
"fs_warp_solid",
constants,
None,
)
};
let solid = SolidPipeline {
plain: solid(&plain),
hdr: kit.hdr_output.then(|| solid(&lit)),
};
let translucent = terrain_pipeline(
"Warped terrain translucent",
&terrain_layout,
"vs_terrain",
&buffered,
"fs_warp_translucent",
&lit,
Some(wgpu::BlendState::ALPHA_BLENDING),
);
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let entity_vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
};
let origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
let static_buffers = [
Some(entity_vertices.clone()),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &origin_attributes,
}),
];
let entity_buffers = [Some(entity_vertices)];
// As gfx.rs's `entity_pipeline_with`, for the pipelines a warped frame swaps.
let entity_pipeline = |look: &EntityLook| {
let (layout, vertex, buffers): (_, _, &[_]) = if look.static_mesh {
(&static_layout, "vs_static_entity", &static_buffers)
} else {
(&entity_layout, "vs_entity", &entity_buffers)
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(look.label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &entity,
entry_point: Some(vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: look.cull,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
bias: look.bias,
..depth(look.depth_write)
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &entity,
entry_point: Some(look.fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[write_light],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: kit.format,
blend: look.blend,
write_mask: look.write_mask,
})],
}),
multiview_mask: None,
cache: None,
})
};
let pipelines = Pipelines {
solid,
translucent,
entities: entity_pipeline(&ENTITIES),
static_entities: entity_pipeline(&STATIC_ENTITIES),
translucent_items: entity_pipeline(&TRANSLUCENT_ITEMS),
text: entity_pipeline(&TEXT),
entity_shadows: entity_pipeline(&ENTITY_SHADOWS),
hdr_mark: entity_pipeline(&HDR_MARK),
hdr_entity_mark: entity_pipeline(&HDR_ENTITY_MARK),
};
log::info!(
"Horizon: a mod's world warp built in {} ms",
started.elapsed().as_millis()
);
Ok(pipelines)
}
}
fn bind(device: &wgpu::Device) -> Bound {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World warp"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("World warp"),
size: std::mem::size_of::<Uniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("World warp"),
layout: &layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
Bound {
layout,
buffer,
group,
}
}
/// How one of the entity pipelines a warped frame swaps draws: as gfx.rs builds the stock one
/// of the same name.
struct EntityLook {
label: &'static str,
static_mesh: bool,
cull: Option<wgpu::Face>,
depth_write: bool,
bias: wgpu::DepthBiasState,
blend: Option<wgpu::BlendState>,
fragment: &'static str,
write_mask: wgpu::ColorWrites,
}
const NO_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState {
constant: 0,
slope_scale: 0.0,
clamp: 0.0,
};
const ENTITIES: EntityLook = EntityLook {
label: "Warped entities",
static_mesh: false,
cull: None,
depth_write: true,
bias: wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.0,
clamp: 0.0,
},
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
fragment: "fs_entity",
write_mask: wgpu::ColorWrites::ALL,
};
const STATIC_ENTITIES: EntityLook = EntityLook {
label: "Warped static block entities",
static_mesh: true,
bias: NO_BIAS,
blend: None,
fragment: "fs_entity_unmarked",
..ENTITIES
};
const TRANSLUCENT_ITEMS: EntityLook = EntityLook {
label: "Warped translucent items",
bias: NO_BIAS,
..ENTITIES
};
const TEXT: EntityLook = EntityLook {
label: "Warped polygon offset text",
cull: Some(wgpu::Face::Back),
bias: wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
},
fragment: "fs_entity_unmarked",
..ENTITIES
};
const ENTITY_SHADOWS: EntityLook = EntityLook {
label: "Warped entity shadows",
cull: Some(wgpu::Face::Back),
depth_write: false,
bias: wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.015,
clamp: 0.0,
},
fragment: "fs_entity_shadow",
..ENTITIES
};
const HDR_MARK: EntityLook = EntityLook {
label: "Warped HDR particle mark",
depth_write: false,
bias: NO_BIAS,
blend: Some(wgpu::BlendState::REPLACE),
fragment: "fs_hdr_mark",
write_mask: wgpu::ColorWrites::ALPHA,
..ENTITIES
};
/// `HDR_MARK` at the entities' depth bias, for what they drew.
const HDR_ENTITY_MARK: EntityLook = EntityLook {
label: "Warped HDR entity mark",
bias: ENTITIES.bias,
..HDR_MARK
};
#[cfg(test)]
mod tests {
use super::*;
const BEND: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
let lift = sin(pos.x * 0.1 + warp.params[0].x) * warp.params[0].y + section.y * 0.0;
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
}
";
#[test]
fn a_warp_without_fragments_draws_the_terrain_as_the_game_does() {
let terrain = terrain_shader(BEND).unwrap();
validate(&terrain).unwrap();
assert!(terrain.contains(DEFAULT_FRAGMENT));
let entity = entity_shader(BEND).unwrap();
validate(&entity).unwrap();
}
#[test]
fn a_warps_own_fragments_replace_the_default_and_stay_out_of_the_entities() {
let source = format!(
"{BEND}{FRAGMENTS}
fn world_fragment(f: Fragment) -> Painted {{
if !f.front || f.light.x < 1.0 {{ return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }}
let far = length(f.home - f.at) + f.cut;
return Painted(sprite(f.uv * 2.0, f.layer) * f.color, 1.0 - min(far, 1.0));
}}
"
);
let terrain = terrain_shader(&source).unwrap();
validate(&terrain).unwrap();
assert!(!terrain.contains(DEFAULT_FRAGMENT));
let entity = entity_shader(&source).unwrap();
validate(&entity).unwrap();
assert!(!entity.contains("world_fragment"));
}
#[test]
fn the_stock_shaders_hold_nothing_of_a_warp() {
for (name, shader) in [
("terrain", super::super::terrain_shader()),
("entity", super::super::static_entity_shader()),
] {
validate(&shader).unwrap();
for word in ["world_warp(", "warp_home", "Warped", "group(2)"] {
assert!(!shader.contains(word), "{name}.wgsl holds {word}");
}
}
}
#[test]
fn every_marker_is_found() {
let terrain = terrain_shader(BEND).unwrap();
let entity = entity_shader(BEND).unwrap();
for shader in [&terrain, &entity] {
assert!(!shader.contains("// [world warp"), "a marker left unfilled");
}
assert!(terrain.contains("let warped = world_warp(warp_home, origin);"));
assert!(entity.contains("let at = world_warp(position, position).pos;"));
assert!(entity.contains("world_warp(section_corner + position, section_corner)"));
assert_eq!(
fill("a // [x] b // [end x] c", "x", "B"),
Ok("a B c".to_owned())
);
assert!(fill("a // [x] b", "x", "B").is_err());
}
#[test]
fn a_warp_that_does_not_compile_is_refused_with_naga_s_reason() {
let terrain =
terrain_shader("fn world_warp(pos: vec3<f32>) -> Warped { return 1; }").unwrap();
let e = validate(&terrain).unwrap_err();
assert!(e.contains("world_warp"), "{e}");
}
#[test]
fn the_uniform_keeps_the_camera_exact_far_out() {
let mut warp = WorldWarp::default();
warp.params[5] = 2.0;
let camera = DVec3::new(29_999_984.3, 70.25, -12_345_678.9);
let u = uniform(&warp, camera, 0.25);
assert_eq!(u.params[1][1], 2.0);
assert_eq!(u.camera[3], 0.25);
for i in 0..3 {
let whole = f64::from(u.camera[i]) + f64::from(u.camera_rest[i]);
assert!((whole - camera[i]).abs() < 1e-6, "{whole} {}", camera[i]);
}
assert_eq!(std::mem::size_of::<Uniform>(), 96);
}
}
@@ -0,0 +1,21 @@
// A native mod's world warp (gfx/world_warp.rs, docs/native-mods.md "World warps"): what the
// game declares around the mod's WGSL in the warp's own copies of terrain.wgsl and entity.wgsl.
// None of it is in the shaders a frame without a warp draws with.
// What `world_warp` makes of a vertex: where to draw it, from the camera, and what to multiply
// its colour by (terrain only; red under 0 marks its faces cut: `Fragment.cut`).
struct Warped {
pos: vec3<f32>,
tint: vec3<f32>,
}
struct WorldWarpUniform {
// `WorldWarp::params`.
params: array<vec4<f32>, 4>,
// xyz: the camera in the world as f32; `camera_rest.xyz` what f32 left out of it, so
// `(p - warp.camera.xyz) - warp.camera_rest.xyz` is the world's point `p` from the camera,
// exact far from the world's origin. w: the frame's partial tick, 0 to 1, for motion
// smoother than the ticks `params` are set in.
camera: vec4<f32>,
camera_rest: vec4<f32>,
}
@group(2) @binding(0) var<uniform> warp: WorldWarpUniform;
@@ -0,0 +1,70 @@
// A world warp's terrain pixels (gfx/world_warp.rs): added to the warp's copy of terrain.wgsl
// only. `world_fragment` is the mod's if its source has one after `// [fragments]`, else
// `DEFAULT_FRAGMENT` in gfx/world_warp.rs (the terrain as the game draws it).
// A pixel of the land as `world_fragment` gets it.
struct Fragment {
// Where this point of the world really is, from the camera: `world_warp`'s `pos`.
home: vec3<f32>,
// Where it is drawn, from the camera.
at: vec3<f32>,
// `sprite(uv, layer)` is the block's texture here.
uv: vec2<f32>,
layer: u32,
// The vertices' colour: biome tint, light, ambient occlusion and the warp's tint.
color: vec4<f32>,
// Sky and block light, 0 to 15.
light: vec2<f32>,
// Over 0 where a vertex of the face had a tint with red under 0.
cut: f32,
// False on a face's back: the warp's pipelines don't cull, so both sides draw.
front: bool,
}
// What `world_fragment` makes of it: a colour (alpha under 0 leaves the pixel out) and how much
// of the game's fog it takes, 0 to 1.
struct Painted {
color: vec4<f32>,
fog: f32,
}
fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
return Fragment(in.warp_home.xyz, in.warp_at, in.uv, in.layer & 0x7fffu, in.color,
in.scene.yx, in.warp_home.w, front);
}
// As `solid_color`, with the mod's colour in place of the texture's.
@fragment fn fs_warp_solid(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let in = from_wire(wire);
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
// (far terrain's tiles and the sections replacing them still cross-fade)
if faded(in.fade, in.local, dx, dy) { discard; }
let p = world_fragment(warp_fragment(in, front));
if p.color.a < 0.0 { discard; }
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
let finished = solid_finish_at(in, color, color, dpdx(in.uv), dpdy(in.uv));
return solid_light(vec4(mix(color.rgb, finished.rgb, p.fog), finished.a));
}
// As `fs_translucent`.
@fragment fn fs_warp_translucent(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let in = from_wire(wire);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let p = world_fragment(warp_fragment(in, front));
if p.color.a < 0.0 { discard; }
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
if color.a < 0.01 { discard; }
let fogged = apply_fog(color, in.spherical, in.cylindrical);
let finished = vec4(mix(color.rgb, fogged.rgb, p.fog), color.a);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
}
return finished;
}
+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.
+181 -60
View File
@@ -6,14 +6,14 @@
//! 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,
lang,
options_ui::{Event, Page as OptionsPage},
settings::{Opt, Settings},
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
text::Text,
};
@@ -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,
@@ -1333,7 +1331,7 @@ fn shown(item: Item, s: &Settings) -> bool {
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
fn parts(item: Item, s: &Settings) -> (String, Option<String>) {
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Key`).
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
if item == Item::Horizon(H::HdrKey) {
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
return (lang::get("Shortcut"), Some(key));
@@ -1347,9 +1345,10 @@ fn row(setting: Setting, s: &Settings) -> Row<Item> {
let item = setting.item;
let (name, value) = parts(item, s);
let value = match (item, item.switch(s), item.slider(s)) {
(Item::Horizon(H::HdrKey), ..) => {
Value::Key(crate::graphics::HDR_KEY, value.unwrap_or_default())
}
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
crate::graphics::HDR_KEY,
value.unwrap_or_default(),
)),
(_, Some(on), _) => Value::Switch(on),
(_, None, Some(position)) => Value::Slider {
position,
@@ -1442,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(),
@@ -1466,6 +1466,8 @@ impl Screen for GraphicsScreen {
.collect(),
})
.collect(),
listed: true,
heading: None,
};
rows().into_iter().map(sheet).collect()
}
@@ -1486,7 +1488,6 @@ impl Screen for GraphicsScreen {
Output::Push(OptionsPage::ShaderPacks),
]
}
Item::Horizon(H::HdrKey) => vec![Output::BindKey(crate::graphics::HDR_KEY)],
_ => {
item.cycle(s, back, self.framebuffer);
vec![Output::Event(Event::Changed)]
@@ -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 {
@@ -1564,7 +1566,7 @@ mod tests {
use super::*;
use crate::{
graphics_style as style,
settings_shell::{Columns, Hosted, Shell, Target},
settings_shell::{Capture, Columns, Hosted, Shell, Target},
};
use winit::keyboard::KeyCode as K;
@@ -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();
@@ -1868,11 +1873,38 @@ mod tests {
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
assert_eq!(
press(&mut ui, &mut s, at(key)),
[CLICK, Output::BindKey(crate::graphics::HDR_KEY)]
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
);
assert!(ui.changed());
}
#[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);
}
@@ -2092,7 +2124,8 @@ mod tests {
);
let key = find(&s, Item::Horizon(H::HdrKey));
assert_eq!(key.name, "Shortcut");
assert!(matches!(key.value, Value::Key(crate::graphics::HDR_KEY, _)));
let waits = Capture::Key(crate::graphics::HDR_KEY);
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
assert_eq!(calibrate.value, Value::Press);
}
@@ -2394,6 +2427,19 @@ mod tests {
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
}
#[test]
fn left_and_right_step_a_focused_choice_back_and_on() {
let mut s = Settings::default();
let mut ui = ui();
row(&mut ui, &s, Item::Horizon(H::EntityCulling));
focus_on(&mut ui, Item::Horizon(H::EntityCulling));
assert_eq!(key(&mut ui, &mut s, K::ArrowLeft), [CLICK, CHANGED]);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
assert_eq!(key(&mut ui, &mut s, K::ArrowRight), [CLICK, CHANGED]);
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
assert_eq!(ui.focused_row(), Some(Item::Horizon(H::EntityCulling)));
}
#[test]
fn entity_culling_precheck_slider_is_only_live_for_async() {
let item = Item::Horizon(H::EntityCullingPrecheck);
@@ -2716,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);
@@ -2733,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);
@@ -2748,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
@@ -2755,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;
+43 -14
View File
@@ -34,7 +34,7 @@ struct Params {
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@group(0) @binding(3) var logo: texture_2d<f32>;
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// directly from its writers; the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
// much of the world it hides there (gfx/hud_coverage.rs).
@group(0) @binding(4) var coverage: texture_2d<f32>;
@group(0) @binding(5) var hud: texture_2d<f32>;
@@ -211,16 +211,6 @@ fn display(light: vec3<f32>) -> vec4<f32> {
return display(light);
}
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1, past the
// peak if they lift it there (the tone curve eases it in). What draws after it (camera overlays,
// the menu blur) draws on light, and nothing is lifted later.
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
let shows = params.world > 0.5 && params.peak > 1.0;
return vec4(pow(lit(w.rgb, w.a, shows, false).light, vec3(1.0 / 2.2)), 1.0);
}
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
@@ -271,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;
}
@@ -294,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);
}
}
+217 -55
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,
@@ -1355,7 +1342,15 @@ impl HudAtlas {
.filter(|&size| size != (w, h));
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
let animated = frames && animation.is_some();
let (gw, gh) = gui_size.unwrap_or((w, h));
// The cap below keeps GUI textures small; a particle sprite isn't capped, as
// vanilla's particle atlas stitches any size (packs draw light shafts and fog
// banks from 1024-wide textures).
let particle = name.starts_with("particle/") || name.contains(":particle/");
let (gw, gh) = if particle {
(0, 0)
} else {
gui_size.unwrap_or((w, h))
};
let maps = read_maps
.then(|| {
let stem = path.file_stem()?.to_str()?;
@@ -1520,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);
@@ -1737,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,
@@ -1834,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.
@@ -1893,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;
@@ -1911,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(
@@ -2529,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.
@@ -2554,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> {
@@ -2628,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 {
@@ -2658,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 {
@@ -2667,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),
@@ -2682,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(),
@@ -2706,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.
@@ -2730,7 +2805,7 @@ impl HudRenderer {
},
},
"fs_crosshair",
false,
None,
);
// BlendFunction.ADDITIVE: ONE, ONE.
let add = wgpu::BlendComponent {
@@ -2745,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(
@@ -2763,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 {
@@ -2786,6 +2872,7 @@ impl HudRenderer {
invert_pipeline,
additive_pipeline,
depth_pipeline,
depth_glint_pipeline,
vignette_pipeline,
end_portal_pipeline,
layout,
@@ -2993,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,
@@ -3001,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 {
@@ -3052,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) => {
@@ -3427,6 +3525,32 @@ mod tests {
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
/// A pack's particle sprite of any size packs whole, as vanilla's particle atlas stitches
/// it (a light shaft drawn from a 1024-wide texture); the cap on GUI textures stays.
#[test]
fn large_particle_sprites_pack_and_large_gui_textures_dont() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-big-particle-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let rays = root.join("assets/mypack/textures/particle/light_rays");
fs::create_dir_all(&rays).unwrap();
write_strip(
&rays.join("02.png"),
1024,
1024,
&[[250, 240, 200, 255]],
None,
);
let gui = root.join("assets/minecraft/textures/gui");
fs::create_dir_all(&gui).unwrap();
write_strip(&gui.join("huge.png"), 2048, 64, &[[1, 2, 3, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["mypack:particle/light_rays/02"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (1024, 1024));
assert_eq!(texel_at(&atlas, rect, 512, 512), [250, 240, 200, 255]);
assert!(!atlas.sprites.contains_key("gui/huge"));
fs::remove_dir_all(&root).unwrap();
}
/// OptiFine random entity variants outside `textures/` pack under the names
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
#[test]
@@ -4567,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);
@@ -12,8 +12,6 @@ impl Gpu {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !hud_coverage::supported(
(false, true, true),
true,
adapter.limits().max_color_attachments,
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
@@ -45,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()),
@@ -72,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] =
@@ -86,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,
@@ -141,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())
@@ -150,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!(
@@ -221,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 {
@@ -0,0 +1,29 @@
//! The HUD kit: small immediate-mode pieces Horizon's own overlays share, so they fit around
//! vanilla's HUD and look alike (`docs/ui-kit.md`). Nothing here reaches gameplay; the game
//! builds the inputs (`Game::hud_room`) and the overlays draw with them.
//!
//! - `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,64 @@
//! Plates: the boxes Horizon's HUD overlays draw behind their text, one place for each look so
//! overlays that mean the same thing look the same.
//!
//! A plate is drawn first, then the overlay's text and glyphs over it. Only the kinds the
//! overlays use today are here; each keeps the colours its overlay always had. Today that is
//! one, shared by the controller notice and splitscreen's prompts.
//!
//! ```
//! use horizon_client_render::hud_kit::plate::Plate;
//!
//! // A notice's box: a dark fill and a faint 1 px edge inside its area.
//! assert_eq!(Plate::Notice.fill(), 0xD200_0000);
//! assert_eq!(Plate::Notice.edge(), Some(0x5AFF_FFFF));
//! ```
//!
//! In a draw: `plate::draw(batch, atlas, Plate::Notice, [x, y, w, h])`, the area in GUI px.
use crate::hud::{HudAtlas, HudBatch, rgba};
/// A plate's look.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Plate {
/// Something to read now: controller notices and splitscreen's prompts. A dark fill with a
/// faint edge.
Notice,
}
impl Plate {
/// The fill colour as `0xAARRGGBB`.
pub fn fill(self) -> u32 {
match self {
Self::Notice => 0xD200_0000,
}
}
/// The 1 GUI px edge's colour as `0xAARRGGBB`, drawn inside the area; `None` for none.
pub fn edge(self) -> Option<u32> {
match self {
Self::Notice => Some(0x5AFF_FFFF),
}
}
}
/// `[a, r, g, b]` of `0xAARRGGBB` as the batch's colour.
fn colour(argb: u32) -> u32 {
let [a, r, g, b] = argb.to_be_bytes();
rgba(r, g, b, a)
}
/// Draws `plate` over `[x, y, w, h]` (GUI px): its fill, then its edge inside the area.
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, plate: Plate, [x, y, w, h]: [f32; 4]) {
batch.fill(atlas, x, y, w, h, colour(plate.fill()));
if let Some(edge) = plate.edge() {
let edge = colour(edge);
for [ex, ey, ew, eh] in [
[x, y, w, 1.0],
[x, y + h - 1.0, w, 1.0],
[x, y, 1.0, h],
[x + w - 1.0, y, 1.0, h],
] {
batch.fill(atlas, ex, ey, ew, eh, edge);
}
}
}
@@ -0,0 +1,137 @@
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
//!
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
//! nothing showing it is empty, and every overlay stays exactly where it always was.
//!
//! All sizes are GUI px, from the view's top left. An area is `[x, y, w, h]`.
//!
//! ```
//! use horizon_client_render::hud_kit::room::{Keep, Room};
//! use horizon_client_render::hud_overlays::boss_bars_bottom;
//!
//! // A centred notice wants y = 18.
//! let empty = Room::new(320.0, 240.0);
//! assert_eq!(empty.below_top(18.0), 18.0);
//! // One boss bar ends at y 17: no overlap, so the notice stays.
//! let one = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 17.0]);
//! assert_eq!(one.below_top(18.0), 18.0);
//! // Two end at y 36: the notice goes below them, a gap past.
//! let bars = boss_bars_bottom(240, 2) as f32;
//! let two = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, bars]);
//! assert_eq!(two.below_top(18.0), bars + 2.0);
//! ```
/// An area in GUI px: `[x, y, w, h]` from the view's top left.
pub type Area = [f32; 4];
/// The gap, in GUI px, an overlay leaves below a keep-out it moved past.
pub const GAP: f32 = 2.0;
/// A HUD element Horizon's overlays keep clear of.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Keep {
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
BossBars,
}
/// A view and the keep-outs its HUD shows this frame.
///
/// Invariants: the view's size is what the overlays draw in (the `HudBatch`'s size), and an
/// empty keep-out (no width or height) is never stored, so a room built from hidden elements
/// equals `Room::new`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Room {
width: f32,
height: f32,
keeps: Vec<(Keep, Area)>,
}
impl Room {
/// A `width` x `height` GUI px view with nothing to keep clear of.
pub fn new(width: f32, height: f32) -> Self {
Self {
width,
height,
keeps: Vec::new(),
}
}
/// This room with `area` kept clear of as `keep`; an empty area is ignored.
pub fn with(mut self, keep: Keep, area: Area) -> Self {
self.add(keep, area);
self
}
/// Keeps clear of `area` as `keep`; an empty area is ignored.
pub fn add(&mut self, keep: Keep, area: Area) {
if area[2] > 0.0 && area[3] > 0.0 {
self.keeps.push((keep, area));
}
}
/// The view's width in GUI px.
pub fn width(&self) -> f32 {
self.width
}
/// The view's height in GUI px.
pub fn height(&self) -> f32 {
self.height
}
/// The keep-outs, in the order they were added.
pub fn keeps(&self) -> impl Iterator<Item = (Keep, Area)> + '_ {
self.keeps.iter().copied()
}
/// How far, in GUI px from the view's top, the keep-outs at the top reach down: the boss
/// bars' bottom, or 0 with none.
pub fn top(&self) -> f32 {
self.keeps()
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
.fold(0.0, f32::max)
}
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
/// `GAP` below that keep-out.
pub fn below_top(&self, wanted: f32) -> f32 {
let top = self.top();
if top > wanted { top + GAP } else { wanted }
}
/// The view below the top keep-outs, with `GAP` past them: where a centred overlay may go.
/// The whole view when nothing is kept clear of.
pub fn usable(&self) -> Area {
let top = self.top();
let top = if top > 0.0 { top + GAP } else { 0.0 };
[0.0, top, self.width, (self.height - top).max(0.0)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_room_is_the_whole_view_and_moves_nothing() {
let room = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 0.0]);
assert_eq!(room, Room::new(320.0, 240.0));
assert_eq!(room.usable(), [0.0, 0.0, 320.0, 240.0]);
assert_eq!(room.below_top(18.0), 18.0);
}
#[test]
fn an_overlay_moves_only_when_the_bars_reach_past_it() {
let bars =
|bottom: f32| Room::new(500.0, 300.0).with(Keep::BossBars, [159.0, 0.0, 182.0, bottom]);
// Ending above or at its top: no overlap.
assert_eq!(bars(17.0).below_top(18.0), 18.0);
assert_eq!(bars(18.0).below_top(18.0), 18.0);
// Past it: below, a gap away.
assert_eq!(bars(36.0).below_top(18.0), 38.0);
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
}
}
@@ -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);
}
}
@@ -50,10 +50,14 @@ pub fn text_with_backdrop(
text(out, t, x, y, color, true);
}
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts: Java's
/// TimSort below `MIN_MERGE` (a natural run, then binary insertion), which
/// matters when the comparator is not a total order. Longer arrays fall back
/// to a stable sort.
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts. Its Javadoc
/// says it is adapted from Tim Peters's list sort for Python, and for fewer than 32
/// elements that sort is one run plus binary insertion, as Tim Peters describes it
/// in `listsort.txt` (CPython's `Objects/listsort.txt`): take the run at the start,
/// reversed in place when it is strictly descending, then insert each remaining
/// element by binary search after the elements equal to it, so the sort is stable.
/// The order of the comparisons matters because vanilla's comparator here is not a
/// total order. Longer arrays fall back to a stable sort.
pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
let n = a.len();
if n < 2 {
@@ -63,7 +67,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
a.sort_by(cmp);
return;
}
// countRunAndMakeAscending.
// The run at the start: strictly descending (then reversed) or non-descending.
let mut run = 2;
if cmp(&a[1], &a[0]) == Ordering::Less {
while run < n && cmp(&a[run], &a[run - 1]) == Ordering::Less {
@@ -75,7 +79,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
run += 1;
}
}
// binarySort.
// Binary insertion of the rest, each element after the last one not greater.
for start in run..n {
let (mut left, mut right) = (0, start);
while left < right {
@@ -571,6 +575,41 @@ pub fn boss_overlay(w: i32, h: i32, bars: &[BossBar], font: &Font) -> Vec<GuiDra
out
}
/// `BossHealthOverlay`'s first bar's top edge, in GUI px (its name sits 9 px above).
pub const BOSS_BAR_TOP: i32 = 12;
/// `BossHealthOverlay`'s step from one bar to the next, in GUI px (`10 + 9`).
pub const BOSS_BAR_STEP: i32 = 10 + 9;
/// A boss bar's sprite height, in GUI px.
pub const BOSS_BAR_HEIGHT: i32 = 5;
/// A boss bar's sprite width, in GUI px; it is centred on the view.
pub const BOSS_BAR_WIDTH: i32 = 182;
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
/// as vanilla's `guiHeight() / 3`), but always draws the first.
pub fn boss_bars_drawn(h: i32, n: usize) -> usize {
let mut drawn = 0;
let mut y = BOSS_BAR_TOP;
while drawn < n {
drawn += 1;
y += BOSS_BAR_STEP;
if y >= h / 3 {
break;
}
}
drawn
}
/// The bottom edge, in GUI px from the view's top, of the last of `n` boss bars a view `h` GUI
/// px tall shows (`boss_overlay`'s layout); 0 when there are none. Names sit above their bar,
/// so nothing of the overlay reaches below this.
pub fn boss_bars_bottom(h: i32, n: usize) -> i32 {
match boss_bars_drawn(h, n) {
0 => 0,
k => BOSS_BAR_TOP + BOSS_BAR_STEP * (k as i32 - 1) + BOSS_BAR_HEIGHT,
}
}
/// `JumpableVehicleBar.extractBackground`: `cooldown` is the mount's jump
/// cooldown and `scale` the player's `getJumpRidingScale()`.
pub fn jump_bar(w: i32, h: i32, cooldown: i32, scale: f32) -> Vec<GuiDraw> {
@@ -1381,3 +1420,64 @@ mod text_position_tests {
assert_eq!(relative, Some(-4));
}
}
#[cfg(test)]
mod boss_bar_room_tests {
use super::*;
/// `BossHealthOverlay.extractRenderState` worked by hand: bars at y 12, 31, 50, 69, 88...,
/// each 5 tall, until the next top reaches `guiHeight() / 3`.
#[test]
fn boss_bars_end_where_vanillas_overlay_stops() {
assert_eq!(boss_bars_bottom(300, 0), 0);
assert_eq!(boss_bars_bottom(300, 1), 17);
assert_eq!(boss_bars_bottom(300, 2), 36);
assert_eq!(boss_bars_bottom(300, 3), 55);
// A third of 300 is 100: the fifth bar is the last (its next top would be 107).
assert_eq!(boss_bars_drawn(300, 9), 5);
assert_eq!(boss_bars_bottom(300, 9), 93);
// A short view still draws the first.
assert_eq!(boss_bars_bottom(60, 3), 17);
// `guiHeight() / 3` is integer division: 152 / 3 = 50 stops at two, 153 / 3 = 51 not.
assert_eq!(boss_bars_drawn(152, 3), 2);
assert_eq!(boss_bars_drawn(153, 3), 3);
}
#[test]
fn boss_bars_bottom_is_below_every_drawn_bar() {
// The missing-glyph metrics: no game assets needed.
let font = Font::load(
std::path::Path::new("/nonexistent-horizon-hud-test-resources"),
&std::collections::HashMap::new(),
&[],
(1, 1),
(0, 0),
crate::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let bars: Vec<BossBar> = (0..6)
.map(|_| BossBar {
name: Text::literal(""),
progress: 1.0,
color: 0,
overlay: 0,
})
.collect();
for h in [60, 120, 152, 153, 240, 300, 600] {
let bottom = boss_overlay(400, h, &bars, &font)
.iter()
.filter_map(|d| match d {
GuiDraw::Sprite { y, h, .. } => Some(y + h),
_ => None,
})
.max();
assert_eq!(bottom, Some(boss_bars_bottom(h, bars.len())), "height {h}");
}
}
}
+4
View File
@@ -55,6 +55,7 @@ pub mod graphics_style;
pub mod graphics_ui;
pub mod hdr_calibration;
pub mod hud;
pub mod hud_kit;
pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
@@ -81,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;
@@ -89,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;
@@ -109,6 +112,7 @@ pub mod shader_pack_list;
pub mod shader_ui;
pub mod signs;
pub mod skins;
pub mod social;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
+9 -7
View File
@@ -653,15 +653,17 @@ fn pack_lighting(
sky_light: bool,
) -> Vec<Light> {
let open = if sky_light { 0xF0 } else { 0x00 };
let mut data = [open; 4096];
(0..sections)
.map(|si| {
if si * 16 >= h {
return Light::Uniform(open);
}
let mut data = Box::new([open; 4096]);
for ly in 0..16 {
let y = si * 16 + ly;
if y >= h {
let out_row = ly << 8;
data[out_row..out_row + 256].fill(open);
continue;
}
let y_offset = y * (W * W);
@@ -674,7 +676,7 @@ fn pack_lighting(
}
}
}
Light::packed(data)
Light::from_slice(&data)
})
.collect()
}
@@ -1281,7 +1283,7 @@ fn relight_layer(
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
@@ -1310,13 +1312,13 @@ mod tests {
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
}
const AIR: u32 = 0;
pub(crate) const AIR: u32 = 0;
const STONE: u32 = 1;
const TORCH: u32 = 2;
pub(crate) const TORCH: u32 = 2;
const SLAB: u32 = 3;
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
/// empty top), with a third face shape for the half sides.
fn tables() -> LightTables {
pub(crate) fn tables() -> LightTables {
let cell = |dampening, emission, faces: [u16; 6]| Cell {
dampening,
emission,
@@ -1335,7 +1337,7 @@ mod tests {
3,
)
}
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
pub(crate) fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
}
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
+296 -9
View File
@@ -7,9 +7,12 @@ use crate::{
mesher::SectionMesh,
streaming::{LodSource, WorkerContext},
};
pub mod bake;
pub mod cache;
mod kept;
mod mesh;
pub mod packed;
mod samples;
use horizon_world::registry::BlockStateId;
use std::{
collections::VecDeque,
@@ -200,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;
@@ -957,7 +969,7 @@ type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
/// every tile's inputs from it (gathering on the render thread stalled the
/// frame, tens of milliseconds for a large job on a 4-thread machine).
struct Snapshot {
samples: FxHashMap<TileKey, Arc<Vec<Column>>>,
samples: samples::Samples,
saved: Saved,
reduced: Reduced,
/// The plan's tiles, to tell terrain not drawn yet from terrain out of
@@ -1267,6 +1279,8 @@ pub struct Lod {
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
restale: FxHashSet<TileKey>,
active: FxHashSet<TileKey>,
/// The active tiles' `ready` samples, as mesh jobs read them (`sync_sample`).
mesh_samples: samples::Samples,
/// Tiles needing a mesh, with the revision their job must carry.
dirty: FxHashMap<TileKey, u64>,
/// The newest revision of each active tile's mesh.
@@ -1317,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,
@@ -1657,6 +1677,7 @@ impl Lod {
ready: FxHashMap::default(),
complete: FxHashMap::default(),
active: FxHashSet::default(),
mesh_samples: samples::Samples::default(),
dirty: FxHashMap::default(),
mesh_version: FxHashMap::default(),
shown_version: FxHashMap::default(),
@@ -1683,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,
}
}
@@ -1708,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;
@@ -1935,9 +1963,11 @@ impl Lod {
if active != was_active {
if active {
self.active.insert(node);
self.sync_sample(node);
self.removed_pending.remove(&node);
} else {
self.active.remove(&node);
self.sync_sample(node);
self.removed_pending.insert(node);
self.cancel_mesh(node);
self.mesh_version.remove(&node);
@@ -2023,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
@@ -2130,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.
@@ -2210,6 +2257,7 @@ impl Lod {
}
}
for key in modified {
self.sync_sample(key);
self.mark_data_changed(key);
}
}
@@ -2271,6 +2319,7 @@ impl Lod {
return;
}
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
self.sync_sample(key);
self.refresh_ready(key, existed);
}
}
@@ -2455,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));
@@ -2464,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();
}
@@ -2504,7 +2556,43 @@ 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
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
@@ -2531,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) {
@@ -2572,6 +2735,7 @@ impl Lod {
.retain(|key| !self.active.contains(key));
let changed: Vec<_> = old.symmetric_difference(&self.active).copied().collect();
for &key in &changed {
self.sync_sample(key);
if old.contains(&key) && !self.active.contains(&key) {
self.cancel_mesh(key);
self.removed_pending.insert(key);
@@ -2849,6 +3013,14 @@ impl Lod {
if self.outstanding_meshes >= self.mesh_workers * 4 {
return;
}
// As in `request_samples`: the far meshing threads run behind the render thread, so it
// never waits for this lock. Busy now, the dirty tiles stay dirty for the next frame.
let shared = self.shared.clone();
let mut queued = match shared.mesh_jobs.try_lock() {
Ok(queued) => queued,
Err(std::sync::TryLockError::WouldBlock) => return,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
};
let keys: Vec<(TileKey, u64)> = self
.dirty
.drain()
@@ -2857,19 +3029,16 @@ impl Lod {
if keys.is_empty() {
return;
}
#[cfg(debug_assertions)]
self.check_mesh_samples();
let snapshot = Arc::new(Snapshot {
samples: self
.active
.iter()
.filter_map(|key| self.ready.get(key).map(|columns| (*key, columns.clone())))
.collect(),
samples: self.mesh_samples.clone(),
saved: self.saved.clone(),
reduced: self.reduced.clone(),
nodes: self.plan.nodes.clone(),
split: self.plan.split.clone(),
});
let covered = Arc::new(self.covered.clone());
let mut queued = self.shared.mesh_jobs.lock().unwrap();
let count = keys.len();
for (key, version) in keys {
let finer = self.finer_of(key);
@@ -2895,6 +3064,31 @@ impl Lod {
self.outstanding_meshes += count;
self.shared.mesh_wake.notify_all();
}
/// Brings `mesh_samples` up to date for `key` after its `active` or `ready` changed.
fn sync_sample(&mut self, key: TileKey) {
let samples = self.ready.get(&key).filter(|_| self.active.contains(&key));
self.mesh_samples.set(key, samples);
}
/// `mesh_samples` holds every active tile's ready samples and nothing else (debug builds).
#[cfg(debug_assertions)]
fn check_mesh_samples(&self) {
let kept = self.mesh_samples.iter().count();
let mut want = 0;
for key in &self.active {
if let Some(columns) = self.ready.get(key) {
want += 1;
let got = self.mesh_samples.get(key);
assert!(
got.is_some_and(|got| Arc::ptr_eq(got, columns)),
"mesh samples of {key:?} out of date"
);
}
}
assert_eq!(
kept, want,
"mesh samples keep tiles no longer active or ready"
);
}
fn finer_of(&self, key: TileKey) -> Vec<TileKey> {
if !self.plan.split.contains(&key) {
return Vec::new();
@@ -2955,7 +3149,14 @@ impl Lod {
self.wanted_stale = false;
}
let mut issued = 0;
let mut jobs = self.shared.jobs.lock().unwrap();
// The far sampling threads take this lock too, and they run behind the render thread
// (`far_terrain_current`): one preempted while holding it kept a frame waiting for over
// 100 ms on a busy machine. Busy now, the requests go out next frame.
let mut jobs = match self.shared.jobs.try_lock() {
Ok(jobs) => jobs,
Err(std::sync::TryLockError::WouldBlock) => return,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
};
while issued < free {
let Some(std::cmp::Reverse(wanted)) = self.wanted.pop() else {
break;
@@ -2971,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;
}
@@ -3898,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();
@@ -4852,6 +5138,7 @@ mod tests {
lod.active.insert(key);
lod.ready
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
lod.sync_sample(key);
lod.mark_mesh(key);
lod.set_near_loading(true);
lod.dispatch_meshes([0, 0]);
@@ -0,0 +1,534 @@
//! Baked far terrain: the generator's far samples (`LodSampler::grid`), kept
//! in the dimension's folder (`horizon_lod/`) so the next session draws them
//! without sampling the noise again. The detail chunks' columns are kept by
//! `lod::cache`.
//!
//! Only generator output is baked: saved chunks still override it
//! (`save::SavedLodSampler`), so edits show as before. A fingerprint of the
//! game build, the world's data packs, the block and biome registries and the
//! level's shape guards the folder; when any of them changes it is emptied.
//! Reduced far detail's approximate samples are kept apart from full ones.
use super::{Column, Span, Surface};
use crate::{chunk::LevelShape, fast_hash::FxHashMap};
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
use horizon_world::registry::BlockStateId;
use sha2::{Digest, Sha256};
use std::{
fs,
io::{BufReader, Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
sync::Mutex,
};
const FORMAT: &str = "horizon-lod-bake 3";
const FOLDER: &str = "horizon_lod";
const FINGERPRINT: &str = "fingerprint.txt";
/// Tiles per bucket file side.
const GRID_BUCKET: i32 = 8;
/// The key written before each record: kind, x, z, stride, side.
const KEY_BYTES: usize = 15;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Key {
/// `LodSampler::grid(x, z, stride, side)`, sampled approximately or not.
Grid {
approximate: bool,
x: i32,
z: i32,
stride: i32,
side: u16,
},
}
impl Key {
fn file(self) -> String {
match self {
Key::Grid {
approximate,
x,
z,
stride,
side,
} => {
let span = stride * i32::from(side) * GRID_BUCKET;
format!(
"{}{stride}x{side}.{}.{}.bin",
if approximate { "a" } else { "g" },
x.div_euclid(span),
z.div_euclid(span)
)
}
}
}
fn bytes(self) -> [u8; KEY_BYTES] {
let (kind, x, z, stride, side) = match self {
Key::Grid {
approximate,
x,
z,
stride,
side,
} => (u8::from(approximate), x, z, stride, side),
};
let mut out = [0; KEY_BYTES];
out[0] = kind;
out[1..5].copy_from_slice(&x.to_le_bytes());
out[5..9].copy_from_slice(&z.to_le_bytes());
out[9..13].copy_from_slice(&stride.to_le_bytes());
out[13..15].copy_from_slice(&side.to_le_bytes());
out
}
fn from_bytes(b: &[u8; KEY_BYTES]) -> Option<Self> {
let int = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
match b[0] {
kind @ (0 | 1) => Some(Key::Grid {
approximate: kind == 1,
x: int(1),
z: int(5),
stride: int(9),
side: u16::from_le_bytes([b[13], b[14]]),
}),
_ => None,
}
}
}
/// One append-only file of records: `[u32 length][key][zlib columns]`.
struct Bucket {
file: fs::File,
/// Each record's compressed columns: offset and length.
index: FxHashMap<Key, (u64, u32)>,
end: u64,
}
impl Bucket {
fn open(path: &Path) -> std::io::Result<Self> {
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
let mut index = FxHashMap::default();
let mut end = 0u64;
let len = file.metadata()?.len();
{
let mut reader = BufReader::new(&mut file);
loop {
let mut length = [0; 4];
if reader.read_exact(&mut length).is_err() {
break;
}
let length = u32::from_le_bytes(length);
let mut key = [0; KEY_BYTES];
if (length as usize) < KEY_BYTES || reader.read_exact(&mut key).is_err() {
break;
}
let Some(key) = Key::from_bytes(&key) else {
break;
};
let body = length - KEY_BYTES as u32;
let start = end + 4 + KEY_BYTES as u64;
// A record cut short by a crash ends the file there.
if start + u64::from(body) > len || reader.seek_relative(i64::from(body)).is_err() {
break;
}
index.insert(key, (start, body));
end = start + u64::from(body);
}
}
if len != end {
file.set_len(end)?;
}
Ok(Self { file, index, end })
}
fn read(&mut self, key: Key) -> Option<Vec<u8>> {
let &(offset, length) = self.index.get(&key)?;
let mut body = vec![0; length as usize];
self.file.seek(SeekFrom::Start(offset)).ok()?;
self.file.read_exact(&mut body).ok()?;
Some(body)
}
fn append(&mut self, key: Key, body: &[u8]) -> std::io::Result<()> {
let length = (KEY_BYTES + body.len()) as u32;
let mut record = Vec::with_capacity(4 + length as usize);
record.extend_from_slice(&length.to_le_bytes());
record.extend_from_slice(&key.bytes());
record.extend_from_slice(body);
self.file.seek(SeekFrom::Start(self.end))?;
self.file.write_all(&record)?;
let start = self.end + 4 + KEY_BYTES as u64;
self.index.insert(key, (start, body.len() as u32));
self.end = start + body.len() as u64;
Ok(())
}
}
/// A dimension's baked far terrain, shared by the far sampling threads.
pub struct LodBake {
dir: PathBuf,
/// Open buckets by file name; `None` for one that failed to open.
buckets: Mutex<FxHashMap<String, Option<Bucket>>>,
}
impl LodBake {
/// Opens `dimension/horizon_lod`, emptying it when its fingerprint is not
/// this build's, world's, level's and registries'. `None` (no baking) on
/// an error.
pub fn open(
dimension: &Path,
shape: LevelShape,
block_states: usize,
biomes: usize,
) -> Option<Self> {
let dir = dimension.join(FOLDER);
let fingerprint = fingerprint(dimension, shape, block_states, biomes);
let current = fs::read_to_string(dir.join(FINGERPRINT)).ok();
if current.as_deref() != Some(fingerprint.as_str()) {
if current.is_some() {
log::info!("Horizon: baked far terrain is out of date; baking it again");
}
let reset = || -> std::io::Result<()> {
if dir.exists() {
fs::remove_dir_all(&dir)?;
}
fs::create_dir_all(&dir)?;
fs::write(dir.join(FINGERPRINT), &fingerprint)
};
if let Err(e) = reset() {
log::warn!(
"Horizon: far terrain will not be baked ({}): {e}",
dir.display()
);
return None;
}
}
Some(Self {
dir,
buckets: Mutex::new(FxHashMap::default()),
})
}
fn with_bucket<T>(&self, key: Key, f: impl FnOnce(&mut Bucket) -> Option<T>) -> Option<T> {
let name = key.file();
let mut buckets = self.buckets.lock().unwrap();
let bucket = buckets
.entry(name)
.or_insert_with_key(|name| match Bucket::open(&self.dir.join(name)) {
Ok(bucket) => Some(bucket),
Err(e) => {
log::warn!("Horizon: baked far terrain {name}: {e}");
None
}
})
.as_mut()?;
f(bucket)
}
fn get(&self, key: Key, count: usize) -> Option<Vec<Column>> {
let body = self.with_bucket(key, |bucket| bucket.read(key))?;
let mut raw = Vec::new();
ZlibDecoder::new(&body[..]).read_to_end(&mut raw).ok()?;
decode(&raw, count)
}
fn put(&self, key: Key, columns: &[Column]) {
if self.with_bucket(key, |bucket| Some(bucket.index.contains_key(&key))) != Some(false) {
return;
}
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
let _ = encoder.write_all(&encode(columns));
let Ok(body) = encoder.finish() else { return };
self.with_bucket(key, |bucket| {
if !bucket.index.contains_key(&key)
&& let Err(e) = bucket.append(key, &body)
{
log::warn!("Horizon: baking far terrain: {e}");
}
Some(())
});
}
/// The baked `LodSampler::grid` result, if any.
pub fn grid(
&self,
approximate: bool,
[x, z]: [i32; 2],
stride: i32,
side: usize,
) -> Option<Vec<Column>> {
let side16 = u16::try_from(side).ok()?;
self.get(
Key::Grid {
approximate,
x,
z,
stride,
side: side16,
},
side * side,
)
}
pub fn put_grid(
&self,
approximate: bool,
[x, z]: [i32; 2],
stride: i32,
side: usize,
columns: &[Column],
) {
// A failed direct sample comes back as empty columns: never keep it.
let Ok(side) = u16::try_from(side) else {
return;
};
if columns.iter().all(|c| *c == Column::default()) {
return;
}
self.put(
Key::Grid {
approximate,
x,
z,
stride,
side,
},
columns,
);
}
}
/// What baked columns depend on besides the world's seed (fixed per world).
fn fingerprint(dimension: &Path, shape: LevelShape, block_states: usize, biomes: usize) -> String {
let build = super::cache::build_key(block_states, biomes);
let packs = world_packs_digest(dimension);
format!(
"{FORMAT}\nbuild {build:016x}\nlevel {} {}\ndatapacks {packs}\n",
shape.min_y, shape.height,
)
}
/// `files_digest` of the data packs of the world `dimension` is a folder of.
pub fn world_packs_digest(dimension: &Path) -> String {
// `dimensions/<namespace>/<path>` inside the world folder.
dimension
.ancestors()
.nth(3)
.map(|world| files_digest(&world.join("datapacks")))
.unwrap_or_default()
}
/// Every file under `root`: its path, size and change time.
pub fn files_digest(root: &Path) -> String {
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
stack.push(path);
} else {
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_nanos());
let relative = path.strip_prefix(root).unwrap_or(&path);
files.push(format!("{} {} {modified}", relative.display(), meta.len()));
}
}
}
files.sort_unstable();
let mut hash = Sha256::new();
for file in &files {
hash.update(file.as_bytes());
hash.update(b"\n");
}
hash.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
fn encode(columns: &[Column]) -> Vec<u8> {
let mut out = Vec::with_capacity(columns.len() * 24);
out.extend_from_slice(&(columns.len() as u32).to_le_bytes());
for column in columns {
match column.surface {
None => out.push(0),
Some(s) => {
out.push(if s.water.is_some() { 2 } else { 1 });
out.extend_from_slice(&s.height.to_le_bytes());
out.extend_from_slice(&s.state.0.to_le_bytes());
out.extend_from_slice(&s.biome.to_le_bytes());
if let Some((height, state)) = s.water {
out.extend_from_slice(&height.to_le_bytes());
out.extend_from_slice(&state.0.to_le_bytes());
}
}
}
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
for span in &column.spans {
out.extend_from_slice(&span.min.to_le_bytes());
out.extend_from_slice(&span.max.to_le_bytes());
out.extend_from_slice(&span.state.0.to_le_bytes());
out.extend_from_slice(&span.biome.to_le_bytes());
out.extend_from_slice(&span.light.to_le_bytes());
}
}
out
}
fn decode(mut b: &[u8], count: usize) -> Option<Vec<Column>> {
fn take<const N: usize>(b: &mut &[u8]) -> Option<[u8; N]> {
let (head, rest) = b.split_first_chunk::<N>()?;
*b = rest;
Some(*head)
}
let i32_ = |b: &mut &[u8]| take::<4>(b).map(i32::from_le_bytes);
let u32_ = |b: &mut &[u8]| take::<4>(b).map(u32::from_le_bytes);
let u16_ = |b: &mut &[u8]| take::<2>(b).map(u16::from_le_bytes);
let i16_ = |b: &mut &[u8]| take::<2>(b).map(i16::from_le_bytes);
if u32_(&mut b)? as usize != count {
return None;
}
let mut columns = Vec::with_capacity(count);
for _ in 0..count {
let kind = take::<1>(&mut b)?[0];
let surface = match kind {
0 => None,
1 | 2 => {
let height = i32_(&mut b)?;
let state = BlockStateId(u32_(&mut b)?);
let biome = u16_(&mut b)?;
let water = if kind == 2 {
Some((i32_(&mut b)?, BlockStateId(u32_(&mut b)?)))
} else {
None
};
Some(Surface {
height,
state,
water,
biome,
})
}
_ => return None,
};
let spans = (0..u16_(&mut b)?)
.map(|_| {
Some(Span {
min: i16_(&mut b)?,
max: i16_(&mut b)?,
state: BlockStateId(u32_(&mut b)?),
biome: u16_(&mut b)?,
light: u16_(&mut b)?,
})
})
.collect::<Option<Vec<_>>>()?;
columns.push(Column {
surface,
spans: spans.into(),
});
}
b.is_empty().then_some(columns)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(seed: i32) -> Column {
Column {
surface: Some(Surface {
height: 70 + seed,
state: BlockStateId(9),
water: (seed % 2 == 0).then_some((80, BlockStateId(86))),
biome: 3,
}),
spans: vec![
Span {
min: -64,
max: (70 + seed) as i16,
state: BlockStateId(1),
biome: 3,
light: 0,
},
Span {
min: (70 + seed) as i16,
max: 80,
state: BlockStateId(86),
biome: 3,
light: 240,
},
]
.into(),
}
}
#[test]
fn columns_round_trip_and_survive_reopening() {
let world = std::env::temp_dir().join(format!("horizon-lod-bake-{}", std::process::id()));
let _ = fs::remove_dir_all(&world);
let dimension = world.join("dimensions").join("minecraft").join("overworld");
fs::create_dir_all(&dimension).unwrap();
let grid: Vec<Column> = (0..256).map(sample).collect();
let other: Vec<Column> = (1..257).map(sample).collect();
let shape = LevelShape::OVERWORLD;
{
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
bake.put_grid(false, [-64, 32], 4, 16, &grid);
// A second put of the same key is ignored, not appended.
bake.put_grid(false, [-64, 32], 4, 16, &[sample(-1)]);
assert_eq!(
bake.grid(false, [-64, 32], 4, 16).as_deref(),
Some(&grid[..])
);
// Reduced far detail's samples are kept apart from full ones.
assert_eq!(bake.grid(true, [-64, 32], 4, 16), None);
bake.put_grid(true, [-64, 32], 4, 16, &other);
assert_eq!(
bake.grid(true, [-64, 32], 4, 16).as_deref(),
Some(&other[..])
);
}
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
assert_eq!(
bake.grid(false, [-64, 32], 4, 16).as_deref(),
Some(&grid[..])
);
assert_eq!(bake.grid(false, [-64, 48], 4, 16), None);
drop(bake);
// A cut-off record (a crash mid-write) is dropped, the rest is kept:
// (-128, 32) shares (-64, 32)'s bucket file.
let key = Key::Grid {
approximate: false,
x: -64,
z: 32,
stride: 4,
side: 16,
};
let file = dimension.join(FOLDER).join(key.file());
let full = fs::metadata(&file).unwrap().len();
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
bake.put_grid(false, [-128, 32], 4, 16, &other);
drop(bake);
let f = fs::OpenOptions::new().write(true).open(&file).unwrap();
f.set_len(fs::metadata(&file).unwrap().len() - 5).unwrap();
drop(f);
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
assert_eq!(bake.grid(false, [-128, 32], 4, 16), None);
assert_eq!(
bake.grid(false, [-64, 32], 4, 16).as_deref(),
Some(&grid[..])
);
assert_eq!(fs::metadata(&file).unwrap().len(), full);
drop(bake);
// Another registry (or data pack, build or level shape) empties the folder.
let bake = LodBake::open(&dimension, shape, 101, 10).unwrap();
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
drop(bake);
fs::remove_dir_all(&world).unwrap();
}
#[test]
fn empty_samples_are_not_baked() {
let world = std::env::temp_dir().join(format!("horizon-lod-empty-{}", std::process::id()));
let _ = fs::remove_dir_all(&world);
let dimension = world.join("dimensions").join("minecraft").join("overworld");
fs::create_dir_all(&dimension).unwrap();
let bake = LodBake::open(&dimension, LevelShape::OVERWORLD, 1, 1).unwrap();
bake.put_grid(false, [0, 0], 16, 16, &vec![Column::default(); 256]);
assert_eq!(bake.grid(false, [0, 0], 16, 16), None);
drop(bake);
fs::remove_dir_all(&world).unwrap();
}
}
@@ -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);
}
}
@@ -0,0 +1,105 @@
//! The samples mesh jobs read (`Snapshot::samples`): the active tiles' ready columns, kept
//! as the tiles change rather than gathered again for every batch of jobs. Kept in shards of
//! nearby tiles behind `Arc`s: a batch's copy (`clone`) copies the shards' handles, and a
//! change copies only its shard, and only while a job still reads it.
use super::{Column, TileKey};
use crate::fast_hash::FxHashMap;
use std::sync::Arc;
type Shard = FxHashMap<TileKey, Arc<Vec<Column>>>;
/// 8 by 8 tiles of one level a shard.
fn shard_of(key: TileKey) -> (i32, i32, u8) {
(key.x >> 3, key.z >> 3, key.level)
}
#[derive(Clone, Default)]
pub(super) struct Samples {
shards: FxHashMap<(i32, i32, u8), Arc<Shard>>,
}
impl Samples {
pub fn get(&self, key: &TileKey) -> Option<&Arc<Vec<Column>>> {
self.shards.get(&shard_of(*key))?.get(key)
}
/// Sets `key`'s samples (`None`: not active or not ready), copying its shard only when
/// they change.
pub fn set(&mut self, key: TileKey, samples: Option<&Arc<Vec<Column>>>) {
let at = shard_of(key);
match samples {
Some(samples) => {
let shard = self.shards.entry(at).or_default();
if shard.get(&key).is_none_or(|s| !Arc::ptr_eq(s, samples)) {
Arc::make_mut(shard).insert(key, samples.clone());
}
}
None => {
let Some(shard) = self.shards.get_mut(&at) else {
return;
};
if shard.contains_key(&key) {
Arc::make_mut(shard).remove(&key);
if shard.is_empty() {
self.shards.remove(&at);
}
}
}
}
}
/// Keeps the tiles `keep` accepts.
pub fn retain(&mut self, mut keep: impl FnMut(&TileKey) -> bool) {
self.shards.retain(|_, shard| {
if !shard.keys().all(&mut keep) {
Arc::make_mut(shard).retain(|key, _| keep(key));
}
!shard.is_empty()
});
}
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (&TileKey, &Arc<Vec<Column>>)> {
self.shards.values().flat_map(|shard| shard.iter())
}
}
impl FromIterator<(TileKey, Arc<Vec<Column>>)> for Samples {
fn from_iter<I: IntoIterator<Item = (TileKey, Arc<Vec<Column>>)>>(iter: I) -> Self {
let mut samples = Self::default();
for (key, columns) in iter {
samples.set(key, Some(&columns));
}
samples
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(x: i32, z: i32, level: u8) -> TileKey {
TileKey { x, z, level }
}
#[test]
fn a_change_leaves_a_batchs_copy_as_it_was() {
let a = Arc::new(Vec::new());
let b = Arc::new(Vec::new());
let mut live: Samples = [(key(0, 0, 0), a.clone()), (key(40, -3, 1), a.clone())]
.into_iter()
.collect();
let batch = live.clone();
live.set(key(0, 0, 0), Some(&b));
live.set(key(40, -3, 1), None);
live.set(key(-9, 2, 0), Some(&b));
assert!(Arc::ptr_eq(batch.get(&key(0, 0, 0)).unwrap(), &a));
assert!(batch.get(&key(40, -3, 1)).is_some());
assert!(batch.get(&key(-9, 2, 0)).is_none());
assert!(Arc::ptr_eq(live.get(&key(0, 0, 0)).unwrap(), &b));
assert!(live.get(&key(40, -3, 1)).is_none());
live.retain(|k| k.x < 0);
assert_eq!(
live.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
[key(-9, 2, 0)]
);
assert_eq!(batch.iter().count(), 2);
}
}
Loaded 100 of 1074 files, more files were not shown because too many files have changed in this diff. Show more