Compare commits

..
Author SHA1 Message Date
Ludovic Marie 86a9d9e669 Merge pull request #10236 from horizon-rust/fix/launcher-screenshot-memory
🤖 Agent: merging. The screenshot tiles, viewer and background no longer go through GPUI's never-evicted img(path) cache. Two 32k×16k screenshots on Home went from an 8.7 GiB peak to 277 MiB on the desktop. One risk to watch: the 2 s grace lets the budget overflow while more than 256 MiB of tiles is on screen at once (about 280 tiles with Show all), which is bounded by the list itself.
2026-11-05 21:37:05 +01:00
Horizon agentandClaude Opus 5.5 23e710f75d launcher: draw screenshots at their shown size, under a byte budget
Screenshot tiles (Overview's three newest, the Screenshots tab), the
viewer and Home's background went through gpui::img(path): GPUI decodes
the whole picture and keeps it, and its sprite-atlas copy, until the
launcher quits. One 32768x16384 screenshot is 2 GiB decoded; with two
of them Home alone reached 8.7 GiB (24 GiB reported on Linux).

- pictures (lib): PNGs are read a row at a time and box-filtered down to
  the drawn size, never held whole; other formats decode whole only
  under 256 MiB. Lru keeps made pictures under a byte budget, sparing
  those drawn in the last 2 s.
- picture_cache: makes them off the UI thread (3 at once), 640 px for
  tiles, 3840 px for the viewer and background, 256 MiB budget; evicted
  images are dropped from the window's sprite atlas.
- glass_shot_blur's blurred copies go through it too, from a shrunk read
  instead of image::open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:35:43 +01:00
Ludovic Marie 3d33b0299d Merge pull request #10223 from horizon-rust/feat/shop-tab-button-label
🤖 Agent: the Wardrobe button opens the launcher's Shop tab now, so its label drops "on playhorizon.dev"; the key is renamed in place in all 86 tables (no stale key left), and the i18n test passes.
2026-11-05 21:20:36 +01:00
Horizon agentandClaude Opus 5.5 2d0c715866 Launcher Wardrobe: the Shop button reads "Get more cosmetics"
It opens the launcher's Shop tab now, not the website, so the old label
"Get more cosmetics on playhorizon.dev" was wrong. The key is renamed in all 86
tables, in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 21:08:34 +01:00
Ludovic Marie f00c4c6392 Merge pull request #10139 from horizon-rust/feat/shop-tab-3
🤖 Agent: Pay checks that the checkout's uuid matches the recipient and only opens playhorizon.dev/shop/pay with a validated txn_ id; no card entry in the launcher.
2026-11-05 21:07:51 +01:00
Ludovic Marie 6e3ecedc2a Merge pull request #10138 from horizon-rust/feat/shop-tab-2
🤖 Agent: the Shop is a plain top-bar tab with no badge; it never opens by itself and isn't the start page; a catalogue failure shows one quiet line and Play is untouched; try-on uses #10153's Viewer (no second renderer).
2026-11-05 21:05:28 +01:00
Ludovic Marie c58f498288 Merge pull request #10137 from horizon-rust/feat/shop-tab-1
🤖 Agent: shop price rules (sets partly owned, items inside a set in the basket, Supporter once) match the site's pricing.ts and are tested against hand-worked values; nothing player-visible yet. Requeued after merging main (#10153's lang tails).
2026-11-05 20:39:00 +01:00
Ludovic Marie 1fdf875085 Merge pull request #9918 from oxi-does-dev/fix/interact-hit-location
🤖 Agent: Requeued on the same head: the parity run died in 1 s on the runner's missing .horizon-prepare.lock (environment, no harness ran), so it's rerunning (42040505129). The interact packet now carries the ray's hit point from the mob's feet. Union on main dd675b574 green.
2026-11-05 20:31:48 +01:00
Ludovic Marie bd340bf472 Merge pull request #9927 from CocofireHD/fix/9291-resolution-scale-shaders
🤖 Agent: With a scaled resolution, final now draws at the picture's size into its own target, and one linear copy stretches it to the window. Same-size output and packs without a final keep their old paths, so only resolution-scaled pack users take the extra pass. Union on main dd675b574 green (fmt, clippy, 4426 client tests including the render crate).
2026-11-05 20:31:43 +01:00
Ludovic Marie 6e98dae760 Merge pull request #9920 from DUzzL/fix/9899-boat-passenger
🤖 Agent: Matches LivingEntity.calculateEntityAnimation: a passenger calls walkAnimation.stop(), which zeroes speed, old speed and position, so the seat moving with the boat no longer swings the legs. Union on main dd675b574 green (fmt, clippy, 4426 client tests).
2026-11-05 20:31:39 +01:00
Ludovic Marie bff2562ec0 Merge pull request #9916 from CocofireHD/fix/9448-gl-depth-textureload
🤖 Agent: WebGPU allows binding a depth view as unfilterable-float, and textureLoad(...).r is the same texel, so Vulkan, DX12 and Metal read the same depth. The translation test now fails on any GL-run entry point that doesn't translate, except the one known glint case. Union on main dd675b574 green (fmt, clippy, 4426 client tests).
2026-11-05 20:31:35 +01:00
Ludovic Marie e3ad1ce3ee Merge pull request #9922 from MSedgeMC/test/animate-tick-neighbours
🤖 Agent: Merged main (9906 landed with the INF-seeded outline_center fold, kept from main) and ran marker_particles on this head: PASS, 2,187 cases and 462,718 lines matching the pinned client. The hive's loop now draws nextInt(1) again on the check that ends it, as BeehiveBlock.animateTick's for-condition does.
2026-11-05 20:27:42 +01:00
Ludovic Marie 5b4823c198 Merge pull request #10093 from horizon-rust/emotes/3-network
🤖 Agent: emotes, part 3 (#10069): others see your emote through the cosmetics service, now based on main. CI verify is green. Polling is bounded: at most once a second, only while Horizon players are in view, and only with Other players' cosmetics on Shown. Sending is signed and only while Show me as Horizon is on. Without horizon-cosmetics#14 deployed, the service answers 404 and the game backs off.
2026-11-05 20:07:45 +01:00
Ludovic Marie c6ad84b481 Merge pull request #10092 from horizon-rust/emotes/2-play
🤖 Agent: emotes, part 2 (#10069): the wheel and playback, retargeted to main after #10091. CI verify is green. Two clients played every emote in game; captures are in the PR. Visual only, nothing sent to game servers. #10093 follows.
2026-11-05 20:05:22 +01:00
Ludovic Marie 0508903777 Merge pull request #10091 from horizon-rust/emotes/1-format
🤖 Agent: emote format, the bottom of the emotes stack (#10069). pr_ready on the whole stack is green except the NixOS-only uname test. pose_body copies the target at full weight: 0.26–1.1 µs per emoting player and frame, measured. #10092 follows once this lands.
2026-11-05 20:01:16 +01:00
Horizon agentandClaude Opus 5.5 e7c5cb7331 Merge main (via feat/shop-tab-2) into feat/shop-tab-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:54:27 +01:00
Horizon agentandClaude Opus 5.5 5583a6bd8b Merge main into feat/shop-tab-2; the Shop tries items on in the Wardrobe's 3D view
#10153 replaced the Wardrobe's still picture with viewer::Viewer. The Shop now
uses its own Viewer (drag to turn, scroll to zoom, Reset view) instead of the
still picture and its Turn button, so there is one renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:54:15 +01:00
Horizon agentandClaude Opus 5.5 e15d46d371 Merge main into feat/shop-tab-1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:52:52 +01:00
Ludovic Marie f2f95dad8a Merge pull request #10153 from horizon-rust/agent/wardrobe-3d
🤖 Agent: verified on the desktop at this tree: horizon-cosmetics fmt/clippy/tests (40) and the launcher's fmt/clippy/tests (695) green; pr_ready skipped because the desktop disk was under 45 GB. Point to watch: preview::render now takes a Camera (launcher is its only caller), and lib.rs will conflict by one line with the emote format PR #10091.
2026-11-05 19:38:53 +01:00
Ludovic Marie 56349b9713 Merge pull request #9926 from masa-masa-0314/feat/realms-settings-tab
🤖 Agent: Fixed to the 26.3 jar's DTOs: the region preference travels as its id (0/1/2) and the region as its nameId, so a Realm that has a preference parses again (it used to fail the whole RealmServer parse). preferredRegions reads regionDataList/regionName, and the configuration POST puts the slot under options with update_slot's RealmsSlotUpdateDto. New tests pin the POST body and the parsing; CI verify is green on this head.
2026-11-05 19:34:24 +01:00
Horizon agentandClaude Opus 5.5 4f0af1f5f2 Merge main (via feat/shop-tab-2) into feat/shop-tab-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:24 +01:00
Horizon agentandClaude Opus 5.5 75adf421e0 Merge main (via feat/shop-tab-1) into feat/shop-tab-2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:22 +01:00
Horizon agentandClaude Opus 5.5 ec78951af1 Merge main into feat/shop-tab-1 (lang tables re-appended)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:29:20 +01:00
Horizon agentandClaude Opus 5.5 85056bcaaf Realms settings: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:20:46 +01:00
Ludovic Marie ff4b4b17bd Merge pull request #9944 from MSedgeMC/fix/steering-stick-wear
🤖 Agent: wear() still fires the durability trigger after Unbreaking, and the broken stat now comes before the rod. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:09:06 +01:00
Ludovic Marie 120fe4d7e3 Merge pull request #9941 from MSedgeMC/fix/bundle-tooltip-display
🤖 Agent: The height probe and the draw share bundle_tooltip, so a hidden grid can't leave an empty box taller than its lines. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:09:02 +01:00
Ludovic Marie bebaecd65a Merge pull request #9939 from degepie/fix/name-tag-interact-trigger
🤖 Agent: The tag is copied before name_mob uses it up, like handleInteract's itemStackBefore. Follow-up gap, not this PR: vanilla also fires the trigger with an empty stack on non-item successes. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:58 +01:00
Ludovic Marie 674de01447 Merge pull request #9938 from degepie/fix/drink-consume-trigger
🤖 Agent: Same two calls finish_consuming makes, before the stack shrinks, as Consumable.onConsume orders them. drink_ominous_bottle awarded neither, so nothing counts twice. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:52 +01:00
Ludovic Marie d1dc1cb937 Merge pull request #9937 from degepie/fix/structure-tag-predicates
🤖 Agent: The pack-less structure tags are a OnceLock keyed on the first resource root, as StructureSpawns::shared is. location_structure_tags_hold_their_structures passes with prepared resources. The parity failure at ~17:00 was the playhorizon runner's dead .horizon-prepare.lock link, not this change: rerun on this head.
2026-11-05 19:08:48 +01:00
Ludovic Marie b0061bc2d3 Merge pull request #9946 from martipops/controller/pad-reach
🤖 Agent: On playhorizon (vscreen, lavapipe) every_screen_has_targets, pause_icon, erase_checkbox, controller_settings_pad and settings pass. The empty-label target can add a second target on a rect that already called target_as: drawn_targets dedups only against labelled widgets, not between targets. Nothing on main seems to do that today. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 19:08:44 +01:00
Horizon agentandClaude Opus 5.5 b162cbaa46 Launcher: the Wardrobe's player in 3D, turned by drag, zoomed by the wheel
The Wardrobe's still picture becomes a live view of the player's own skin
(both layers, slim or wide arms) wearing their items, drawn by the same CPU
renderer (horizon_cosmetics::preview) only when something changed:

- drag to turn (followed past the view's edges while held), scroll to zoom
  up to 3x on the head, Reset view; hovering an owned item's card tries it
  on (Nothing takes the slot's off) until the pointer leaves;
- items animate (swing, bob, spin) on the game's 20-tick clock with
  Item::animate, as the game does, only while the window is in front and
  not under reduced motion (HORIZON_LAUNCHER_MOTION=always keeps them going);
- horizon_cosmetics::viewer::Stage decides each frame: nothing changed, no
  picture and no timer; at most 20 pictures a second while animating (one a
  tick), 60 while dragging. Pictures are drawn at the view's size in points
  (240x330, about 2 ms) and baked up to device pixels;
- preview::Camera holds the turn, tilt and zoom (with headroom above the
  head for tall hats and back items), Draw::item_posed keeps items on posed
  bones, and viewer::BodyMotion is the hook an emote plays through
  (Viewer::play), for the emote format in flight (#10091).

The view is a GPUI view of its own, so a new picture rebuilds it alone;
the pointer is handled by the page's element around it, since GPUI Fast
hit-tests the page's own elements. "Turn" gives way to "Reset view" and a
hint, in every language.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:08:41 +01:00
Ludovic Marie 0ad6666970 Merge pull request #9942 from TheMrZZ/fix/enderman-neighbour-placement
🤖 Agent: Deferring the attempt to the event phase lets update_from_neighbour_shapes see the real level and ticks, as EndermanLeaveBlockGoal.tick does. leave_block_goal_uses_real_neighbor_shapes_and_keeps_air_placement passes with prepared resources. Still missing: canSurvive and the BLOCK_PLACE game event. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 19:08:39 +01:00
Horizon agent 1011abe707 Merge branch 'emotes/2-play' into emotes/3-network 2026-11-05 19:04:32 +01:00
Horizon agentandClaude Opus 5.5 6f88fac9a9 Emotes: the wheel's key release and click in emote_wheel.rs (keeps game.rs's release block whole)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 19:04:32 +01:00
Ludovic Marie baa9153c82 Merge pull request #9943 from CocofireHD/fix/9439-tank-refuse-fluid
🤖 Agent: native_fluid_at now allocates the block id String on every call, drains and tank queries included. That's fine for mod helpers, but worth an &'static str if a tick path ever calls it. The fragment stamps (tests.test_stamp_native_hooks passed in the union run). I ran the native_fluids tests on this head on playhorizon, 12 passed, the new dispatch test included.
2026-11-05 19:03:54 +01:00
Ludovic Marie 936594e71b Merge pull request #10078 from horizon-rust/pvp/hud-layout
🤖 Agent: merging. The layout lines only add keys to horizon-extras.txt; files without them load as before (every widget at its default place and style). Verify is green; stacked on #10077.
2026-11-05 18:58:43 +01:00
Ludovic Marie 1e5bf4c58f Merge pull request #10077 from horizon-rust/pvp/mod-hud
🤖 Agent: merging. Every widget reads local state only (no packets, no server queries); with every widget off, draw_pvp_hud returns after Hud::any(), so a player who never turns one on pays nothing. Verify is green; pr_ready --quick on playhorizon fails only the render crash:: tests that SIGSEGV under llvmpipe there, and crash.rs is untouched.
2026-11-05 18:58:39 +01:00
Ludovic Marie 35fe345f50 Merge pull request #9923 from CocofireHD/fix/9500-shulker-close-open
🤖 Agent: open_menu already counts the guest as an opener (container_opener_event), so the raise in lan_open_container counted them twice and stopOpen never reached 0, for chests as well as shulker boxes. The author's lan_e2e shulker_menu check pins the lid open, then shut, on both host and guest. Union on main dd675b574 green.
2026-11-05 18:58:35 +01:00
Ludovic Marie a553d191a8 Merge pull request #9917 from CocofireHD/fix/8615-vibration-particles
🤖 Agent: The server now sends one minecraft:vibration LevelParticles (count 1, no spread) with the listener's PositionSource and arrival ticks, and only draws locally when the window is the client, so a server-thread world doesn't draw it twice. Tested at a729b3cfe in a union on main dd675b574 (fmt, clippy, 4426 client tests green); this head only adds the author's main merge (test helper conflict) on top.
2026-11-05 18:58:26 +01:00
Ludovic Marie 383b898652 Merge pull request #9913 from Mayonesu0/fix/native-build-smart-app-control-5911
🤖 Agent: The launcher code is identical to what I tested in a union on main dd675b574 (launcher fmt, clippy -D warnings, 699 tests green). With no check before the build, a machine whose build programs ran before Smart App Control was turned on still builds (Mayonesu0's case 1), and only an os error 4551 leads the error with the explanation.
2026-11-05 18:58:22 +01:00
Ludovic Marie a6950f2d63 Merge pull request #9819 from TheMrZZ/fix/h320-transmute-result-patch
🤖 Agent: Merged main into this branch (the conflict was in command_exec_corpus.py: the transmute group now sits beside main's mob-effect and frame-map groups, and its recipe pack stays in ISOLATED_RECIPES). The result patch is now applied after the input patch, so a '!minecraft:damage' removal wins over inherited damage, as ItemStackTemplate.apply does. Union on main dd675b574: fmt, clippy, 4426 client tests green; CI parity runs command_exec.
2026-11-05 18:58:17 +01:00
Ludovic Marie d545e9df60 Merge pull request #10068 from horizon-rust/fix/launcher-main-red
🤖 Agent: tests now check all four hosts' cargo commands on every runner (main's launcher red since #9479); launcher dry run 42072143059 green on windows-arm64 and macos-x64.
2026-11-05 18:48:10 +01:00
Ludovic Marie 169b0f55c9 Merge pull request #9934 from hildlabs/github-stars
🤖 Agent: The stars come from the registry index, so the launcher makes no new GitHub API call, and both strings go through button()'s tr() and trf(). Until horizon-mods#285 lands, every page shows "0 stars on GitHub". I ran a union on playhorizon at 8b149e665; this head only merges main and the PR diff is unchanged.
2026-11-05 18:48:06 +01:00
Ludovic Marie 23907d0741 Merge pull request #9936 from degepie/fix/query-command-feedback
🤖 Agent: Matches CommandSourceStack.sendSuccess(…, false) for every query. I ran the ignored a_silent_source_drops_query_results_but_still_stores_them with prepared resources and it passes. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 18:48:02 +01:00
Ludovic Marie b2a67d7572 Merge pull request #9930 from NikitaMGrimm/shader-api-02
🤖 Agent: I pushed d4e13b4c0, which drops the "🤖 Agent:" markers from code comments and from the 0.2 prelude's COLOR_HELPERS text, since that text is composed into every 0.2 stage. Only 0.1's prelude digests are pinned, so they don't move. Added frame-identity now that #9900 is in. Union run on playhorizon (main a1e0c0db0 + this gate's 11 PRs): fmt, clippy, all touched crates' tests pass.
2026-11-05 18:47:58 +01:00
CocofireHDandopencode 1c712e434a Mod fluid tanks: prove a hook refusal through the dispatch
A focused test drives the public block_entity_accepts_fluid dispatch, as the game does: a water tank that refuses a mod's fluid leaves both insert passes empty and offers native_move_fluids no room, while water and a tank with no hook still fill.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 18:34:29 +01:00
Horizon agentandClaude Opus 5.5 ce9d8ae6cf Emotes: the measured cost per frame in docs/cosmetics.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 4a59bae751 Emotes: the wheel reads the cursor as it is (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 95244a792a Emotes: others see yours (the cosmetics service), the wheel lists the account's
- Mine: while an emote plays, the game tells the cosmetics service, signed in as for "Show me
  as Horizon" and only while that is on: `POST /v1/emote`, with a session kept an hour
  (`account::Kept`). A loop is said again every 30 s.
- Theirs: `horizon_cosmetics::Feed`, a thread, asks `POST /v1/emotes` which Horizon players in
  view play one, at most once a second and only while some are in view, then fetches the item
  files once. On failure it waits 15 s, doubling up to 10 min. Their models play the emote from
  where it is; a player who moves ends theirs at once.
- Follows "Other players' cosmetics": with anything but Shown, nothing is asked and nothing is
  drawn.
- The wheel: the account's emotes (`GET /v1/me`'s `emotes`), read when it opens, at most every
  10 minutes.

Nothing reaches game servers; vanilla clients see nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:23:53 +01:00
Horizon agentandClaude Opus 5.5 ff8997168c Emotes: horizon-ctl emote in the generated command reference
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:59 +01:00
Horizon agentandClaude Opus 5.5 2fed27fce9 Emotes: play one from the emote wheel, on your model and in the camera
- The wheel: Emote Wheel (`key.emote`, B by default, unbound on a controller; rebindable in
  Controls) shows up to 8 emotes around the middle of the screen. To play one: hold the key and
  release on an emote, click one, or press its number.
- The emote cancels (and fades out) on movement, an attack, mining, using an item, being hurt,
  riding, sleeping, gliding or sneaking. Only a standing player starts one.
- From first person, the camera turns to the front view and eases out from close for the emote,
  then comes back.
- Bones: `anim::setup` lays the emote over vanilla's animation, keyed bones only.
- Props: drawn with the cosmetics, growing in and out with the blend.
- `horizon-ctl emote [ID|stop]`.
- HORIZON_COSMETICS_LOCAL: the wheel offers the folder's emotes, and mannequins play its first
  one (the art and the bench).

Visual only: nothing reaches the game server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:59 +01:00
Horizon agentandClaude Opus 5.5 44c14af45a Emotes: fully in, the bones take the target as it is (no quaternion round trip)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:58 +01:00
Horizon agentandClaude Opus 5.5 977fcfdf79 Launcher tests: check every host's build commands, on any machine
#9479 made the native-mod and toolchain tests build their Toolchain from
`rust_toolchain::host().unwrap()`. On the macOS x64 runner there is no
managed host (None: 7 panics); on the Windows ARM64 runner it is the ARM64
host, whose cargo gets its target's rustflags as `--config` before the
subcommand, which the tests' argument layout didn't expect.

The code is right on both: the rustflags are scoped to
target.aarch64-pc-windows-gnullvm and only the ARM64 host adds them. The
tests now run each check for every host the launcher ships a toolchain
for, expect the host's own arguments first, and pin that only the ARM64
host adds any.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:08:11 +01:00
hildlabsandClaude Opus 5.5 c140315d79 Merge origin/main into github-stars
Brings the branch up to date; the parity job's last run failed in the
runner's preparer (.local/.horizon-prepare.lock missing), as it did for
every PR between 14:17 and 14:41 UTC.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:05:21 +01:00
Horizon agentandClaude Opus 5.5 071feb3308 Cosmetics: the emote format (keyframed bones, root, props)
An item of the new `emote` slot carries an `emote` block: per-part tracks of keys (rotation,
offset, a prop's scale) eased smooth (Catmull-Rom), linear, in_out or step, one-shot or looping,
faded in and out over `blend` ticks. `Item::parse` checks its limits (200 ticks, 16 tracks,
32 keys a track and 192 in all, 360° turns and 8 px moves, the root 720° and 16 px, props'
reach through every key). `Emote::pose_body` poses the six bones (keys replace a bone's
rotation, keyed bones only), `pose_props` the props, and `Playing` gives an emote's time and
weight, its cancel included. Visual only, nothing random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:04:09 +01:00
Horizon agentandClaude Opus 5.5 1fcc43f60f Realms settings: the 26.3 JSON shapes for the region preference and the configuration
RegionSelectionPreference travels as its id (0, 1, 2) and the region as its nameId, so a
Realm with a preference no longer fails to parse as a String. PreferredRegionsDto reads
regionDataList / regionName / serviceQuality, RealmsConfigurationDto posts the slot under
options with update_slot's RealmsSlotUpdateDto (worldTemplateId, worldTemplateImage), a Realm
without a preference is automatic by owner (RegionSelectionPreferenceDto.DEFAULT), and the
labels use the realms.configuration.region* keys vanilla's settings tab shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:58:19 +01:00
Horizon agentandClaude Opus 5.5 ad73e4a692 Launcher Shop: a basket, gifts, the Supporter badge, and Pay in the browser
Add to basket on the chosen card, the basket's lines and total with the
service's rules (owned items and items inside a set in the basket cost
nothing), a gift to a friend looked up by name, the Supporter badge's
amount within the catalogue's range, and "Ask whoever pays before you
buy. Refunds within 14 days for any reason." by Pay, which starts
Paddle's checkout on the service and opens playhorizon.dev/shop/pay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:52:57 +01:00
Horizon agentandClaude Opus 5.5 fac063268c Launcher: a plain Shop tab beside the Wardrobe
The catalogue's items, sets, collections and the Supporter badge as cards
with their prices, the chosen card tried on the account's own skin, and
the founder set's claim (one click where Paddle doesn't sell, else
Paddle's free checkout in the browser). No badge on the tab, it never
opens by itself, and a catalogue that doesn't load says so in one line.
The Wardrobe's one shop button now opens this tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:51:19 +01:00
Horizon agent 621d47ab6d Merge commit 'dd675b574115536446647051e29dac76f1055bc5' into cg51/pr-9922
# Conflicts:
#	crates/horizon-client/examples/probe/marker_particles_probe.rs
#	crates/horizon-client/src/animate_tick.rs
#	tools/test_marker_particles_parity.py
2026-11-05 17:50:19 +01:00
Ludovic Marie fb6121bce9 Merge pull request #9980 from horizon-rust/settings-unify/labels-fit
🤖 Agent: Extras and PvP & HUD measure each label in the screen's font, with its first values; one wider than a half-width button takes the whole row, so 'Keep channels for UI sounds' and 'Scroll wheel changes settings' no longer slide, in any language. verify green at this head; no caption, key or default changes.
2026-11-05 17:49:54 +01:00
Claude a94f171297 Merge main into fix/native-build-smart-app-control-5911
Resolves the launcher/lang/*.json conflicts: both sides added strings at
the end of each table; main's tables plus this branch's one string.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y255SLkXwxkdNVZnS8dNbf
2026-11-05 16:49:33 +00:00
Horizon agent f4382aac3f Merge commit 'dd675b574115536446647051e29dac76f1055bc5' into cg51/pr-9819
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-05 17:49:03 +01:00
Horizon agentandClaude Opus 5.5 d4e13b4c08 Drop agent markers from code comments and the 0.2 prelude
PR-comment markers do not belong in source, and the 0.2 prelude text is
part of every composed 0.2 stage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:47:38 +00:00
CocofireHD 49e8906c56 Merge remote-tracking branch 'origin/main' into fix/8615-vibration-particles 2026-11-05 17:47:05 +01:00
Ludovic Marie dd675b5741 Merge pull request #10030 from horizon-rust/fix/launcher-uninstall-move
🤖 Agent: one-word fix (closure gets move) for the Windows build error E0373 that #10003 left on main; verified with cargo check --target x86_64-pc-windows-gnu --bins --tests (mingw) on the desktop at this exact head.
2026-11-05 17:46:55 +01:00
degepieandClaude Opus 5.5 377fcceae7 Merge origin/main into fix/query-command-feedback
Both main (selector_bounds_tests) and this branch append a test module at
the end of commands.rs; both are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 21:40:22 +05:00
Horizon agentandClaude Opus 5.5 07c08509df Launcher Shop: prices, sets and the Supporter badge from the catalogue; the web checkout
The catalogue's shop fields (currencies, prices, sets, collections, the
Supporter badge), the player lookup and the web checkout in the wardrobe
client, and the basket's price rules in shop.rs, as the website applies
them. Nothing shows them yet: the Shop tab comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:40:05 +01:00
Ludovic Marie a1e0c0db0b Merge pull request #10027 from horizon-rust/browser-registry-filters
🤖 Agent: registry tags come from known_index(), so tags fetched before the first search finishes stay empty for that kind until the launcher restarts; searches always load the index first, so in practice the row is filled.
2026-11-05 17:38:06 +01:00
Ludovic Marie 8d9f806a18 Merge pull request #10026 from horizon-rust/browser-official-source
🤖 Agent: "from Horizon" now follows browser::horizon::official (authors ["Horizon"] + horizon-rust source, the site's isOfficial), read from the already-loaded index; the instance mod list's install-source label is left as is.
2026-11-05 17:38:00 +01:00
Horizon agentandClaude Opus 5.5 4ee8db2a44 Launcher: the window closure owns the setup mode (Windows build)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:37:26 +01:00
Ludovic Marie 9d364ca713 Merge pull request #10021 from horizon-rust/agent/launcher-wardrobe-site-link
🤖 Agent: launcher-only (plus docs), green launcher rustfmt/clippy/669 tests on the desktop at this head, i18n coverage test included. The one thing it keeps on purpose: Claim for free where Paddle doesn't sell, since the site sends those players to the Wardrobe.
2026-11-05 17:35:28 +01:00
Ludovic Marie f52d73d116 Merge pull request #10020 from horizon-rust/agent/cosmetic-name-tag-lift
🤖 Agent: verified on the desktop (pr_ready --quick at this head: clippy, 1026 tests incl. the new lift test) and on the ThinkPad's GPU (before/after shots). Point to watch: the lift is only ever added when an item is drawn on the body, so tags without tall cosmetics keep vanilla's exact 2.3 (tested); the extra per-tag cost is one max over ≤5 floats inside the existing cosmetics lookup.
2026-11-05 17:35:24 +01:00
CocofireHDandopencode 40eeef6f80 lan_e2e: state the shulker lid check in the harness docs
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:26:35 +01:00
Ludovic Marie 2a013743f2 Merge pull request #10017 from horizon-rust/fix/launcher-uninstall-i18n
🤖 Agent: fixes the red launcher i18n tests that #10003 left on main (reuses already translated strings); fmt, clippy and the 579 launcher lib tests were green on the desktop for this change.
2026-11-05 17:25:52 +01:00
Horizon agentandClaude Opus 5.5 f9f15fabff Launcher: uninstall reuses existing translated strings
Every language file must hold every string the code asks for; the new wording needs
translations from the language owners, so this keeps the existing text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:23:13 +01:00
Horizon agentandClaude Opus 5.5 cb0629d42d Mod browser: "from Horizon" only on Horizon's own projects
A registry result, its page and its Installed row said "from Horizon" for
every project in the registry, so a contributor's mod read as if Horizon
made it. Now only Horizon's own projects say it (author Horizon, source
under github.com/horizon-rust: the site's "Official", the rule
browser::horizon::official already used for the trust line); a
contributor's reads "by <author>" alone. Modrinth and CurseForge keep
"from <source>".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:22:29 +01:00
CocofireHD a729b3cfe6 Merge remote-tracking branch 'origin/main' into fix/8615-vibration-particles 2026-11-05 17:18:40 +01:00
Ludovic Marie 2c6ccefcb7 Merge pull request #9859 from TiggerTakkyTTT/fix/ender-dragon-spawn-egg
🤖 Agent: Checked against SpawnEggItem.useOn/spawnMob, EntityType.spawn/create and SpawnEggItemBehavior. I pushed d7e7b9273: the spawn yaw is wrapped, Mob.finalizeSpawn's triangle and nextFloat come from the level random, and the dragon seeds its own random. The author's dispenser height (16x8 box) and growl are kept. Known gap: Horizon keeps one dragon per level, so a second egg does nothing.
2026-11-05 17:15:57 +01:00
Ludovic Marie 5ce7036f71 Merge pull request #9854 from ilguerro/fix/placement-vibrations
🤖 Agent: Checked against HangingEntityItem.useOn, ArmorStandItem.useOn, EndCrystalItem.useOn, MinecartItem.useOn and EnderChestBlockEntity.startOpen. The armor stand now falls up to two blocks (getYOffset with movedUp). I pushed ada2c6b81: drop_offset keeps its three arguments and drop_offset_from takes movedUp, so #9859's new caller compiles whichever lands first. Main is merged in, and use_item_on keeps the wake, used-stat and placement cases.
2026-11-05 17:15:51 +01:00
Horizon agentandClaude Opus 5.5 8a2e225ad3 Mod browser: filter Horizon's registry by tags, picked filters in view
On Horizon's registry, Filters already listed the kind's categories. Now:

- A Tags row lists the tags the kind's projects carry (the 30 most used,
  from the index the search read): a result has every tag picked, as for
  categories, and the search words narrow them further.
- Categories read as playhorizon.dev names them ("Game mechanics", "World
  generation", "GUI"), in the registry's order, translated where the
  launcher's tables have the words.
- While the Filters panel is closed, every picked category, tag, loader,
  version or side stays in view as a chip with a cross that removes it,
  then Clear filters (which keeps the sort).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:12:30 +01:00
Horizon agentandClaude Opus 5.5 7b9daf7bbf PvP & HUD: document the widgets' style and layout
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:22 +01:00
Horizon agentandClaude Opus 5.5 afc6c3c43a PvP & HUD: free widget places, sizes, text colour, opacity and shadow
Each HUD widget can leave its corner for a place kept as fractions of the
screen's free room (so it stays on screen at any size) and a size from 50
to 200%, saved as hudPlace lines in horizon-extras.txt only once moved.
The page gains the boxes' opacity, the text colour and the text shadow.
Snapping (screen edges, middle, other boxes) is here for the editor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:22 +01:00
Horizon agentandClaude Opus 5.5 69874aef48 PvP & HUD: no widget on, no HUD work; document the new widgets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:17 +01:00
Horizon agentandClaude Opus 5.5 9f46032653 PvP & HUD: FPS, CPS, keystrokes, clock, reach and speed widgets
Six more widgets on the PvP & HUD page, off by default, each from the
client's own state: the frame counter, presses of the last second, the
movement and mouse keys while held, the local time, the distance of the
last hit the crosshair's ray measured, and horizontal speed sampled from
the player's position. Each caches its lines and rebuilds them only when
the shown value changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:10:17 +01:00
CocofireHDandopencode 463c970b13 Merge origin/main into fix/8615-vibration-particles
Resolved the one conflict, in game/lan_particles.rs: test_hub keeps the
shared helper the vibration test needs, and its guests now carry main's
Joined.expected field and bind through NetConfig::loopback. The
sculk_vibration LAN check survives main's rewrite of tools/lan_e2e.py.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:08:57 +01:00
Ludovic Marie d95fbb3240 Merge pull request #10003 from horizon-rust/fix/launcher-uninstall-window
🤖 Agent: launcher-only fix (Control Panel Uninstall opened nothing: the CLI parser refused 'uninstall'); fmt, clippy and tests green on the desktop at this exact head; the Windows build runs in CI.
2026-11-05 17:06:34 +01:00
Ludovic Marie db1da68bcd Merge pull request #9979 from horizon-rust/settings-unify/one-place
🤖 Agent: Video Settings... opens the graphics screen (as Sodium's), which gains vanilla's Video Settings rows it lacked and the picture extras; FOV and View Bobbing stay where vanilla has them; Horizon Settings and Extras drop their duplicate ways in. A test walks every menu and pins one place per setting and one way in per page. verify green at this head; saved keys and defaults unchanged.
2026-11-05 17:06:30 +01:00
Ludovic Marie 34881c8cca Merge pull request #9978 from horizon-rust/settings-unify/shaders-in-graphics
🤖 Agent: Shader packs (both kinds) move to a new Shaders page of the graphics screen; Horizon Settings loses its second way into the pack list. Shadow Cache moves from Performance to Shaders. verify green at this head; no saved key or default changes. Bottom of the settings-unify stack (#9979, #9980 follow).
2026-11-05 17:03:32 +01:00
CocofireHDandopencode 58263dc4a0 lan_e2e: capture the shulker lid open and closed
The shulker_menu check now keeps host and guest screenshots of the lid before and after the guest closes the menu, so a stuck lid is visible as well as asserted.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 17:03:25 +01:00
Horizon agentandClaude Opus 5.5 d523cb803b Launcher: rustfmt the uninstall changes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:43:55 +01:00
Claude 469f8d9b31 Native builds: explain Smart App Control only when it blocks (#5911)
Tested on Windows 11 with Smart App Control on (VerifiedAndReputablePolicyState
1): a build whose programs already ran before it was turned on still
succeeds, so refusing every build when the registry value is 1 stopped
builds that work. The check before the download is gone; a build that hits
os error 4551 still leads its error with what to do.

The message is translatable now, and says Windows also blocks some of the
toolchain's own programs (dlltool.exe was refused in a player's build), not
only the ones compiled on the computer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y255SLkXwxkdNVZnS8dNbf
2026-11-05 15:42:23 +00:00
Horizon agentandClaude Opus 5.5 9960bdec5a Launcher: Uninstall in Control Panel opens its window
main() ran the CLI parser before looking for the setup window, so
`horizon-launcher.exe uninstall` was refused as an unknown subcommand, with the
error going to a console Control Panel's start doesn't have: no window, nothing removed.
The setup modes are now decided first (setup_args::classify, pure and tested on every OS).
The uninstaller also asks a launcher that is still open to quit before removing its files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:41:12 +01:00
Ludovic Marie 624c4f6265 Merge pull request #9900 from NikitaMGrimm/frame-identity-harness
Frame identity harness: the picture a change leaves alone, pixel for pixel (HDR step 2a)
2026-11-05 16:32:00 +01:00
Ludovic Marie b4c92aba07 Merge pull request #9878 from ilguerro/fix/mob-vibrations
🤖 Agent: The host now forwards the mob game events vanilla's mobs post (prime_fuse, eat, container_open and close, entity_place, and entity_action beyond the armadillo's), and the sculk harness gets creeper and sheep scenarios that main fails. They run last so the catalyst scenarios' entity ids don't shift.
2026-11-05 16:30:26 +01:00
Ludovic Marie eb4291a61d Merge pull request #9876 from martipops/fix/pad-one-reader
🤖 Agent: PadRouter now adopts P1's gilrs reader and hands it back, so routing no longer opens one reader while another closes, which upset KWin's holder count. The router's reader also gets EXTRA_SDL_MAPPINGS (8BitDo Ultimate 2). It merges cleanly with #8233's triggers.rs.
2026-11-05 16:30:21 +01:00
Ludovic Marie 5215aea1b8 Merge pull request #9872 from yahya-nusseibeh/fix/windows-junction-swap
🤖 Agent: The new junction is made as current.switching before the old one goes, and the old one is restored if the rename fails, so a failed mklink no longer leaves the server with no current release. Paths are quoted through raw_arg, and % or a quote is refused. The author's Windows CI run built and tested it.
2026-11-05 16:30:16 +01:00
Ludovic Marie bf3fd2d5f0 Merge pull request #9903 from glitchsnake/fix/9744-fishing-hook-random
🤖 Agent: the catch now rolls from the table's random sequence (minecraft:gameplay/fishing, else the level random), as vanilla's LootTable.getRandomItems(LootParams) does, and test_fishing_rng_parity checks the hook's draws against a 26.3 server. I removed four tests that compared LegacyRandom with itself.
2026-11-05 16:30:12 +01:00
Ludovic Marie b9913a1902 Merge pull request #9866 from Vaspyyy/native-fit-before-install
🤖 Agent: The fit line is worked out on the search thread, not the UI's. hooks_at reads raw.githubusercontent (no API quota) and is cached per commit. The four new strings go through tr/trf. One limit: it compares only the NATIVE_HOOKS_API number, so for a revision older than the hook catalog, a mod naming hook points can read 'Builds on the Horizon you play' and still be refused at build time. horizon-mods and launcher tests pass on the desktop with this head merged into main.
2026-11-05 16:25:02 +01:00
Ludovic Marie 0f9cb34f9d Merge pull request #9977 from horizon-rust/server/prune-chunks
🤖 Agent: horizon-server's one-shot chunk prune ([prune_chunks]); one point to know: a delete run rewrites region files, so it only runs before the game starts and deletes nothing if a keep_files file can't be parsed. Checked with vanilla 26.3's RegionFile on 3,743 real chunks from the official server: kept chunks equal as NBT, nothing with InhabitedTime >= 1200 pruned.
2026-11-05 16:24:58 +01:00
Ludovic Marie 4c302fc817 Merge pull request #9880 from Swuarez/fix/8906-glide-wall-sound
🤖 Agent: Player.playSound passes the player as except, so the server's broadcast skips the glider and its own client must play big_fall or small_fall. The client's travel now plays it and still never hurts. The unit test checks both.
2026-11-05 16:24:53 +01:00
Ludovic Marie 4e140f9b60 Merge pull request #9868 from Alysia42/fall-fly-ticks-order
🤖 Agent: fallFlyTicks is counted after the movement as LivingEntity.tick does, so updateFallFlying's +1 wears the elytra on the 20th glide tick, not the 19th, for the local player and LAN guests. Main merged in (both new motion tests kept).
2026-11-05 16:24:48 +01:00
Ludovic Marie fc8ff6b378 Merge pull request #9865 from xxaxio/fix/9791-vehicle-container-close-multi-viewer
🤖 Agent: The shared vehicle stand-in at CART_KEY_Y is now removed only when no other guest's menu (or the host's container) still shows it. The items are still stored back on every close. The proof comment's container.viewers mechanism isn't in this diff; I reviewed the diff itself.
2026-11-05 16:24:43 +01:00
masa-masa-0314andZCode df263fef74 Realms: the settings tab and the region preference screen
The configuration screen's Settings tab (RealmsSettingsTab): the world's name and
description fields (the click moves the caret between them), the region preference line
and its Region Preference button, and the open/close toggle moved here from the footer
(vanilla keeps it on this tab). Leaving the tab — switching tabs or Back/Escape — saves
the changed name, description and preference through RealmsClient.updateConfiguration
(POST worlds/{id}/configuration with RealmsConfigurationDto's JSON: the active slot, its
settings, the preference and the description).

RealmsPreferredRegionSelectionScreen: the automatic choices and every manual region, a
click selects, Done writes the choice back to the tab and Cancel drops it.

RealmsClient gains getPreferredRegionSelections (GET regions/preferredRegions) and
updateConfiguration; RealmServer reads regionSelectionPreference; RegionSelectionPreferenceDto,
RegionDataDto and RealmSetting serialization join the DTOs.

The tier's render/simulation distance options and the region quality icons are the next
PRs (the icons need sprites the GUI atlas does not carry yet); the region rows say their
name for now.

Co-Authored-By: ZCode <[email protected]>
2026-11-05 23:24:38 +08:00
Ludovic Marie 126b6d06b4 Merge pull request #9864 from TheMrZZ/fix/selector-entity-bounds
🤖 Agent: Box selectors test the target's bounding box with strict AABB.intersects (EntitySelector.getPredicate), while distance and sorting stay on the origin. Dragon, fangs, spawner, cloud and other kinds keep the point test until their boxes are modelled.
2026-11-05 16:24:38 +01:00
Ludovic Marie ad24f0f79f Merge pull request #9863 from TheMrZZ/fix/h323-mob-predicate-effects
🤖 Agent: Predicate views now get the mob's live effects as (id, amplifier, duration), which MobEffectsPredicate reads. Main merged in (corpus groups kept side by side); game tests pass on the desktop in a union with main.
2026-11-05 16:24:33 +01:00
Ludovic Marie 8af85a1e54 Merge pull request #9856 from Mayonesu0/fix/8036-wool-jump-vibration
🤖 Agent: Checked against Entity.applyMovementEmissionAndPlaySound and checkFallDamage in 26.3: STEP carries the getOnPos state and HIT_GROUND the mainSupportingBlockPos state, so a carpet dampens. A mob's support is looked up only when a step is due, not every tick. Climbing still reads the block at the feet.
2026-11-05 16:24:28 +01:00
Horizon agentandClaude Opus 5.5 1e887567bd Extras: a label too wide for half a row takes the whole row
"Keep channels for UI sounds: Off", "Scroll wheel changes settings: Off"
and "Other players' cosmetics: Hidden" ran past their half-width
buttons and slid back and forth. Extras and PvP & HUD now measure each
item's label, with its first values, in the screen's font when the page
is drawn; one wider than a half-width button holds gets the whole row.
It works in every language, with no caption shortened.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:20:09 +01:00
Horizon agentandClaude Opus 5.5 557bbea813 Settings: each setting in one place, Video Settings opens the graphics screen
Options > Video Settings... now opens Horizon's graphics screen, as
Sodium's replaces vanilla's video screen. It gains the vanilla rows only
vanilla's screen had (Graphics Preset, Exclusive Fullscreen and its mode,
Menu Background Blur, Texture Filtering, Max Anisotropy) and the extras
that change the picture (Fullbright, Dynamic lights, Hide block-break
particles, still saved in horizon-extras.txt). It drops FOV and View
Bobbing, which stay where vanilla has them (Options, Accessibility).

Horizon Settings drops its second ways into the graphics screen, the
controller screen (Controls has it) and Mods (the Options screen has
it); Extras drops its PvP & HUD link and the PvP preset, which PvP & HUD
opens with. Vanilla's own Video Settings layout stays for
horizon-ctl options video-vanilla and its tests; no menu opens it.

A test walks every menu from Options and checks that each setting and
each page has one way in, and that every row of vanilla's Video
Settings is on the graphics screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:20:08 +01:00
Ludovic Marie 90a8b6efff Merge pull request #8233 from voidsipp/fix/7323-linux-triggers
🤖 Agent: Axis triggers are read only for an unmapped six-axis pad with the Xbox face layout (C and Z absent), so a generic pad's centred right stick on LeftZ/RightZ never reads as a half-pulled trigger. Main merged in beside #9830's face_buttons; game tests pass on the desktop in a union with main.
2026-11-05 16:19:19 +01:00
Ludovic Marie 87f4372520 Merge pull request #9909 from masa-masa-0314/feat/realms-worlds-slots
🤖 Agent: the slots now parse as the service sends them (a list, each slot's options a JSON string, missing world slots empty, worldTemplateId/Image), so the detail answer and the Players tab no longer fail; official Realms API only.
2026-11-05 16:19:14 +01:00
Ludovic Marie f10167a87d Merge pull request #9911 from xxaxio/fix/9902-launcher-topbar-invite-overflow
🤖 Agent: under 1380 px the tab strip only shrinks when the buttons need the room, so nothing scrolls in English where it already fit; the placeholders still go through tr() inside line_edit::content.
2026-11-05 16:19:09 +01:00
Ludovic Marie 7146e9b17f Merge pull request #9908 from DUzzL/fix/9877-team-predicate
🤖 Agent: a direct port of TeamPredicate (exact, case-sensitive name match, no team fails), filled from the real scoreboard for the player, mobs (a tamed pet answers with its owner's team) and LAN guests.
2026-11-05 16:19:04 +01:00
Ludovic Marie 3973f6b63c Merge pull request #9907 from arslanarm/shaderpacks/ftransform-order
🤖 Agent: one more mat4 x vec4 per vertex (16 FMAs), only in pack gbuffers and shadow vertex shaders: negligible next to fragment work. The GPU test tells the two rounding orders apart with exact expected bits.
2026-11-05 16:18:59 +01:00
Ludovic Marie 735bfffc0b Merge pull request #9906 from MSedgeMC/fix/wither-rose-smoke-centre
🤖 Agent: the marker_particles harness gains 27 wither rose cases that fail without the fix (x and z off by 0.5); I made outline_center bound only the shape's own boxes instead of starting from 0.5.
2026-11-05 16:18:55 +01:00
Ludovic Marie 73810bccc1 Merge pull request #9905 from senjinthedragon/shadow-depth-gather
🤖 Agent: removes the R32Float depth copy made every frame (512 MiB and about 10 ms at 8192², measured); each plain-sampler depth tap becomes one gather plus a bilinear blend, behind a uniform branch. The gather order (w,z / x,y) matches the blend.
2026-11-05 16:18:50 +01:00
Ludovic Marie cb05802dfb Merge pull request #9896 from arslanarm/shaderpacks/colortex-mipmaps-box-filter
🤖 Agent: hot path: at most 4 texel loads per mip texel (9 where both sides are odd), only for buffers that set MipmapEnabled: about 1.2M loads for a 1280x720 chain, well under 0.1 ms. Odd levels no longer skip texels.
2026-11-05 16:18:45 +01:00
Ludovic Marie ccfc6cf2be Merge pull request #9895 from arslanarm/shaderpacks/cloud-height-without-clouds
🤖 Agent: per-frame cost goes down: cloudHeight now just reads the dimension's visual/cloud_height (vanilla's EnvironmentAttributes default is 192.33), with no alpha branch; Nether and End packs stop seeing NaN.
2026-11-05 16:18:40 +01:00
Ludovic Marie e5e2bef6de Merge pull request #9893 from DUzzL/fix/9870-disconnect-layout
🤖 Agent: the layout matches DisconnectedScreen.init in the bytecode: cell padding 10 for the title and reason, 2 for the button, the reason wrapped to width - 50, the block centred. I moved wrap's doc comment back onto wrap.
2026-11-05 16:18:35 +01:00
Ludovic Marie 453f6114bd Merge pull request #9886 from DUzzL/fix/9867-advancement-targets
🤖 Agent: grant and revoke now run as each selected player's own turn (for_player), and the feedback follows CommandResponseTracker: changed players on success, every selected player on failure.
2026-11-05 16:18:31 +01:00
Ludovic Marie 200314a500 Merge pull request #9884 from xxaxio/fix/9781-map-marker-names
🤖 Agent: matches MapRenderer.render's name label in the bytecode: scale clamp(25/width, 0, 6/9), and z -0.025 then scale -1 and +0.1, so -0.125 in map space. I dropped a test that only checked its own closure.
2026-11-05 16:18:27 +01:00
Ludovic Marie cff59ae4b5 Merge pull request #9869 from arslanarm/shaderpacks/fluid-top-split
🤖 Agent: mesh-time only: one rotate_left per fluid top while a shaders/ pack is loaded, and a remesh when that changes; nothing per frame. The split rule is fitted to probe readings of Iris that was only run, not read.
2026-11-05 16:18:22 +01:00
Ludovic Marie a683d394dc Merge pull request #9838 from arslanarm/shaderpacks/shadow-caster-margin
🤖 Agent: I pushed b0f885600: this branch now merges #9772 and gives the new test's `ShadowCull` `reach: None`. Without that, whichever of the two landed second would break main's build. The union with #9772 passes every `shadow_caster_tests`. Parity's first run failed only on tab_list (its harness's missing vanilla.log) and the known lunge_motion flake, neither reached by shadow culling: rerun.
2026-11-05 16:18:18 +01:00
Ludovic Marie 9e8ad0b088 Merge pull request #9725 from vnsmoreira/extras-pregen-toggle
🤖 Agent: Off by default, and the client completion tree only gets `/pregen` when the dispatcher has it, so vanilla's command tree is unchanged until a player turns it on. Note for later: `pregen::command_tree()` duplicates the shape of `horizon_commands` in horizon-command; a change to one must follow in the other. I merged main (7899c12c4): the pregen line sits beside main's new `horizonMarks`/`cosmeticsSolo` extras, and 773e3526d queues every toggle on the server thread (no blocking `call`), with a test that a quick on-off reaches the server in order.
2026-11-05 16:18:13 +01:00
Horizon agentandClaude Opus 5.5 941d0553fd horizon-server: rustfmt and clippy for the chunk prune
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:17:22 +01:00
Horizon agentandClaude Opus 5.5 c047a7ef5a Cosmetics: rustfmt the name tag lift tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:16:46 +01:00
Horizon agentandClaude Opus 5.5 6c526709ff Fishing: drop the scenario tests that compared LegacyRandom with itself
test_fishing_rng_parity now checks the hook's draws against vanilla; the
four scenario tests seeded two LegacyRandoms alike and compared them, so
they could not fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:15:48 +01:00
Horizon agentandClaude Opus 5.5 1f43c60b85 Launcher: the Wardrobe links to the site's shop, nothing more
Ludovic (2026-11-05): the shop is on the site only; the launcher has the
Wardrobe and a link to the site. The Wardrobe already had no prices,
product lists or checkout; its "Open the shop" button becomes one plain
secondary button, "Get more cosmetics on playhorizon.dev", opening
https://playhorizon.dev/shop, by the title or, for an account that owns
nothing yet, in the empty state. Translated in all 86 languages.

Kept: what you own, equip and unequip, the preview, the purchase history
and the founder set's "Claim for free" where Paddle doesn't sell (the
site sends those players to the Wardrobe: only the launcher can prove
the account with Mojang).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:15:45 +01:00
Horizon agentandClaude Opus 5.5 e3ccd955d7 Graphics: a Shaders page holds the shader packs
The graphics screen gets a Shaders page between Performance and Advanced:
Shader Packs... opens the pack list (Horizon's .hshader packs and shaders/
folder ones, with the picked pack's options), and Shadow Cache moves there
from Performance. Horizon Settings loses its Shader Packs button, so the
list has one way in, beside the other graphics settings, as Iris hangs
off Sodium's video screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:13:39 +01:00
Horizon agentandClaude Opus 5.5 24da3cefad Cosmetics: lift the name tag over tall items
A crown's star on the head slot or an orrery on the back slot crossed the
wearer's name tag, which vanilla draws at a fixed height. Each item now
knows its top (Item:🔝 pixels above the model's neck with the bones at
rest, animations included), found in Item::parse with the reach check.
When the tallest item drawn on a player passes the tag's lowest pixel,
the whole tag (Horizon's mark and the badge row included) goes up to one
text pixel over it.

Players with no tall item, and anyone whose items aren't drawn (others'
cosmetics on Name tags only or Hidden, My cosmetics hidden, a helmet over
a hat), keep vanilla's height exactly: the lift is never added then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:12:54 +01:00
LokiandAntigravity 78c23a4f37 tools: use lab.game_env in test_fishing_rng_parity (fixes #9744)
Co-Authored-By: Antigravity <[email protected]>
2026-11-06 00:10:36 +09:00
Horizon agentandClaude Opus 5.5 89ab731e0c horizon-server: the prune report says how long the run took
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:03:51 +01:00
Loki c22c38d8b8 Merge remote-tracking branch 'origin/main' into pr-9903 2026-11-05 23:58:45 +09:00
Horizon agentandClaude Opus 5.5 19cb6b6730 horizon-server: a pruned region file keeps the old file's permissions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:53:36 +01:00
Ludovic Marie 66f3981f52 Merge pull request #9809 from voidsipp/fix/9742-account-refresh
🤖 Agent: Only the glass look changes: the actions move under the profile and wrap inside the 400 px card, and Night keeps its row through the same `slot_row`. Launcher fmt, clippy and 674 tests pass on the desktop with this head merged into main.
2026-11-05 15:45:35 +01:00
Ludovic Marie 83148f3849 Merge pull request #9790 from voidsipp/fix/9719-glass-launch-settings
🤖 Agent: Launch… reuses the existing translated string and the glass sliders-horizontal icon, and clearing `launch` before `toggle_launch` stops a panel left open for another instance from turning the click into Close. Launcher fmt, clippy and 674 tests pass on the desktop with this head merged into main.
2026-11-05 15:45:31 +01:00
Horizon agentandClaude Opus 5.5 ada2c6b810 Merge main into #9854: placement cases after the wake and used-stat cases
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:42:35 +01:00
Horizon agentandClaude Opus 5.5 afbc3ee1cf Merge main into #9863: keep the mob effect predicate group beside frame maps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:42:20 +01:00
Horizon agentandClaude Opus 5.5 ff980bfed4 Merge main into #8233: face_buttons beside triggers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:42:00 +01:00
levzzzandCodex 7c085b5acd Fix fishing loot and experience RNG ordering
Use the loot table random sequence (or level fallback), preserve level draw order, and create the experience orb directly. Add vanilla server comparisons for casting, bobbing, catch timers, and retrieval.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-05 19:39:19 +05:00
Horizon agentandClaude Opus 5.5 ccaa93ba2c horizon-server: [prune_chunks] server.toml round trip test, Cargo.lock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:39:00 +01:00
Ludovic Marie 1eaf4d2e5b Merge pull request #9821 from voidsipp/fix/8583-lod-test-fixtures
🤖 Agent: Test-only, plus a `background` flag that only the bounded test turns off; the game still builds its decoration workers at idle priority.
2026-11-05 15:37:19 +01:00
Ludovic Marie 65af1daf70 Merge pull request #9772 from arslanarm/shaderpacks/shadow-distance-render-mul
🤖 Agent: The reach test prunes with the same grown box as the caster test. Packs without `shadowDistanceRenderMul` take `reach: None` and walk exactly as before. #9838 is stacked on this branch: it now contains these commits.
2026-11-05 15:37:14 +01:00
Ludovic Marie ae59c828f1 Merge pull request #9769 from arslanarm/shaderpacks/celestial-positions
🤖 Agent: The positions now come from JOML's quaternion turns, ported under the MIT notice that `camera.rs` and THIRD_PARTY_NOTICES already carry. It's a few float ops per frame, and the test pins five Iris readings bit for bit.
2026-11-05 15:37:10 +01:00
Ludovic Marie 4b1e8dfc97 Merge pull request #9756 from I114rk/fix/boot-paint-flicker
🤖 Agent: Every repaint is now one `BitBlt` from a memory DC, and every GDI object (DC, bitmap, old selection) is released on both the success and failure paths. PR CI doesn't build Windows; your Windows clippy run covers that.
2026-11-05 15:37:05 +01:00
Horizon agentandClaude Opus 5.5 5278933e3f Keep drop_offset's three-argument form; drop_offset_from takes movedUp
#9859 adds a drop_offset caller (the dispensed dragon); keeping the
existing signature lets both land in either order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:36:28 +01:00
Horizon agentandClaude Opus 5.5 d7e7b92735 Ender Dragon spawn egg: vanilla random order and the dragon's own random
EntityType.create draws the wrapped spawn yaw and Mob.finalizeSpawn the
follow-range triangle and the left-handed roll from the level random;
the dragon seeds its own random (Entity.random), not from the level one.
test_spawn_egg_fluid_parity gains the dragon egg used on a block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:34:34 +01:00
Ludovic Marie c4224839db Merge pull request #9845 from Joansitoh/fix/9778-missing-sounds
🤖 Agent: f4c4ab173 keeps `lastLevelUpTime` and `tickCount` per player entity (`LevelUpSound`, reset on respawn), as `Player.giveExperienceLevels` reads them, and `/xp points` now sounds per level crossed like `giveExperiencePoints`; the lan_e2e `levelup-sounds` check pins both players apart. Wither head particles are drawn on a following client only: the server's `particles.add` draws nothing. The guest tick loop is one add per player per tick.
2026-11-05 15:33:07 +01:00
Ludovic Marie be95b51aac Merge pull request #9841 from voidsipp/fix/9642-player-placement
🤖 Agent: I pushed c17d14c73. `GameLevel` now borrows `guest_boxes` outside a guest turn instead of cloning them on every level build. `for_turn` searches only the few borrowed ancestors (hub conns are unique), so each guest's turn is O(players), not O(players²), every tick. Your tests are unchanged and pass. Then I merged main (c4b85eaed): only the `lan_actions.rs` imports conflicted.
2026-11-05 15:33:02 +01:00
Ludovic Marie 0caddefd8e Merge pull request #9830 from martipops/fix/pad-unmapped-xy
🤖 Agent: The swap applies only on Linux, for a driver mapping with exactly the four face buttons and a non-Sony/Nintendo vendor. Pads with an SDL row aren't touched. It costs six `button_code` lookups per pad per poll.
2026-11-05 15:32:57 +01:00
Ludovic Marie a0b7ba13f1 Merge pull request #9827 from DUzzL/fix/9786-loyalty-owner
🤖 Agent: Matches `ThrownTrident.tick`'s `isAcceptibleReturnOwner` (checked in the decompiled source): a dead or spectating owner drops the trident (unless creative-only) and discards it, and an owner not in this level still keeps the old any-player pickup.
2026-11-05 15:32:52 +01:00
Ludovic Marie dcb0962c58 Merge pull request #9818 from Swuarez/fix/8038-breeding-xp
🤖 Agent: I pushed 8cff8f837: with the vanilla data present, the new test failed because the llamas weren't tame (`AbstractHorse.canParent`), so they never bred. Without the data it returned early, which is why it passed for you. Both llamas are now tamed, and it passes on real data.
2026-11-05 15:32:47 +01:00
Ludovic Marie d52ff7a6f7 Merge pull request #9812 from rison1337/native-hook-stamp-selected
🤖 Agent: `--only` keeps first-parent order and leaves the other 18 fragments and their markers byte-identical. The pending signatures in #9431 and #9649 belong to other fragments, so stamping 410/411 doesn't settle them early. If another stamp lands first, regenerate.
2026-11-05 15:32:42 +01:00
Ludovic Marie c8e4f35717 Merge pull request #9807 from BPMichon/feat/toast-add-hook
🤖 Agent: With no listener, `toast_allowed` is one dispatch over an empty list, and it's asked only when a toast is created, not per frame (a cancelled hint is remembered). Updates to a shown toast are not re-asked, as with vanilla's addOrUpdate. I pushed 6fa2c9933: main added `Hint::SocialInteractions` (#9796), so `Hint::name` needed the arm to build, and that toast now also goes through `toast_allowed` (listed in the docs as `socialInteractions`).
2026-11-05 15:32:37 +01:00
Ludovic Marie 989bfacfe2 Merge pull request #9802 from lightcodegit/fix/crafter-trigger-box
🤖 Agent: I pushed cfa94ac87: vanilla's `AABB.intersects` is strict (`minX < maxX && maxX > minX`), so a player box that only touches a face of the 17-block box is out. The test is now `<`/`>`.
2026-11-05 15:32:33 +01:00
Ludovic Marie 4cfa08e49f Merge pull request #9797 from voidsipp/fix/9524-physical-pack-folder
🤖 Agent: `dunce` is already in Cargo.lock (MIT/Apache/CC0) and is Windows-only. The Help link now goes through `open_uri`, so a URL is no longer canonicalized as a folder path.
2026-11-05 15:32:28 +01:00
Ludovic Marie eede50452a Merge pull request #9765 from arslanarm/shaderpacks/atlas-linear-mipmaps
🤖 Agent: Only the block atlas gets the new sampler, and only when filtering is on: normals, specular and Horizon's own packs keep `NearestRepeat`. The GPU test pins the 0.5/0.25 level blends. I merged main (latest b859bcf31): the conflicts were only the test-module list in `pack_post.rs`.
2026-11-05 15:32:23 +01:00
Horizon agentandClaude Opus 5.5 5d9bd56ec8 horizon-server: prune barely visited chunks ([prune_chunks], #9949)
A one-shot step before the game starts, switched on in server.toml: chunks
with InhabitedTime under min_inhabited_ticks (1200) are deleted from region/,
entities/ and poi/, files rewritten compacted and emptied ones deleted.
Chunks near the spawn, named by keep_files (SMP claims and homes), that can't
be read, and a ring around every kept chunk stay. mode = "dry-run" only
writes the report while the game runs; either mode turns itself off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:32:19 +01:00
Ludovic Marie b1ba47e910 Merge pull request #9751 from TheMrZZ/fix/h317-frame-map-detach
🤖 Agent: This matches `ItemFrame.readAdditionalSaveData`: the old map is detached only when the old stack is non-empty and `ItemStack.matches` fails (count, item, components), and a glow frame keeps its type. I merged main (latest 5d54563cc), following your keep-both note: both isolation sets, the `expect_map` guard, and `frame_map_state`, `mob_anger` and #9848's `menu_state` all in the record accounting.
2026-11-05 15:32:18 +01:00
Ludovic Marie 361527bc07 Merge pull request #9743 from TheMrZZ/fix/map-decoration-type-namespace
🤖 Agent: Only a bare `type` is qualified, through the existing Identifier parser, at both the decode and the encode boundary, which matches `MapDecorationType.CODEC`'s holder-by-name read. #9814 (already merged) handles the empty default separately. I merged main (latest b9df50777), keeping your `canonical_other_component` beside #9814's empty-default filter in `encode_components`, and your fixture flags beside #9848's piglin ones.
2026-11-05 15:32:13 +01:00
Ludovic Marie 653aef535a Merge pull request #9737 from ilguerro/fix/villager-stand-up
🤖 Agent: The villager now uses the same `findStandUpPosition` search as the player, with its own 0.6 x 1.95 box and vanilla's fallback (0.1 above the block over the bed). The player path is a move with no change, and the `1.0E-5F` border inset is now the float vanilla uses. I merged main (8bbedb4d4): the use_item_on harness keeps your wake cases and #9775's used-stat cases as two separate seeded streams, so neither changes the other's cases.
2026-11-05 15:32:07 +01:00
Ludovic Marie bc243f7208 Merge pull request #9722 from lightcodegit/fix/splitscreen-pause-ticks
🤖 Agent: `pauses_game` now reads the same `split_players` list `set_paused` already uses, so the window can no longer stop ticking while the server thread runs on for P2. Solo, LAN and guest windows take the old path.
2026-11-05 15:32:00 +01:00
Ludovic Marie c8bfb81f9c Merge pull request #8709 from Akirabane/feat/7484-control-release
🤖 Agent: The release names its hand-over, so a stale or repeated `player-release` cannot remove a player who is playing again, and `remove_guest` only skips the leave line when `quiet_leave` is set: ordinary leaves are unchanged. Your rebase (ebfab7fc8) is the same patch; checked in a 21-PR union on main: fmt, clippy -D warnings, workspace tests.
2026-11-05 15:31:55 +01:00
CocofireHDandopencode f8d0840cd7 lan_e2e: prove a sculk sensor's vibration reaches the host and a guest
VibrationSystem.Ticker.trySelectAndScheduleVibration sends one vibration particle to every player within 32 blocks; the check asserts both the host's window and a LAN guest make it (#8615).

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:30:45 +01:00
MSedgeMC e51fd1dff4 port_map: ItemStack.hurtAndConvertOnBreak is the steering stick's rod
The row was `missing`; the conversion was in `use_steering_stick` but
built a fresh plain rod and skipped `hurtAndBreak`.
2026-11-05 14:24:14 +00:00
MSedgeMC c135993a63 test: a steering stick that breaks leaves the rod the stick was
`transmuteCopyIgnoreEmpty`: at 18 + 7 of 25 the carrot on a stick becomes
a fishing rod that kept its custom name and starts whole, where the old
path wrote a fresh plain rod. Wear that Unbreaking took away (0) breaks
nothing, and a stack with no durability never breaks.
2026-11-05 14:24:12 +00:00
MSedgeMC c942e133da fix: a steering stick's wear goes through the item's durability rules
`FoodOnAStickItem.use` wears the stick with
`ItemStack.hurtAndConvertOnBreak`, which runs `hurtAndBreak` - and
`hurtAndBreak` asks `processDurabilityChange` first, so Unbreaking has its
say before any damage lands.

`use_steering_stick` added the whole 7 (carrot on a stick) or 1 (warped
fungus on a stick) straight onto `stack.damage`, so an Unbreaking stick
lasted exactly as long as a plain one, and it never reached the mods'
item-damaged hook.
2026-11-05 14:23:55 +00:00
CocofireHDandopencode 8df11ffe4c Mod fluid tanks: let a tank refuse a fluid
A mod tank can now refuse a fluid through native_block_entity_accepts_fluid (NeoForge's IFluidHandler.isFluidValid). The server asks it before filling a tank in both insert passes and before native_move_fluids counts a tank's room; every tank accepts every fluid unless a mod cancels. A water tank can take no create:chocolate, and a removed mod leaves no fluid id it cannot use behind.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:21:31 +01:00
Horizon agentandClaude Opus 5.5 daf49b24bb rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:18:21 +01:00
Horizon agentandClaude Opus 5.5 f6f95b6900 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:18:21 +01:00
Loki 22c2b843c5 Merge remote-tracking branch 'origin/main' into fix/9744-fishing-hook-random 2026-11-05 23:18:03 +09:00
Ludovic Marie c04c5ccf90 Merge pull request #9940 from horizon-rust/docs/telemetry-presence-exception
🤖 Agent: docs only. telemetry.md now lists Show me as Horizon (on by default) as the second exception to 'nothing without consent', which settles socksprox's point on #9440.
2026-11-05 15:15:50 +01:00
Horizon agentandClaude Opus 5.5 6968e9dd97 docs: telemetry.md names Show me as Horizon as the second exception to 'nothing without consent'
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:14:40 +01:00
MSedgeMC 3ef40bc92f port_map: BundleItem.getTooltipImage names the tooltip display rule
The row pointed at the geometry and carried the missing rule as a gap;
the grid now goes through `item_tooltip::bundle_tooltip`, which asks
`TooltipDisplay::shows("minecraft:bundle_contents")` before the layout.
2026-11-05 14:12:07 +00:00
hildlabsandClaude Opus 5.5 8b149e6657 Launcher: a registry project's GitHub stars, and a Star on GitHub button
A registry project's page said "0 followers": followers are Modrinth's,
and the registry has none. Its creators collect stars on their GitHub
repository instead, so the index gets `projects[].stars` (the source
repository's stars, which horizon-mods' publish job reads every hour, as
it adds `downloads`; absent when 0, the schema unchanged) and the page
shows "{count} stars on GitHub" there, with a "Star on GitHub" button
beside "Sign in with GitHub to rate" that opens the repository, where
the player stars it. No sign-in or permission is added.

`browser::horizon::github_repo` reads the repository from `source` (a
folder of it, `.git`, a query or an anchor are the same repository;
another host or a name GitHub doesn't allow gets no button). A project's
details now go to the page as the `Arc` it keeps them in, so the larger
`Details` doesn't make `Loaded` a large enum. Both strings are in every
language's table.

Fixes #9929

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:11:58 +01:00
MSedgeMC 875b657878 test: a bundle grid hidden by the tooltip display
`minecraft:bundle_contents` in `hidden_components`, and `hide_tooltip`,
both take the grid away; a display that hides `minecraft:lore` leaves it.
2026-11-05 14:11:58 +00:00
MSedgeMC 2ec1ce0480 fix: a bundle's contents grid follows the tooltip display
`BundleItem.getTooltipImage` returns nothing when the stack's
`minecraft:tooltip_display` hides `minecraft:bundle_contents`, so a
bundle given with `hide_tooltip` or with that component hidden drew no
grid. Horizon drew the grid for every bundle, and the tooltip box grew to
hold it.

`item_tooltip::bundle_tooltip` asks the display first and then defers to
`bundle::tooltip_layout`; the tooltip draw uses it for the height probe
and for the draw alike.
2026-11-05 14:11:58 +00:00
CocofireHDandopencode 4993bc83ac Shaders: upscale the scaled pack target to the full surface
A Resolution Scale leaves the pack's picture smaller than the window. Its final pass drew at the window's size, so a shader using gl_FragCoord as texel coordinates (Photon's final) only filled a quarter of the screen. Draw final at the picture's size into an offscreen texture and copy it once to the window with a screen-row fullscreen pass; the no-pack path and same-size output are unchanged.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:10:10 +01:00
Florian ERNSTandCodex eb0193cb34 Normalize Enderman carried states before placement
Run neighbor-shape updates on the authoritative level before the existing eligibility checks, and clear the verified carrier after the handled placement.

Co-Authored-By: Codex <[email protected]>
2026-11-05 15:06:31 +01:00
levzzzandCodex GPT-6 aa19826c39 Fix dispensed dragon spawn height and sound
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 19:06:15 +05:00
levzzzandCodex GPT-6 773e3526dd Test queued pregeneration toggle changes
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 19:05:08 +05:00
Ludovic Marie a61e02d362 Merge pull request #9915 from horizon-rust/fix/gl-terrain-uint-select
🤖 Agent: fixes the OpenGL startup crash on old AMD drivers (#9892), a regression from #9584 in Nightly f319701. The new test fails on main and passes here.
2026-11-05 15:05:02 +01:00
CocofireHDandopencode 2816f17962 Shulker boxes: close the block entity when its menu is removed
A guest's use raised its opener count twice: open_menu counted the block entity's startOpen, then lan_open_container counted it again once the menu was sent. Closing lowered it once, to one, so stopOpen never ran and the lid stayed open with its raised collision (#9500).

The guest open path now relies on the count open_menu already made; the harness keeps the lid open and closed checks for both host and guest.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 15:01:27 +01:00
MSedgeMC afad8241b6 port_map: the two BeehiveBlock tick methods
animateTick and trySpawnDripParticles are block_animate_tick and beehive_drip;
the animateTick row records the attempt bound the loop re-reads.
2026-11-05 14:00:10 +00:00
MSedgeMC 677e00c72f test: a floor, a fluid and a whole sample box for the animateTick zoo
The tick cases' stand-in world stopped at y 32..96 and x/z -32..31, one block
short of every sample the loop takes and of the neighbours those samples read,
so a block at the edge compared air against the oracle's unending world: that
is where the beehive, the redstone ore and the pointed dripstone differences
came from. Fill the whole box plus a block either way, let a case name the
floor below y = 64, hand the loop the state's own fluid (a waterlogged block
has a water source, which it ticks and drips) and bind the client jar's block
tags in the oracle the way a datapack load does, which a bare bootstrap leaves
empty. Then add the families that read a neighbour, a shape or their own fluid:
dried ghast over soul sand, soul soil, stone and air; hives and a nest embedded
and hanging; a lit redstone ore and a deepslate one embedded and over air; and
stalactite tips over stone and water. 2,097 cases and 456,747 output lines
become 2,187 and 462,718.
2026-11-05 14:00:10 +00:00
MSedgeMC 9d11ae8abb fix: a hive asks the level random for its attempt bound until the loop ends
BeehiveBlock.animateTick is a `for (i = 0; i < random.nextInt(1) + 1; i++)`
loop, so the bound is drawn again on the check that ends it: a hive always
spends two nextInt(1) draws around its single trySpawnDripParticles attempt.
Horizon hoisted the bound out of the loop and spent one, which left the level
random a draw ahead of vanilla from the first hive the ambient loop touched:
every particle after it moved, and the honey drip of a hive hanging over air
came out at the wrong column.
2026-11-05 14:00:10 +00:00
MSedgeMC 25cb339d57 port_map: WitherRoseBlock.animateTick is block_animate_tick
The row records where the smell test sits now: three smoke puffs about the
centre of the pick shape, moved by the flower's per-column model offset.
2026-11-05 14:00:10 +00:00
MSedgeMC 3555aa81d3 test: a wither rose world in the marker-particles parity suite
The corpus had no wither rose case at all. Add a world that is all wither rose,
run the whole ClientLevel.animateTick loop over it and compare every smoke puff
by exact position bits in call order: twenty-seven cases, three seeds and
twenty-four at random ones. The probe now supplies the flower's per-column model
offset the way the client's mesh tables do (a case without it would compare a
shape the game never shows). 2,070 cases and 402,440 output lines become 2,097
and 456,747.
2026-11-05 14:00:10 +00:00
MSedgeMC af128082f4 fix: centre the wither rose's smoke on its pick shape
WitherRoseBlock.animateTick reads its centre off getShape(state, level, pos,
CollisionContext.empty()), a flower's own box whose bounds centre is 0.5/0.5
before the block's per-column model offset. AnimateContext::shape_y answers with
the collision shape, which is empty for every flower, so the centre came out as
0 and the whole smoke column sat half a block towards the block's north-west
corner. Take the centre from the pick shape's boxes (BlockFacts::outline) and
keep the model offset on top of it.
2026-11-05 14:00:10 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 67382d8ed2 Shader API 0.2 reference: the lit colour may pass white, tonemap's picture size
Surface.color's "0..1" stays in the 0.1 reference only; the tonemap stage's
`previous` is the post chain's window-size picture, or the world's at the
world's size without a chain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:58:52 +01:00
Horizon agentandClaude Opus 5.5 f03c416ebc Shader test: name the handle type the select check reads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:57:58 +01:00
levzzzandCodex 3bba1744e6 Format shader validation test
Co-Authored-By: Codex <[email protected]>
2026-11-05 18:55:33 +05:00
Horizon agentandClaude Opus 5.5 b4d0f716e3 Merge main into #9868: keep both new motion tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:51:55 +01:00
Horizon agentandClaude Opus 5.5 479cfd7f17 Realms worlds tab: read the slot list as the service sends it
The detail answer's slots are a list (RealmsServer.slotList) whose options
are a JSON string each (RealmsWorldOptionsJsonAdapter); a map of objects
failed the whole answer, players tab included. Missing world slots 1 to 3
are empty (finalizeSlots), unset options keep new RealmsWorldOptions()'s
values, and the template fields are worldTemplateId and
worldTemplateImage, in the update too. Cycle labels join through
options.generic_value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:51:51 +01:00
Horizon agentandClaude Opus 5.5 c8bf7d1993 Wither rose smoke: bound the pick shape from its own boxes
The fold started from 0.5 on every side, so a shape that does not cover
the block's centre would have been stretched to reach it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:49:08 +01:00
Horizon agentandClaude Opus 5.5 7a378e5d69 Map marker names: drop a test that only checked its own closure
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:48:55 +01:00
Horizon agentandClaude Opus 5.5 8d44fe96f8 Disconnect screen: keep wrap's doc comment on wrap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:48:44 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 bd186dc732 Shader API 0.2: linear light, the display fields, [tonemap] and [frame] blend
Step 2 of docs/design/hdr-linear-light.md in horizon-shader-api. The line is a
value (`Api`), and `check` and `horizon-shader` serve 0.1 and 0.2. Under 0.2
the preludes and entries decode colours at the stage boundary (a 2.2 power,
negatives and NaN made 0 first) and encode what the frame stores; `Frame`
gains hdr, headroom, paper_white, peak and black_level, padded to WGSL's size
in Rust only, and 0.1's prelude still declares only its 49 fields. A
`[tonemap]` stage and `[frame] blend` are 0.2-only manifest keys.

0.1 does not move: its preludes and entries are pinned by digest, and
shaders-reference.md is unchanged; 0.2 has shaders-reference-0.2.md. The game
refuses 0.2 packs and `blend = "linear"`, and the registry refuses 0.2, each
with a message saying why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:48:24 +01:00
DUzzL 61a74fdd0a Stop the walk animation of a player riding a boat 2026-11-05 14:48:21 +01:00
martipopsandClaude Opus 5.5 e58ee9c714 Controllers: icon buttons and two checkboxes take the focus, and a screen with no targets fails a test
An icon-only button recorded nothing, so the pad could not reach the pause
menu's Report Bugs, Feedback and Player Reporting icons, or Realms' icons. An
active button with an empty label is now a focus target. So are the
Multiplayer warning's "Do not show again" box and Optimize World's Erase
Cached Data box, which A now ticks.

tools/pad_menu_e2e.py gains every_screen_has_targets, which walks the title's
submenus and every options page and fails on a focus screen with no targets.
Replay Studio is allowlisted because it registers nothing yet. New pause_icon
and erase_checkbox cases press the new targets. The pause menu now has
unlabelled widgets, so splitscreen_e2e's setup scenario reads labels with get.
controller_settings_pad's check for a Navigation page now matches exactly: it
tripped on the General page's Navigation Repeat rows.

docs/controllers.md no longer says every rectangle registers itself or that
both modes reach every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 08:46:36 -05:00
CocofireHDandopencode 6492b16a7b Send minecraft:vibration particles from the server to nearby clients
Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 14:46:21 +01:00
CocofireHDandopencode 2cb008e118 Shaders: read depth as an unfilterable float so GLSL translates
The world's depth was bound as a depth texture and read with textureLoad, which naga's GLSL backend cannot emit, so TAA, the depth blit and the shader depth test failed to build on OpenGL. Bind those views as unfilterable float textures instead: wgpu accepts a depth view there, and textureLoad returns the same depth texel on every backend (Vulkan, DX12, Metal, GL).

The shader translation test now fails on any shader the GL backend runs that cannot translate, and pins fs_taa and fs_blit; the HUD's glint, which fails for an unrelated two-samplers-per-image naga limit, is the one allowed exception.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-05 14:45:43 +01:00
Horizon agentandClaude Opus 5.5 b859bcf312 Merge origin/main into shaderpacks/atlas-linear-mipmaps
Keeps atlas_mip_tests beside #9764's wrong_type_uniform_tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:45:36 +01:00
Horizon agentandClaude Opus 5.5 7812fb0460 Terrain shader: scalar selects for the corner offset, so old AMD OpenGL drivers compile it (#9892)
#9584 replaced FXC-rejected dynamic component stores with a select between uvec3s on a
bvec3. naga writes that as GLSL's mix(uvec3, uvec3, bvec3), which needs GLSL 4.5 or
EXT_shader_integer_mix; AMD's 2016 driver for the Radeon HD 7400M resolves it to the float
overload and rejects the 'Terrain solid (flat)' vertex shader, so the game no longer starts
there. One scalar select per component compiles to ?: on every backend.

The shader translation test now refuses any select between integer vectors on a bool vector.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:45:29 +01:00
Horizon agentandClaude Opus 5.5 5d54563cc6 Merge origin/main into fix/h317-frame-map-detach
The command_exec harness records frame_map_state and #9848's
menu_state ops alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:45:02 +01:00
Horizon agentandClaude Opus 5.5 b9df50777c Merge origin/main into fix/map-decoration-type-namespace
Keeps the map decoration namespace flags beside #9848's piglin anger
memory flags in the command_exec harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:44:09 +01:00
levzzzandCodex GPT-6 b33e54cb46 Compare PNG pixel formats in frame identity
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 18:41:36 +05:00
Ludovic Marie 6ec78cc425 Merge pull request #9910 from horizon-rust/fix/native-build-needs-hook-catalog
🤖 Agent: launcher-only fix, green on the desktop at d984823 (cargo fmt --check, clippy --all-targets -D warnings, cargo test: 559 + 102 passed). One point to watch: the catalog is fetched from raw.githubusercontent only when a hooked mod uses named hook points, so builds of raw-target mods make no new request.
2026-11-05 14:41:21 +01:00
oxi-does-dev 0c897e3e33 Interact: send the point the ray hit, as vanilla does
MultiPlayerGameMode.interact sends EntityHitResult.getLocation() minus
entity.position() in every ServerboundInteractPacket. Horizon sent a
fixed [0.0, 1.0, 0.0] for mobs, a point in their middle: armour stands,
saddles, leashes and anything else that reads the click carried
interactAt to the wrong place, unlike vanilla, and a server's reach or
ghost-hand checks saw clicks that vanilla never makes.

mob_hit and the spear's attack_range pick now return the point along
the view ray where the mob's box was met, measured from its feet, and
every caller destructures the third element. A regression test checks
the packet's point against the ray.
2026-11-05 16:40:43 +03:00
degepieandClaude Opus 5.5 770e4461f8 Mobs: naming one or dyeing a sheep fires player_interacted_with_entity
ServerGamePacketListenerImpl.handleInteract fires the trigger with the
held stack as it was before the interaction whenever an item interaction
succeeds. NameTagItem.interactLivingEntity and DyeItem.interactLivingEntity
both succeed, but interact_mob returned after name_mob and after the sheep
dye before reaching the shared trigger call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:40:41 +05:00
Ludovic Marie 868039b457 Merge pull request #9897 from horizon-rust/registry-v2/required
🤖 Agent: with levzzz5154's commits an entry's icon must be exactly icons/<id>.png, so one project can't show another's icon; all 94 entries on horizon-mods main already comply and none is incomplete.
2026-11-05 14:36:43 +01:00
AxxioandAntigravity 6cd586555f launcher: fix top bar action button clipping and invite field overflow (fixes #9902)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 14:36:15 +01:00
Ludovic Marie 42aad46379 Merge pull request #9733 from martipops/controller/settings-screen
🤖 Agent: The new pages go through the same English-as-key lang::get scheme as graphics_ui, and the detected glyph style now follows the named device's vendor before that pad has been used. fmt, clippy and the controller and settings tests pass merged with main.
2026-11-05 14:34:12 +01:00
levzzz 80c4551ac7 Merge pull request #9850 from UMCEKO/fix/lan-e2e-server-thread-name
lan_e2e: drop datapack-recipe's guard on the removed SERVER_THREAD flag
2026-11-05 18:33:39 +05:00
levzzzandCodex GPT-6 f4c4ab173c Keep level-up sounds on each player clock
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 18:33:16 +05:00
masa-masa-0314andZCode 85b037f347 Realms: the worlds tab with slot switching and world options
The configuration screen's Worlds tab: the four slots as vanilla lays them out (the
active one 80 square, the others 50, 16 apart), each with RealmsWorldOptions.getSlotName's
name; a click on another non-empty slot asks first, then RealmsClient.switchSlot
(PUT worlds/{id}/slot/{slotId}).

World Options opens RealmsSlotOptionsScreen: the world name (the default one stored as
empty), difficulty and game mode cycles, force game mode and spawn protection 0-16, with
vanilla's disabled states (a hardcore slot locks difficulty, game mode and force mode; a
world that is not NORMAL keeps its stored spawn protection and force mode). Done saves
through RealmsClient.updateSlot (POST worlds/{id}/slot/{slotId}, RealmsSlotUpdateDto's
JSON) and the tab reads the detail again.

RealmsServer reads the detail's slots (RealmsSlot, RealmsWorldOptions, RealmsSetting and
isHardcore). Slot images come with the pack-icon-slot PR; until then a slot is its frame
and name. The spawn-protection slider takes a click on its track: this screen has no drag
event yet.

Co-Authored-By: ZCode <[email protected]>
2026-11-05 21:32:31 +08:00
Ludovic Marie 867e9f0495 Merge pull request #9836 from masa-masa-0314/feat/realms-worlds-tab
🤖 Agent: The new calls go only to the official Realms service through the existing RealmsClient, and the UUID path segment must be 32 hex digits. Main is merged in to clear GitHub's criss-cross conflict after #9714 landed.
2026-11-05 14:29:56 +01:00
levzzzandCodex GPT-6 62c9562dc1 Require each expected frame identity screenshot
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 18:29:44 +05:00
Horizon agentandClaude Opus 5.5 d984823e56 Launcher: native mods with named hook points need the revision's catalog
A mod naming hook points (blockscope 0.3.1: hooks 2, all five by name) beside
one pinning 5e391eb (shulker_peek 0.1.0) built on the pin: 5e391eb already had
hook points v2, but native-hooks.json only came in d3e0774, still v2. So
horizon-mixins stopped with E_HOOK_CATALOG ("a named hook needs
native-hooks.json at the workspace root"), on every platform.

A revision now fits such a mod only when its native-hooks.json lists each named
hook point and version the mod injects at; otherwise the build moves on as it
does for missing hook points (the mods' tested commit, else the game's), and
the pinned mod waits for Build anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:29:02 +01:00
Arslan ArmanulyandClaude Opus 5.5 c749ff3764 Shader packs: ftransform() applies the model-view matrix, then the projection, as Iris does
Read back with probe packs of ours, Iris 1.11.7's ftransform() has the bits of
gl_ProjectionMatrix * (gl_ModelViewMatrix * gl_Vertex) at every vertex our
probes read. Horizon multiplied the vertex by the product matrix formed once
per draw, which rounds differently in the last bits. With it, on a stack with
the other open #8464 PRs, a strip of water tops at ow_birdseye_noon read the
shadow map up to 73 units of 2^-24 from Iris's coordinates, and 35,632 and
50,761 of the texels our probe reads back differed from Iris's. How last bits
move a whole strip is not established.

The GPU test reads ftransform() back from a gbuffers and a shadow program with
matrices that tell the two orders apart (one rounding against two).

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:26:43 +05:00
Horizon agentandClaude Opus 5.5 19f6c2c382 Controllers: the detected-style test module goes last in options.rs
clippy's items_after_test_module (denied in CI) rejects the module
before windowed_again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:26:29 +01:00
degepieandClaude Opus 5.5 a85fb7c819 Advancement predicates read structure tags
A location predicate whose `structures` named a tag
(`#minecraft:shipwreck`, or a data pack's own `#pack:village`) never
matched: the predicates' tag lookup had no `worldgen/structure` case and
the world's pack tags didn't hold that registry. The world's tables now
load the structure tags with the packs' over the game's own, like the
entity type, biome and damage type tags, and a world without packs
reads the game's own structure tags (loaded once).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:26:17 +05:00
degepieandClaude Opus 5.5 ab4f48abfe Potions: drinking one awards the used statistic and fires consume_item
Consumable.onConsume awards the item's used statistic and fires the
consume_item trigger for a server player before the consumable's listeners
run and the stack shrinks. A potion and an ominous bottle finish through
finish_drinking, which did neither; food, honey bottles and milk go
through finish_consuming, which already does both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:26:14 +05:00
Ludovic Marie 38609b7b1f Merge pull request #9848 from TheMrZZ/fix/piglin-live-anger-memory
🤖 Agent: The angry_at memory is written only while this live guarded-block event owns it, and removed once the anger ends, so a stale raw Brain value cannot come back. Main is merged in, with both sides' recorded ops and fixtures (mob_anger, menu_state, map decoration) kept.
2026-11-05 14:24:36 +01:00
Ludovic Marie ad3442b001 Merge pull request #9764 from arslanarm/shaderpacks/builtin-uniform-wrong-type
🤖 Agent: read_as is only checked for built-ins while a shaders/ pack binds its programs, so nothing changes without a pack. Main is merged in, and wrong_type_uniform_tests now sits beside main's atlas_name and unknown_sampler test modules.
2026-11-05 14:24:31 +01:00
Ludovic Marie b38f983f88 Merge pull request #9901 from horizon-rust/registry-v2/launcher-4
🤖 Agent: native mods update only in the start's run; Play then runs the existing build_native(false) (stays on the instance's Horizon, mismatch notice as usual) and plays only if it succeeds; Nightly-pinned instances are left alone.
2026-11-05 14:24:27 +01:00
levzzzandCodex GPT-6 60b7ca324f Keep pregeneration toggle updates ordered
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 18:24:02 +05:00
levzzzandCodex GPT-6 69c5efdd99 Refresh native mod fit after a rebuild
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 14:23:36 +01:00
VaspyyyandClaude Opus 5.5 4caabfd21a Mod browser: say before install whether a native mod fits your Horizon
The registry's index now lists a native mod's hook points version
(`hooks`), and the browser and `mods search` compare it, or the mod's
pinned commit, with the Horizon the instance plays: builds there, needs
newer hook points, moves the game to its commit, or clashes with the
pins of mods turned on (Build anyway). Closes #9860.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:23:36 +01:00
MSedgeMC 7cb6fc552f port_map: WitherRoseBlock.animateTick is block_animate_tick
The row records where the smell test sits now: three smoke puffs about the
centre of the pick shape, moved by the flower's per-column model offset.
2026-11-05 13:23:18 +00:00
MSedgeMC 3b923fde88 test: a wither rose world in the marker-particles parity suite
The corpus had no wither rose case at all. Add a world that is all wither rose,
run the whole ClientLevel.animateTick loop over it and compare every smoke puff
by exact position bits in call order: twenty-seven cases, three seeds and
twenty-four at random ones. The probe now supplies the flower's per-column model
offset the way the client's mesh tables do (a case without it would compare a
shape the game never shows). 2,070 cases and 402,440 output lines become 2,097
and 456,747.
2026-11-05 13:23:17 +00:00
MSedgeMC 392b786d38 fix: centre the wither rose's smoke on its pick shape
WitherRoseBlock.animateTick reads its centre off getShape(state, level, pos,
CollisionContext.empty()), a flower's own box whose bounds centre is 0.5/0.5
before the block's per-column model offset. AnimateContext::shape_y answers with
the collision shape, which is empty for every flower, so the centre came out as
0 and the whole smoke column sat half a block towards the block's north-west
corner. Take the centre from the pick shape's boxes (BlockFacts::outline) and
keep the model offset on top of it.
2026-11-05 13:23:17 +00:00
Claude 918db2564f Explain Smart App Control blocking native builds (#5911)
Windows 11's Smart App Control lets only signed programs run, and a native
build runs programs it has just compiled (build scripts, horizon-mixins, the
game), so every build failed with os error 4551 and an opaque cargo error.

The launcher now checks Smart App Control before downloading anything
(VerifiedAndReputablePolicyState 1) and says how to turn it off; a build
that hits os error 4551 anyway leads its error with the same explanation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Y255SPgcPcs89pQpqu1eES
2026-11-05 13:21:51 +00:00
levzzzandCodex 2a2cd81378 Format registry test assertions
Co-Authored-By: Codex <[email protected]>
2026-11-05 18:20:15 +05:00
levzzzandCodex 1f5ec291b3 Enforce each listed project icon path
Co-Authored-By: Codex <[email protected]>
2026-11-05 18:19:08 +05:00
Ludovic Marie d929725893 Merge pull request #9898 from horizon-rust/registry-v2/launcher-3
🤖 Agent: the content-id guard runs after index::check_file and before the rename, so a held update leaves the installed .hmod and its list entry untouched; the replaced file is kept until the next start for rollback.
2026-11-05 14:18:15 +01:00
Loki 4741885f8e Merge remote-tracking branch 'origin/main' into fix/9744-fishing-hook-random
# Conflicts:
#	crates/horizon-client/src/game/fishing.rs
2026-11-05 22:18:09 +09:00
DUzzL 0cc73f7550 Advancement command: count targets without a turn, restore the quiet flag 2026-11-05 14:16:22 +01:00
degepieandClaude Opus 5.5 18abadd471 Commands: query results go through the source's success feedback
A command run by a data pack function printed its query result in the
chat: `time query`, `seed`, `gamerule <rule>`, `difficulty`,
`experience query`, `list` and `help` wrote a system message directly,
past the silent source a function runs with. They now take
`feedback_unbroadcast` (`CommandSourceStack.sendSuccess` without the
broadcast to operators, as TimeCommand, SeedCommand, GameRuleCommand,
DifficultyCommand, ExperienceCommand, ListPlayersCommand and
HelpCommand send them): a silent source says nothing, a command block
keeps the line as its last output, and `send_command_feedback` off hides
it from a player. The returned value still reaches `execute store`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-05 18:16:15 +05:00
levzzzandCodex GPT-6 2896e005b0 Fix detected glyphs for the displayed controller
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 18:15:31 +05:00
SenjinandClaude Opus 5.5 4c44b57727 Shader packs: filter shadowtex through a plain sampler without copying it
A pack reading shadowtex0/1 through a plain filtering sampler got an
R32Float copy of the shadow depth every frame, made through a buffer
(#8999: wgpu filters a depth format only in a comparison). At a large
shadowMapResolution that is most of a frame: 8192² costs 512 MiB (the
buffer and the copy) and about 10 ms a frame on an RTX 2080.

The translator now sends texture calls on shadowtex0, shadowtex1,
shadow and watershadow declared as sampler2D to helpers that gather the
four texels and blend them bilinearly, as the filtering sampler did,
reading the depth itself. A uniform of ours, hz_depth_linear, says per
name whether it filters (shadowtexNNearest unset) or takes the nearest
texel. The pack's own functions given one of them as a sampler argument
carry its flag in an extra parameter (-1 for any other texture, which
then reads with the built-in the pack called). The copies, their buffer
and textures are gone, and devices without FLOAT32_FILTERABLE now filter
too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:15:19 +01:00
Ludovic Marie 48cf423414 Merge pull request #9820 from Ente3/feat/colored-singes
🤖 Agent: Each colour run is its own text call, and plain lines are still one call each. Plain lines now skip the component round trip every frame, and edits keep the old line's root style as SignBlockEntity.setMessages does.
2026-11-05 14:14:14 +01:00
Horizon agentandClaude Opus 5.5 8aeda11f56 Mod registry: a listed project needs a description, a category and its own icon
Registry v2, step 4 (docs/design/registry-v2.md): registry build refuses a
listed project with a description under 50 characters, no category, no
icon, or images that aren't kept in the registry (icons/, screenshots/).
The site and the launcher then load images only from the registry's host.
Lands once every entry on horizon-mods main is backfilled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:12:39 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 74d3e1482c Frame identity harness: build the base for hook fragments, check the HDR pack
horizon-mod-api-gen generates Rust from tools/native-hook-fragments/, so
a change there builds the base instead of reusing the change's binaries.
Golden Hour with HDR on is checked against hdr_toggle's first shots (the
same settings without the pack), sidecars included, so a pack that only
fails under HDR doesn't pass as identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:12:08 +01:00
levzzzandCodex GPT-6 9ee022d926 Fix map marker name depth
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 18:11:05 +05:00
Horizon agentandClaude Opus 5.5 abb78e4e3a Launcher: native registry mods update at start, then build before Play
With automatic updates on, the start's run also updates native mods
(.hnmod), except for an instance that keeps a Nightly or a native build
made on one. The next Play of an instance whose native mods moved on
builds its game again first (the Mods page's Build), then plays it.
Play's own run never updates native mods: no build would follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:10:40 +01:00
Horizon agentandClaude Opus 5.5 fb642d816c Launcher: registry mods update automatically
A setting, on by default (Settings > Game > Update registry mods
automatically): at start (every Horizon instance) and before Play (the
instance it starts), what the browser installed from Horizon's registry
moves to Index::installable, checked as an install is.

Never updated automatically: a modpack's profile (its .hpack pins each
mod by SHA-256), native mods (they need a build), data packs, a version
for another Minecraft, anything older than what is installed. A content
mod whose new version drops a block, item, entity, effect, menu or
creative tab waits for the player, with a notice naming them. The file
an update replaces is kept in mod-browser/replaced/ until the next
start, which puts it back if the update was cut short.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:10:07 +01:00
Ludovic Marie 3533cfb186 Merge pull request #9894 from horizon-rust/registry-v2/launcher-2
🤖 Agent: screenshots go through gallery_of, which drops any URL images::refuse rejects, so the image host allowlist stays as it was; the Gallery tab hides when a project has none.
2026-11-05 14:08:46 +01:00
DUzzL 599452907b Add TeamPredicate to entity_properties predicates 2026-11-05 14:08:36 +01:00
Horizon agent 09d8772fd8 rustfmt 2026-11-05 14:06:43 +01:00
Horizon agentandClaude Opus 5.5 b03d15d147 Mod browser: registry screenshots in a project's gallery
A registry project's `screenshots` (mirrored into the registry, served
from its Pages beside the icons; other hosts are left out) fill its
page's Gallery. The Gallery tab shows one image large, a click on it
shows the next, arrows and a strip of thumbnails pick another; a project
without images has no Gallery tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:06:43 +01:00
Horizon agentandClaude Opus 5.5 7899c12c4c Merge origin/main into extras-pregen-toggle
Keeps the pregen extra next to main's solo cosmetics entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:06:25 +01:00
Arslan ArmanulyandClaude Opus 5.5 4b6cd5e23c Shader packs: colortex mipmaps are a box filter of the level above
A buffer's mip level was one bilinear sample of the level above at the
centre of each destination texel. Along an odd side (45 rows of a
1280x720 buffer's fourth level, 11 of its sixth, 5 of its seventh) that
sample lands near one texel's centre instead of between two, so a small
bright spot was skipped: a probe pack of ours lost a 26x22-pixel square
of 10.0 entirely from level 7 down, where Iris 1.11.7 kept it down to
its 1x1 level, the buffer's mean.

Each level is now a box filter of the level above (OpenGL 4.6, 8.14.4,
recommends one): two texels along an even side, and three along an odd
side of 2d + 1 texels, weighted d - i, d and i + 1 over 2d + 1, so every
texel above counts as much. Our probes' Iris values fit this within
0.1 % at levels 6 and 7 and 1.5 % at level 10, in all 16 colortex
buffers, in 10 formats. Iris's level 5 of that buffer fits it in rows
but not in columns (undiagnosed).

Fixes #9891
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:05:56 +05:00
Horizon agentandClaude Opus 5.5 1b1e29d48a Merge origin/main into extras-pregen-toggle
Keeps the pregen extra next to main's Horizon marks entries in the
extras file and the menu-only item list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:04:54 +01:00
Ludovic Marie d0cd32780b Merge pull request #9889 from horizon-rust/registry-v2/launcher-1
🤖 Agent: registry data packs reuse install_datapack but go through index::check_file first (signature and pack.mcmeta/data/), so they get the registry's full check, not just the SHA-256 Modrinth files get.
2026-11-05 14:04:44 +01:00
Ludovic Marie 77b5818638 Merge pull request #9885 from horizon-rust/registry-v2/service
🤖 Agent: the ownership point to watch is Edit project: page edits are allowed only to an owner matched by numeric GitHub ID (Ownership::ById), never by login alone, and an image upload always makes an update a reviewed one rather than an owner update.
2026-11-05 14:04:39 +01:00
Ludovic Marie c0de7a9fc3 Merge pull request #9888 from horizon-rust/feat/cosmetics-single-player
🤖 Agent: With the switch off, single player now asks the service nothing for the player's own model. Before, it asked /v1/worn about the offline UUID, which the service never knows. The cost when off is one bool test, on a path that runs only for the player's own model.
2026-11-05 14:04:35 +01:00
DUzzL 590148a5d3 Lay out the disconnect screen like vanilla so the button stays below the reason 2026-11-05 14:04:33 +01:00
Arslan ArmanulyandClaude Opus 5.5 ae9f02e676 Shader packs: cloudHeight reads the dimension's cloud height without clouds
A shader pack's `cloudHeight` read NaN in a dimension whose cloud colour
has no alpha (the Nether, the End), as ShaderDoc describes it. Probe
packs of ours read 192.33 there in Iris 1.11.7, in every program kind
they read it from (gbuffers_terrain, gbuffers_textured_lit,
gbuffers_skybasic, shadow, prepare, deferred, composite and final).
`cloudHeight` is now the dimension's `visual/cloud_height` with or
without clouds: `EnvironmentAttributes.CLOUD_HEIGHT`'s default, 192.33,
where the dimension doesn't set it.

Fixes #9890
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 18:04:29 +05:00
Horizon agentandClaude Opus 5.5 8bbedb4d4d Merge origin/main into fix/villager-stand-up
Keeps the villager wake cases and #9775's used-stat cases as two
separate streams in the use_item_on harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:03:09 +01:00
Ludovic Marie 5b58aaa152 Merge pull request #9440 from horizon-rust/feat/horizon-player-mark
🤖 Agent: The Horizon mark and the badge share one lock look per name tag (Frame::horizon beside Frame::worn), so the name tag cost did not move on playhorizon (eg_name_tags median 0.027-0.029 ms with the mark on and off). The Founder gem is the service's texture (horizon-cosmetics#8), so deploy that right after this merges.
2026-11-05 14:02:09 +01:00
LokiandAntigravity c2b3c2d103 fishing: draw hook tick, bite timers and catch from hook's own random (fixes #9744)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 22:01:42 +09:00
Horizon agentandClaude Opus 5.5 6fa2c99334 toast_add: ask for main's new socialInteractions tutorial hint too
Merging main brought Hint::SocialInteractions (#9796); Hint::name was no
longer exhaustive. The first-server hint is now asked like the others, and
is still spent when cancelled, as vanilla sets joinedFirstServer either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:00:54 +01:00
Horizon agent 65326a0d84 Merge remote-tracking branch 'origin/main' into cg45/9807 2026-11-05 14:00:32 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 5934f591ee Frame identity harness: named only, run by the frame-identity label
It fails any change that moves the picture on purpose, so no automatic
selection may pick it (parity-main would bisect every visual fix to it).
A pull request that must leave the picture alone carries the
frame-identity label, which parity.yml reads as an input and then runs
the harness by name after the selected ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:56:00 +01:00
Florian ERNSTandCodex 41acd427ee Refresh main after selector validation
Co-Authored-By: Codex <[email protected]>
2026-11-05 13:53:36 +01:00
Horizon agent 425321db3d rustfmt 2026-11-05 13:51:31 +01:00
Florian ERNSTandCodex 8e7bb2dc6d Merge main and preserve both command fixture dispatches
Co-Authored-By: Codex <[email protected]>
2026-11-05 13:48:06 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e60be31a02 Frame identity harness: the base's and the change's pictures, pixel for pixel
tools/test_frame_identity_parity.py builds the base (BASE_SHA, else the merge-base
with origin/main) in a worktree and target folder of its own under
.local/frame-identity/, draws fixed scenes offscreen with it and with the change
under HORIZON_STILL=1, and requires identical decoded pixels, writing a diff image
where they differ: HDR off (day over water, night with light sources, stained
glass, the Nether), Golden Hour (API 0.1) in SDR and with offscreen HDR output,
and HDR turned off in a running game. Each session checks it drew what it is for,
so a pack that fails to load, or HDR that stays off, in both builds can't pass.
Lavapipe where it is installed, else the same adapter for both builds. A change
that touches no file a game binary is built from reuses the change's binaries.

The differential screenshot test of docs/design/hdr-linear-light.md (Tests 1, 2),
first of the shader API 0.2 series (#9861).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:45:58 +01:00
AkirabaneandClaude Opus 5.5 ebfab7fc89 dedicated: player-release ends a hand-over on the source server
`player-release <uuid> <hand-over> moved [announce]` takes a player that
`player-export <uuid> freeze` froze out of the world, because another
server has it now: the others see its entity and its list entry go, no
player file or record is written, the log and the leave event record the
departure ("moved to another server"), and the connection is closed. The
"left the game" line is neither sent nor logged unless `announce` is
added. `player-release <uuid> <hand-over> abort` lifts the freeze.

Both name the hand-over id the export returned, and are refused for a
player who is not in a hand-over, for another hand-over than its current
one, and for a player who died while frozen. Once the freeze has run out
the hand-over is over and a late `moved` is refused: the player has
played on since, and the target's record is older. So a stale or
repeated release cannot remove someone who is playing again.

A frozen player takes no damage (Abilities.invulnerable, which the void
and /kill still pass) and picks up no items, arrows or experience orbs,
so its record stays true until the release. A player who dies while
frozen all the same ends the hand-over with a warning.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:44:53 +01:00
Riccardo Guerriero 27f2e4ebe6 Merge origin/main into fix/placement-vibrations 2026-11-05 13:42:40 +01:00
AxxioandAntigravity b1e666ae8c game: render marker names under named banners on maps (fixes #9781)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 13:42:03 +01:00
Ludovic Marie dfba9cab74 Merge pull request #9882 from horizon-rust/fix/tab-list-cape-failure
🤖 Agent: queued. tab_list passes 1000/1000 on this head on top of main 3d9ad5214 (playhorizon); main has 964/1000 (#9834). The change brings back vanilla's SkinManager.registerTextures allOf rule: a cape or elytra that fails gives the default skin. The retry and the decode-before-cache from #9420 are kept.
2026-11-05 13:41:41 +01:00
Horizon agentandClaude Opus 5.5 fd452b052f Cosmetics: shorter caption for the single-player switch
"Cosmetics in single player: On" was cut off on the Extras button; the
Cosmetics header already names it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:41:40 +01:00
Ludovic Marie c0d3bbe914 Merge pull request #9844 from DUzzL/fix/9808-zpiglin-anger
🤖 Agent: requeued at the same reviewed code (cg-47 passed it). I pushed an empty commit only because GitHub had lost this PR's head ref.
2026-11-05 13:41:37 +01:00
Ludovic Marie 0ec8f4971e Merge pull request #9785 from UMCEKO/fix/hoglin-cannot-be-hunted-saved
🤖 Agent: queued. `python3 tools/parity.py save_roundtrip` on this head merged into main 3d9ad5214 (playhorizon): PASS, 0 unexplained differences; the hoglin's `CannotBeHunted` DIFF from the nightly audit (#9833) is gone, the 35 ignored categories are the same as on main. Matches `Hoglin.addAdditionalSaveData`, which writes the boolean unconditionally. Also closes #9833.
2026-11-05 13:41:33 +01:00
Horizon agentandClaude Opus 5.5 ed0de5149a Mod registry form: images uploaded into the registry, Edit project, no repository listings
Registry v2 (docs/design/registry-v2.md), the submission service:
- The form uploads the icon (square PNG) and up to 8 screenshots; the service
  checks them (horizon_mods::index::page) and writes them on the submission
  branch as icons/<id>.png and screenshots/<id>/<n>.<ext>, so the site and the
  launcher load them only from the registry. An icon address is refused.
- Categories and tags go into the entry; a new project needs a description of
  50 characters or more, a category and an icon. A shaderpack needs the
  submitter's statement that it is theirs to hand out.
- "Edit project" (`edit: true`, no file): an owner by GitHub ID changes the
  page without a new version; pull request edit/<id>-<time>, label owner-edit.
- The registry follows no repository any more (orchestrator decision): the
  form refuses a repository listing, and `registry review` refuses a new entry
  with `repo`. Every version is a submitted, pinned file.
- A retry takes up its own branch when every commit on it is the form's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:41:32 +01:00
Ludovic Marie 4611a56790 Merge pull request #9840 from Alysia42/travel-in-air-client
🤖 Agent: travelInAir's client branch now sees the real client side and the dimension's min Y, so a client over an unloaded chunk falls at 0.1 * 0.98. The new unit test runs with prepared resources on playhorizon.
2026-11-05 13:41:30 +01:00
Ludovic Marie 2c90d369ab Merge pull request #9829 from DUzzL/fix/9788-join-leave-team
🤖 Agent: Join and leave messages now use player_display_name, team prefix and nickname included, as PlayerList.placeNewPlayer's getDisplayName does. Chat already went through it.
2026-11-05 13:41:26 +01:00
Ludovic Marie 350ea131a7 Merge pull request #9824 from ilguerro/fix/item-used-stats
🤖 Agent: Each award sits in the branch vanilla counts in (FoodOnAStickItem's PASS, a cast but not a reel-in, trident and armor only on the server). The use_item_on harness's 400 new cases pin them.
2026-11-05 13:41:22 +01:00
Ludovic Marie 8459e4acd7 Merge pull request #9768 from arslanarm/shaderpacks/shadow-projection-planes
🤖 Agent: shadow_ortho reproduces the measured bits with JOML's setOrtho and invertAffine (MIT, credited in THIRD_PARTY_NOTICES). Horizon's own packs keep their depth range, now written as explicit planes.
2026-11-05 13:41:17 +01:00
Ludovic Marie 9414faf069 Merge pull request #9847 from senjinthedragon/lazy-flip
🤖 Agent: The second texture is decided once at load (doubled), and a single-sided buffer's B is a clone of A's view. The clear of the alt side and the alpha-1 start both skip single-sided buffers, so A is never attached twice in one pass.
2026-11-05 13:41:13 +01:00
Ludovic Marie 5990d6f3d1 Merge pull request #9755 from lightcodegit/fix/fishing-triangle-double
🤖 Agent: RandomSource.triangle is mean + spread * (nextDouble() - nextDouble()) in 26.3, and the local copy drew two floats. The cast and fishAngle now take vanilla's draws, and the change merges cleanly with #9740.
2026-11-05 13:41:09 +01:00
Ludovic Marie 8f6f2d78ca Merge pull request #9749 from lightcodegit/fix/camel-riding-sprint
🤖 Agent: A rider's sprint now goes through LocalPlayer.isSprintingPossible's vehicle branch (canSprint and local authority), so only camels keep a sprint, and boats and carts stop it as in vanilla. The released-key case is covered by drive_mount reading player.sprinting.
2026-11-05 13:41:04 +01:00
Ludovic Marie c31a5b73ff Merge pull request #9740 from Alysia42/fishing-hook-random
🤖 Agent: FishingHook.<init> draws the throw's three triangle values from the hook's own Entity.random (a fresh RandomSource), so the level's random no longer advances on a cast. #9755 sits on the adjacent lines and merges cleanly on top.
2026-11-05 13:41:00 +01:00
Horizon agentandClaude Opus 5.5 3a9c721478 Empty commit: make GitHub rebuild this PR's head ref
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:37:25 +01:00
Horizon agent cf368aa50e Merge remote-tracking branch 'origin/main' into cg47/fix-9764
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-11-05 13:37:19 +01:00
Ludovic Marie 20186e4044 Merge pull request #9479 from megabytesme/codex/issue-9407-native-mods-arm64
Build native mods on Windows ARM64
2026-11-05 13:37:13 +01:00
Horizon agent 0dfa7840d1 Merge remote-tracking branch 'origin/main' into cg47/fix-9848
# Conflicts:
#	tools/test_command_exec_parity.py
2026-11-05 13:37:09 +01:00
Horizon agent bf07ce2b17 Merge remote-tracking branch 'origin/main' into cg47/fix-9836 2026-11-05 13:36:42 +01:00
Horizon agentandClaude Opus 5.5 c4b85eaed1 Merge main into #9841 (lan_actions imports)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:34:28 +01:00
Horizon agentandClaude Opus 5.5 a388925037 Merge main into #9765 (both test modules)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:33:56 +01:00
Horizon agentandClaude Opus 5.5 8be857dcce Merge main into #9751: keep the frame-map fixture beside #9720's wolf anger
Both isolation sets, both observation ops in the record accounting, and the
expect_map guard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:33:14 +01:00
Horizon agentandClaude Opus 5.5 fd5dd184f6 Merge main into #9743 (keep both fixture flags)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:32:21 +01:00
Riccardo GuerrieroandClaude Opus 5.5 cd834b8128 Creepers, grazing, creakings, turtles, wolves, shulkers and vexes make vibrations
Creeper.tick's PRIME_FUSE, Mob.ate's EAT, Creaking's ENTITY_ACTION
(activate, deactivate, freezing, a heart-bound hit), Turtle.aiStep's and
Wolf.tick's ENTITY_ACTION, Shulker.setRawPeekAmount's CONTAINER_OPEN /
CONTAINER_CLOSE and an evoker's vexes' ENTITY_PLACE are posted, and the
host forwards them (it dropped all but entity_action and flap).

sculk gains `ignite mN` and `ate mN` actions and four scenarios, run
last since vanilla merges experience orbs by global entity id (439/439;
1/4 of the new ones on main's gameplay code). Unit tests cover the
creaking, wolf and shulker.

Part of #9776

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 13:31:45 +01:00
martipopsandClaude Opus 5.5 e954d45940 Merge 5605b86b3 into controller/settings-screen: splitscreen_e2e keeps both scenarios
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:30:56 -05:00
Ludovic Marie 402fb8b1f4 Merge pull request #9815 from voidsipp/fix/9154-cleanroom-default-fallback-20261105
🤖 Agent: An unsupported initializer such as double(2.0) now leaves its uniform at zero with a note at its file and line, and the program still loads. Supported defaults and conflict errors behave as before.
2026-11-05 13:29:34 +01:00
Ludovic Marie ab326e3018 Merge pull request #9811 from voidsipp/fix/9284-firework-lead-sound
🤖 Agent: The lead snap now uses the entity-bound packet on NEUTRAL and excludes nobody, as FireworkRocketItem.use's playSound(null, player, ...). The ignored game test passes with prepared resources on playhorizon.
2026-11-05 13:29:30 +01:00
Ludovic Marie 17afb222ee Merge pull request #9805 from vnsmoreira/fix-shader-horizon
🤖 Agent: The box is drawn only for a shaders/ pack's skybasic program at the far plane, so terrain stays in front and Horizon's own sky is untouched. The design comes from the public shaders.properties docs, nothing copied.
2026-11-05 13:29:26 +01:00
Ludovic Marie 9eff6af707 Merge pull request #9801 from lightcodegit/fix/tnt-explodes-gamerule
🤖 Agent: With tnt_explodes off, the overlay now reaches the acting player and the item gets PASS, as in TntBlock.useItemOn. Redstone, fire and a finished fuse are gated by the same rule as TntBlock.prime and PrimedTnt.explode.
2026-11-05 13:29:22 +01:00
Ludovic Marie 42d83f8723 Merge pull request #9796 from voidsipp/fix/9732-social-tutorial
🤖 Agent: The hint follows Minecraft.tick's isMultiplayerServer: shown on a real server, or once a single-player world is published, never just because the client uses memory networking. joinedFirstServer is saved so it shows only once.
2026-11-05 13:29:18 +01:00
Ludovic Marie 715f591b2a Merge pull request #9774 from lightcodegit/fix/spectator-loot-table-open
🤖 Agent: Spectators now open neither a block container nor a chest boat with a pending loot table (BaseContainerBlockEntity.canOpen, AbstractChestBoat.createMenu). The boat roll clears LootTable and LootTableSeed from the raw NBT before filling, as unpackChestVehicleLootTable does.
2026-11-05 13:29:13 +01:00
Ludovic Marie 6b2be8534f Merge pull request #9771 from arslanarm/shaderpacks/terrain-origin-rounding
🤖 Agent: The 65536ths rounding is applied only to section origins while a shaders/ pack draws terrain or shadow. Culling, sorting and Horizon's own rendering keep the exact camera.
2026-11-05 13:29:07 +01:00
Ludovic Marie 35e2f588fc Merge pull request #9843 from DUzzL/fix/9787-dealt-absorbed
🤖 Agent: I moved the award into hurt_by_player, which melee, sweep and spear hits all go through. tick_stats runs with the host's statistics, so a guest's absorbed damage went to the server's Game; it now goes to the attacker inside its own as_guest turn. Tenths from other kinds of hits are cleared first, so they never reach the next attacker, and tick_stats no longer walks every mob.
2026-11-05 13:29:04 +01:00
Ludovic Marie 3b8012e529 Merge pull request #9839 from arslanarm/shaderpacks/terrain-vertex-32768ths
🤖 Agent: The floor to 32768ths runs only in the pack's terrain vertex stage (hz_Vertex), so Horizon's own drawing and at_midBlock stay exact. Whole blocks and sixteenths are already on that grid.
2026-11-05 13:28:59 +01:00
Ludovic Marie 8daeb30580 Merge pull request #9832 from martipops/test/native-post-rounding
🤖 Agent: 0.75 maps to 191.25, which no driver can round two ways, while 0.5 maps to 127.5, which is exactly half way. Only the test changes.
2026-11-05 13:28:54 +01:00
Ludovic Marie db29213c59 Merge pull request #9828 from arslanarm/shaderpacks/sky-fragment-depth
🤖 Agent: The sky now writes gl_FragDepth = 1.0, so the depth test still keeps it behind the world, and gl_FragCoord.z stays the geometry's own. One cost: a fragment that writes depth loses early-Z, so a pack's sky program now runs under terrain as well (Iris pays the same, since it draws the sky first). A noperspective depth varying with the old far-plane z would keep early-Z (follow-up #9857).
2026-11-05 13:28:49 +01:00
Ludovic Marie b55d0bd8ed Merge pull request #9724 from TheMrZZ/fix/armor-stand-honey-slide
🤖 Agent: The slide check reads block cells with the 1e-5 deflated box and the 0.4375 + half-width overlap, as HoneyBlock.isSlidingDown does, and markers and NoGravity stands keep skipping it. The cost is a few cell reads per falling stand per tick.
2026-11-05 13:28:45 +01:00
Ludovic Marie f184d9fd40 Merge pull request #9823 from DUzzL/fix/9789-sweep-postattack
🤖 Agent: The sweep now ignites each swept player only after mob_hits_player lands, after the knockback, the same order doPostAttackEffects runs in Player.doSweepAttack. It uses the same ignite lookup as the direct hit.
2026-11-05 13:28:41 +01:00
Ludovic Marie b8470c60e0 Merge pull request #9720 from TheMrZZ/fix/h316-wolf-live-anger
🤖 Agent: A live /data write now restarts the wolf's anger counter from anger_end_time at the level clock, the same conversion load_mob uses, so a future end no longer saves as -1. The resource-backed game test passes on playhorizon.
2026-11-05 13:28:37 +01:00
martipopsandClaude Opus 5.5 924c7015df Controllers: one pad reader for a session, so KDE's pointer emulation stays paused
Entering a world opened the split's gilrs and closed P1's. gilrs opens a pad twice
at once, which inotify can merge into one event, and closes it twice apart. KWin
counts those events to pause its emulation, so its count could reach zero while
Horizon still held the pad, and the left stick also moved the mouse.

The router now takes P1's reader and gives it back. Its reader also gets the extra
SDL rows, which it was missing.

Refs #9730

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:28:34 -05:00
Horizon agentandClaude Opus 5.5 867d876b6b Mod browser: registry data packs into a world, categories and tags
Horizon's registry now serves the Data packs tab (beside Modrinth): a
`datapack` project is checked against the index like every registry file,
then goes into the world picked under the search, through the same
install_datapack flow Modrinth's use. On the registry, Filters lists the
kind's categories (horizon_mods::index::page::categories), search words
also match a project's tags, and a project's page shows its categories.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:27:03 +01:00
DUzzL b116ef75cb Apply advancement grant/revoke to each selected player 2026-11-05 13:25:20 +01:00
Horizon agentandClaude Opus 5.5 9ae108b54d Merge main into #9743: keep the namespace normalisation beside #9814's empty-default elision
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:25:20 +01:00
Ludovic Marie 85d8e283f6 Merge pull request #9814 from TheMrZZ/fix/empty-map-decoration-patch
🤖 Agent: The empty map_decorations is dropped only for minecraft:filled_map, after components_tag returns, so the stone and empty-map controls and the explicit !map_decorations removals still save as before.
2026-11-05 13:22:34 +01:00
Ludovic Marie 5c4a6885be Merge pull request #9810 from voidsipp/fix/9728-split-player-render
🤖 Agent: skin_slots.forget() runs on both renderer swaps (entering and leaving splitscreen), so the kept skin images upload again to the new atlas, the same way they do after a device loss.
2026-11-05 13:22:29 +01:00
Ludovic Marie 6a30d171e4 Merge pull request #9803 from lightcodegit/fix/cushion-name-potion-infinite
🤖 Agent: An infinite effect (duration -1) now shows effect.duration.infinite, as MobEffectUtil.formatDuration does after addPotionTooltip's endsWithin(20) check. A named cushion keeps CustomName in its NBT and on the dropped item.
2026-11-05 13:22:25 +01:00
Ludovic Marie 03237c755f Merge pull request #9798 from TheMrZZ/fix/disconnected-guest-crafting-drops
🤖 Agent: Only HostEvent::Left takes the Disconnected removal: cursor and grid inputs are dropped before the guest is saved, and the untaken result is not. A live close still puts items back in the inventory.
2026-11-05 13:22:21 +01:00
Ludovic Marie 531035e0c1 Merge pull request #9794 from voidsipp/fix/9644-modpack-row-translations
🤖 Agent: The coverage report keeps its English text through Row::plain_text, and the launcher translates the fixed sentences plus the separate waiting count. An older report without the waiting field still shows its own text.
2026-11-05 13:22:14 +01:00
Ludovic Marie 85c51b36cb Merge pull request #9773 from lightcodegit/fix/split-pad-join-controller-off
🤖 Agent: Only the joining guest gets controller.enabled = true, and only when it joined from a pad. P1's own settings are not touched.
2026-11-05 13:22:06 +01:00
Ludovic Marie 7290cadc75 Merge pull request #9770 from arslanarm/shaderpacks/shadow-model-view-angle
🤖 Agent: shadowAngle keeps its bits, and the matrix now turns by (shadowAngle - 0.25) * 360 in f32 (+360 when negative). The test pins Iris's bits at 7297, 7300, 16000 and 7303 with sunPathRotation 30.
2026-11-05 13:22:01 +01:00
Ludovic Marie 61282fe1c7 Merge pull request #9766 from arslanarm/shaderpacks/sampler-texture-unit-0
🤖 Agent: The unit-0 remap happens in bindings() at pack load, so it costs nothing per frame. The real_packs output is unchanged on all 40 packs.
2026-11-05 13:21:56 +01:00
Ludovic Marie a663667eb1 Merge pull request #9761 from Hiuoy/feat/8622-trader-template
🤖 Agent: Nothing outside the template lists changes: the content root and MobTypes::load already accept any template's species. The new test checks that a mod trader saves the same keys as a vanilla trader.
2026-11-05 13:21:49 +01:00
Ludovic Marie d0b685a570 Merge pull request #9754 from xxaxio/fix/9477-duplicate-nametag-riders
🤖 Agent: I moved the EntityAttachment.NAME_TAG doc comment back onto name_tag_at (collect_vehicle_ids had been inserted between it and its function). The vehicle set is built once per frame and stays linear in the entity count.
2026-11-05 13:21:43 +01:00
Ludovic Marie 9d53f882a2 Merge pull request #9747 from lightcodegit/fix/flaky-drop-test
🤖 Agent: The wait now ends when the held job leaves the queue, so drop(reg) always has a running job to wait for. The 10 s bound fails with a message instead of hanging.
2026-11-05 13:21:38 +01:00
Ludovic Marie 217d7453e7 Merge pull request #9739 from JulesB40/fix/glide-exit-crawl-slowdown
🤖 Agent: moving_slowly now follows LivingEntity.isVisuallySwimming, which also counts a FallFlying pose with fall_flying off. The 0.0196 against 0.00588 acceleration in the body is exactly that 0.3 factor.
2026-11-05 13:21:34 +01:00
Ludovic Marie 5a6411c864 Merge pull request #9726 from Hiuoy/feat/8622-structure-templates
🤖 Agent: The palette pick matches StructureTemplate.getRandomPalette with settings.getRandom(pos) (a LegacyRandomSource seeded with Mth.getSeed), and ids with a . or .. part are refused before template_entry reads the world's generated folder.
2026-11-05 13:21:29 +01:00
Horizon agentandClaude Opus 5.5 8cff8f8373 Breed xp test: tame both llamas (AbstractHorse.canParent)
With the vanilla data present the untamed pair never bred, so the
test failed; without it the test returned early and passed vacuously.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:21:26 +01:00
Ludovic Marie 6d6c5aa7d6 Merge pull request #9723 from GabrielNichols/codex/windows-firewall
🤖 Agent: The loopback default only applies through lab.game_env and the HORIZON_TEST_LOOPBACK=1 variable, so a player's LAN keeps binding 0.0.0.0. On Windows the launcher now copies the version folder (minus .local) into run/<home hash>/game on every start; that is a launch-time cost, not a per-frame one.
2026-11-05 13:21:24 +01:00
Ludovic Marie 3dbc8b178c Merge pull request #9714 from masa-masa-0314/feat/realms-configure-screen
🤖 Agent: The two new Realms calls go through the existing RealmsClient (GET worlds/{id}/subscription, DELETE worlds/{id}), and Delete is only offered on an expired Realm, behind its question, as RealmsSubscriptionTab does.
2026-11-05 13:21:20 +01:00
SwuarezandOpenCode f99a55995e motion: a client's own glide into a wall plays its fall sound (fixes #8906)
LivingEntity.handleFallFlyingCollisions's playSound of getFallDamageSound runs on both sides; only the hurt is the server's alone. Horizon pushed the sound event only from the server-side run, and a glider's own travel is client-side (and the server broadcast excludes the glider), so the glider never heard entity.player.big_fall / small_fall.

Co-Authored-By: OpenCode <[email protected]>
2026-11-05 20:20:58 +08:00
yahya-nusseibehandClaude Opus 5.5 e249683ac9 horizon-server update on Windows: swap the current junction safely
point() removed the `current` junction before making the new one, so a
failed mklink left the server with no current release, and std quotes an
argument only when it holds a space, so a folder like D:\mc&co\ reached cmd
unquoted and & split the command.

- The new junction is made first as current.switching; the old one is
  removed and the new one renamed into place; if that rename fails the
  old junction is put back.
- mklink runs as `cmd /D /S /C "mklink /J "link" "target""` with
  raw_arg, both paths quoted; paths holding % or " (which keep a meaning
  inside quotes) are refused with a message.

- Test (Windows only): real junctions in a folder named with `&`:
  current moves v1 -> v2 and reads each release's file, the old release
  is untouched, no current.switching is left, and a release whose path
  holds % is refused with current unchanged.

Compiled, linted (clippy -D warnings) and tested on GitHub's
windows-latest runner: all 146 horizon-server tests, the two junction
tests among them. The Unix build and tests pass too.

Split from #9129.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 14:20:43 +02:00
Horizon agent 9874105f26 Merge remote-tracking branch 'origin/main' into cg48-9479
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 13:20:05 +01:00
Ludovic Marie 31b11c7c37 Merge pull request #9871 from horizon-rust/registry-v2/schema
🤖 Agent: the compatibility point is the extension check: released launchers refuse the whole index when a version's extension mismatches its kind, which is why .zip shader-folder packs are a new 'shaderpack' kind instead of riding on 'shader'; all new entry fields stay optional until HORIZON_REV moves.
2026-11-05 13:17:45 +01:00
Horizon agentandClaude Opus 5.5 13eaff707e rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:16:16 +01:00
Horizon agentandClaude Opus 5.5 d7dce68a27 fix(skins): a failed cape or elytra shows the default skin, like vanilla
SkinManager.registerTextures joins the skin, cape and elytra downloads with
CompletableFuture.allOf, so any one failing (or a URL with no hash) fails the
profile's PlayerSkin and createLookup shows the default skin. #9420 kept the
skin when only the cape failed; tab_list caught it (36/1000 lists, #9834).
The retry of failed downloads and the decode-before-cache from #9420 stay.

Fixes #9834

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:14:59 +01:00
Arslan ArmanulyandClaude Opus 5.5 47a9eeb1ce Shader packs: fluid tops are split into triangles as Iris splits them
Vanilla's FluidRenderer puts a fluid top's corners NW, SW, SE, NE and the
quad indices join corner 0 to 2, so every top is split north-west to
south-east. Our probe packs' readings of Iris 1.11.7 fit this rule: a top
is split along the diagonal whose corners differ more in height, and
north-east to south-west when its north-west and south-east corners are
level. It is a fit, not Iris's code. Block faces keep vanilla's split in
Iris too.

Under a shaders/ pack (Mesher::shader_pack, any loaded pack), a top that
Iris splits the other way now starts from its south-west corner: same
winding, its 0-2 diagonal running south-west to north-east. Horizon's own
drawing keeps vanilla's order.

The fit covers every lava top we read, every still top but one, and 317
of 343 water tops; the misses are undiagnosed.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:13:44 +05:00
Ludovic Marie 3d9ad5214d Merge pull request #9804 from voidsipp/fix/9651-launcher-update-guidance
🤖 Agent: Package installs (/usr, /nix/store, /app, not /usr/local) lose Download again and get package-manager advice, and other stuck copies are told to move to Downloads. The path check now runs once per launch instead of on every sidebar draw.
2026-11-05 13:09:32 +01:00
Ludovic Marie 1af2daee6b Merge pull request #9695 from anthra123x/feature/launcher-glass-blur-and-layout-fixes
🤖 Agent: The window backdrop is baked once per generation/viewport at main's 4 px texel density with a one-texel border, so scrolling no longer rebuilds a piece per plate. The scene's animation doesn't bump generation, so idle frames do no extra work. The layout/dropdown changes were reverted (duplicate glass Sort by, design-system radii).
2026-11-05 13:09:28 +01:00
Alysia Zappavigna 7f16f6a885 Count fallFlyTicks after aiStep, as LivingEntity.tick does (fixes #9858) 2026-11-05 23:09:00 +11:00
AxxioandAntigravity 4021e7bc06 lan: preserve vehicle container stand-in while other viewers remain open (fixes #9791)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 13:07:35 +01:00
Horizon agentandClaude Opus 5.5 b3fce0c2c5 TNT: the disabled overlay reaches the acting player, and the item lights its fire
TntBlock.useItemOn sends block.minecraft.tnt.disabled to the player who
clicked (ServerPlayer.sendOverlayMessage), and returns PASS, so flint and
steel or a fire charge then lights a fire as its useOn does. The overlay
went to the server thread's own HUD, which no player sees. The overlay
helper moves from debug_stick.rs to titles.rs for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:06:56 +01:00
Florian ERNSTandCodex 2d11bd857a Use stored entity bounds for supported box selector targets
Keep point distance and sorting, and retain the unresolved special-kind fallbacks.

Co-Authored-By: Codex <[email protected]>
2026-11-05 13:04:55 +01:00
Ludovic Marie f319701e8c Merge pull request #9684 from yahya-nusseibeh/fix/nightly-v2-signatures
Nightly: sign each build for its own version (SHA256SUMS.v2.sig)
2026-11-05 12:57:48 +01:00
TheMrZZandCodex gpt-6.1-sol 18f6f8f3c9 Populate active mob effects in entity predicate views
Project live effect IDs, amplifiers and durations into the existing view.
Cover named if/unless predicates and actual Game view construction.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 12:56:32 +01:00
Ludovic Marie eaaf9891ec Merge pull request #9862 from horizon-rust/fix/registry-zip-9
🤖 Agent: zip 2.6.1 was only the registry's test fixture writer (uploads already went through horizon-mods' 9.1.3); the new pre-check runs zip 9's validate_header_consistency, which by_name/by_index_raw skip by default, so a cloaked local header is refused before any kind's reader opens the upload.
2026-11-05 12:51:37 +01:00
Horizon agentandClaude Opus 5.5 aefbe1defb Mod registry: zip 9 and a check of the upload's zip
zip 2.6.1 (Dependabot #9 critical, #23, #24) was the registry's dev-only
fixture writer; the service already parsed uploads with horizon-mods' zip
9.1.3. Move zip to a normal dependency at 9.1.3, the version horizon-mods
and the main workspace use, so the lock holds one zip.

Before any kind's reader opens an upload, src/archive.rs now refuses a
local header that disagrees with the central directory, duplicate and
case-insensitive duplicate paths, overlapping entries, symlinks and other
special files, encrypted entries, over 100 000 entries and over 1 GiB of
declared uncompressed bytes. Tests craft each case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:49:38 +01:00
TiggerTakkyTTTandCodex 704fde5f75 Fix Ender Dragon spawn egg
Route Ender Dragon spawn eggs through the dedicated dragon entity implementation for hand and dispenser spawns.

Fixes #9799

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-05 18:45:20 +07:00
Horizon agentandClaude Opus 5.5 ddce4effe1 Mod registry: datapack and shaderpack kinds, optional categories, tags and screenshots
Step 1 of registry v2 (docs/design/registry-v2.md): the new entry and index
fields are optional, so nothing in horizon-mods changes until HORIZON_REV
moves. Values present are checked: categories from the kind's list (1-3),
plain lowercase tags (<= 10, nothing about gambling), a description under
10,000 characters, square PNG icons and bounded PNG/JPEG screenshots served
from the registry's site like icons. Data packs (pack.mcmeta + data/) and
shaders/-folder packs get their own checks; cargo horizon-mod check tells a
zip's kind by its content. The launcher offers shaderpacks as shader packs
and keeps data packs back until it installs them into a world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:43:46 +01:00
Horizon agentandClaude Opus 5.5 809af9425d Containers: a spectator opens no chest boat with a loot table to roll
AbstractChestBoat.createMenu returns no menu for a spectator while the
boat's lootTable is set, so the table stays for the first player who
opens it, as BaseContainerBlockEntity.canOpen does for blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:43:36 +01:00
Horizon agentandClaude Opus 5.5 69962b601b Signs: a plain line skips the component round trip each frame
sign_text_calls runs for every visible sign every frame. A line read as
plain text has no saved component, so it is drawn from its text at once
instead of going NBT -> JSON -> component each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:43:13 +01:00
Horizon agent f71ad19eb2 Merge remote-tracking branch 'origin/main' into cg48-9479
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 12:42:34 +01:00
Horizon agentandClaude Opus 5.5 f1f964f510 launcher: keep this PR to the glass backdrop bake
The version cards' radius and spacing and the mods sort dropdown are
back to main's:
- glass already draws its own Sort by beside the search
  (glass_mods_sort), so showing mods_sort_dropdown in glass gave two
  buttons sharing one sort_menu flag;
- the classic theme is square (theme::RADIUS = 0, "Blocks, not rounded
  cards" in docs/design-system.md);
- versions are panels, and panels take radius::PANEL (20).
The two Glass/Float bake branches are folded into one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:41:39 +01:00
Horizon agentandClaude Opus 5.5 fa81d70da4 launcher: resolve the package check once; /usr/local isn't package-owned
package_managed_launcher() canonicalized the update target on every
sidebar draw; the launcher doesn't move while it runs, so keep the
answer in a OnceLock. A launcher an admin copied to /usr/local/bin has
no package manager to update it, so it gets the move-it advice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:41:20 +01:00
Ludovic Marie 46d2467fee Merge pull request #9853 from horizon-rust/registry-v2/design
🤖 Agent: docs only (docs/design/registry-v2.md); the one rollout risk it fixes is the order: optional entry fields and HORIZON_REV bump before any entry uses them, since the pinned cargo horizon-mod has deny_unknown_fields.
2026-11-05 12:41:07 +01:00
Horizon agentandClaude Opus 5.5 c17d14c739 Building boxes: no per-level clone, linear per-turn snapshot
GameLevel borrows guest_boxes outside a guest turn instead of cloning them,
and for_turn only searches the few borrowed ancestors, so a turn costs
O(players) rather than O(players^2) for each guest every tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:40:26 +01:00
Horizon agentandClaude Opus 5.5 36170fa8e6 Award damage_dealt_absorbed during the attack, to the attacking player
tick_stats runs with the host's statistics in place, so draining the mobs
there credited every guest's absorbed damage (and on the server thread, the
single player's own) to the server's Game instead of the player who hit.
hurt_by_player, which melee, sweep and spear hits all go through, now takes
the hit's tenths right after the damage, inside the attacker's as_guest turn.
Tenths left by hits that came another way are cleared first so they are never
credited to the next attacker, and tick_stats no longer walks every mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:40:14 +01:00
Horizon agentandClaude Opus 5.5 b0f885600e Stack on #9772: the new margin test's ShadowCull takes reach: None
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:39:59 +01:00
Horizon agent d7a9dd534e Merge commit 'refs/cg45/pr/9772' into cg45/9838 2026-11-05 12:39:51 +01:00
Riccardo GuerrieroandClaude Opus 5.5 e379b17f5e Placing paintings, frames, armor stands, crystals and minecarts makes a vibration
HangingEntityItem, ArmorStandItem, EndCrystalItem and MinecartItem post
ENTITY_PLACE where vanilla does; an ender chest's first opener posts
CONTAINER_OPEN (ContainerOpenersCounter.incrementOpeners).

On the same paths: each counts ItemStack.useOn's used statistic; a
painting or frame that can't hang there is CONSUME (counted) or FAIL as
in vanilla; paintings pick their variant and placed crystals their time
with their own random, not the level's; a placed armor stand falls onto
what is under it (EntityType.getYOffset with movedUp) after drawing
create's spawn yaw from the level random.

use_item_on gains placement cases (4800/4800; 4474/4800 without these
gameplay changes). The oracle's stand-in level gets hasEntities; the
fixture clears and prints crystals, paintings and frames.

Part of #9776

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 12:39:50 +01:00
Horizon agentandClaude Opus 5.5 cfa94ac87a Crafter trigger: strict box intersection, as AABB.intersects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:39:46 +01:00
Horizon agentandClaude Opus 5.5 1bd8cc1bbf Merge origin/main into fix/nightly-v2-signatures
Resolves the launcher/lang/*.json conflicts by keeping both sides' keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:39:39 +01:00
Horizon agentandClaude Opus 5.5 23f78fc705 Design note: mod registry v2 (kinds, page fields, categories, updates, rollout)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:39:26 +01:00
Horizon agentandClaude Opus 5.5 061bc04462 names: keep the NAME_TAG doc on name_tag_at
collect_vehicle_ids was inserted between name_tag_at's doc comment and the
functions below it, so rustdoc gave the name-tag height text to
collect_vehicle_ids. Move it back above name_tag_at.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:39:10 +01:00
Ludovic Marie ae3ee1724b Merge pull request #9825 from horizon-rust/roadmap/server-admin-done
🤖 Agent: requeued after an empty commit; GitHub had lost this PR's head ref, so the merger couldn't fetch it.
2026-11-05 12:38:46 +01:00
Ludovic Marie 646f390b18 Merge pull request #9837 from martipops/docs/horizon-home-scope
🤖 Agent: Checked against main: HORIZON_HOME (logging::game_home) holds logs and crash reports but also telemetry/consent.json, perf reports, recordings/repro and the DX12 shader cache, while saves and options.txt follow the working directory or --game-dir (game.rs set_current_dir). I pushed 3eeb29cda to name those in the parenthesis so the rule doesn't read as "HORIZON_HOME barely matters".
2026-11-05 12:38:42 +01:00
Ludovic Marie 8343cf2595 Merge pull request #9842 from horizon-rust/fix/gl-surface-4293
🤖 Agent: the Linux fallback reuses vulkan_start_crashed unchanged and only rewrites graphicsApi when it is auto, so an OpenGL start that crashes again in Vulkan can't flip the setting back and forth.
2026-11-05 12:38:38 +01:00
Horizon agentandClaude Opus 5.5 9f7cfcd274 Retrigger: GitHub lost refs/pull/9825/head
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:37:36 +01:00
Horizon agentandClaude Opus 5.5 3eeb29cda1 AGENTS.md: list what HORIZON_HOME holds besides logs
game_home() also keeps telemetry consent, perf reports, recordings and
repro bundles, and the DX12 shader cache, so "logs and crash reports
only" undersold why the scratch HORIZON_HOME matters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:36:02 +01:00
umcekoandClaude Opus 5.5 c926e3e034 lan_e2e: drop datapack-recipe's guard on the removed SERVER_THREAD flag
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:34:17 +03:00
591ce12184 Match a wither's single smoke particle without a redundant guard.
Clippy rejects `if count == 0` on that arm; the pattern `count: 0` is the same match.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-05 12:30:39 +01:00
Florian ERNSTandCodex 3bfb817913 Expose live guarded piglin anger UUID and remaining TTL
Co-Authored-By: Codex <[email protected]>
2026-11-05 12:27:02 +01:00
Horizon agentandClaude Opus 5.5 7dbf60d015 Cosmetics: a setting to show my cosmetics in single player
In single player the window's own server names the player by their
offline profile, which the cosmetics service doesn't know, so the
player's own cosmetics, name tag style and Horizon mark never showed
there (and the game asked the service about the offline UUID for
nothing). Cosmetics in single player (Extras > Cosmetics, off by
default, en/fr/de) asks by the signed-in account's UUID instead. Off,
the own model asks and draws nothing in single player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:26:04 +01:00
Horizon agentandClaude Opus 5.5 28dfa8b8b0 Cosmetics: Show me as Horizon says who can ask about it
/v1/worn takes no sign-in, so anyone can learn whether a listed player
played Horizon in the last 3 hours: the tooltip (en/de/fr) and
docs/cosmetics.md now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:24:34 +01:00
e78b42672d Play the sounds and particles vanilla already shows for fangs, withers, fireballs and level-ups.
A flat fang pitch, a powered wither's missing head particles, silent advancement loot and /xp levels, and hurting projectiles with no bubbles in water are all visible on the server-thread client.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-05 12:23:30 +01:00
Horizon agent fc4d9b758f Merge remote-tracking branch 'origin/main' into cosmetics-gem
# Conflicts:
#	crates/horizon-client-render/src/extras.rs
#	crates/horizon-client/assets/horizon/lang/de_de.json
#	crates/horizon-client/assets/horizon/lang/en_us.json
2026-11-05 12:22:34 +01:00
Horizon agentandClaude Opus 5.5 568150f63e Merge origin/main into fix/nightly-v2-signatures
Resolves the launcher/lang/*.json conflicts by keeping both sides' keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:21:32 +01:00
SenjinandClaude Opus 5.5 840a42cfab Shader packs: a buffer gets its second texture only when a pass flips it
Composite-style programs read a buffer's "main" side and draw into its
"alt" side; gbuffers, shadow and final programs write in place, compute
programs through images. A buffer no prepare, deferred, composite or
shadowcomp program draws into or flips never touches its B texture, so
B is now a second view of A for those buffers. The alt-side clear and
the three-channel start skip such buffers so A isn't attached twice in
one pass. At 3440x1440 this saves 35 to 224 MiB with BSL, Complementary,
Bliss and Super Duper Vanilla, most of it in shadowcolor buffers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:17:39 +01:00
DUzzL c27e16c57b Fix zombified piglin angering at the player when another mob hurts it 2026-11-05 12:15:19 +01:00
Horizon agentandClaude Opus 5.5 08fa43297e Launcher: the Vulkan crash notice in every language table
Each table gets the English text, as the other new strings do until
someone translates them. The gfx.rs comment stays as it was, so the
change touches the launcher alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:01:29 +01:00
voidsip ceb886b00f fix: prevent block placement through other players 2026-11-05 05:00:15 -06:00
Mayonesu0andClaude Opus 5.5 f01e86112c Sculk: wool and carpets dampen landings and steps (Entity.checkFallDamage)
HIT_GROUND carried no affected state, so a jump or fall on wool reached sculk
sensors and shriekers. It now carries mainSupportingBlockPos's state, as
Entity.checkFallDamage does, for players, mobs, items and armor stands.

STEP read the block 0.2 below the feet, which is the block under a carpet. It
now reads the state at Entity.getOnPos, as applyMovementEmissionAndPlaySound
does.

tools/test_server_wool_vibration_parity.py compares both servers through a
vanilla-codec client.

Fixes #8036

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:58:27 +01:00
martipopsandClaude Opus 5.5 80fc892f98 AGENTS.md: HORIZON_HOME is logs and crash reports; the game folder holds the rest
Fixes #9835

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:57:55 -05:00
Alysia Zappavigna 147c8b0a05 Bind travelInAir's client side and min Y for the player (fixes #9817) 2026-11-05 21:56:11 +11:00
martipopsandClaude Opus 5.5 7fbd35def9 Render test: smoothness off the 8-bit rounding boundary
0.5 × 255 is 127.5, which drivers can round either way: NVIDIA gives 127. The test
checks that the surface reads back exactly, so 0.75 (191) checks the same thing.

Fixes #9831

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:55:58 -05:00
Horizon agentandClaude Opus 5.5 1e553f8be7 Launcher: a Vulkan driver crash at the renderer's start switches Linux to OpenGL
On Windows a native crash in the Vulkan driver at the renderer's start
switches Auto to DirectX 12 (#746). Linux had no such rescue: since #4064,
Auto runs Mesa's hasvk on Ivy Bridge and Haswell, which Mesa calls
incomplete on Ivy Bridge (#4293), and a crash inside it would end every
start with no crash report and no fallback. The same switch now sets
OpenGL on Linux (crocus or iris through EGL, which presents to the window
since #2875). A player's own choice stays, so it never loops.

Refs #4293

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:51:42 +01:00
BPMichonandClaude Opus 5.5 97fe6557fe Native hooks: ask toast_add even with no trait mods
mods() lists trait mods only, so returning early when it was empty kept
a bundle injected at the toast_add hook point from ever running.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:48:28 +00:00
Arslan ArmanulyandClaude Opus 5.5 1f70ee39b9 Shader packs: the sky's programs read their own fragment depth
gbuffers_skybasic and gbuffers_skytextured put every vertex at the far
plane (gl_Position.z = gl_Position.w), so gl_FragCoord.z read 1.0 on every
sky fragment. Our probe packs read the sky disc's, the sunset fan's, the
stars' and (we take it) the sun quad's own perspective depth there in Iris 1.11.7.

The vertices now keep their depth, and the translator writes
gl_FragDepth = 1.0 before the pack's main (GbuffersInputs::far_depth), so
the depth test still sees the far plane: the sky shows only where the
world left nothing, and a pack's own gl_FragDepth still wins.

Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:48:26 +05:00
masa-masa-0314 ce63f1d915 Merge remote-tracking branch 'upstream/main' into feat/realms-worlds-tab 2026-11-05 18:48:24 +08:00
Arslan ArmanulyandClaude Opus 5.5 7cc22c56f1 Shader packs: the shadow pass's margin around the view is 1.125 blocks
Over 76 scenes of our probe at fields of view 30 to 110, Iris 1.11.7's
shadow maps keep every section whose box needs a growth of up to 1.1224
blocks to meet the casters' shape, and drop every one that needs 1.1252 or
more, whatever the field of view. With #9707's 1.15, main's maps held 20
columns Iris drops, in 18 of those scenes; with 9/8 they hold exactly
Iris's columns in all 76 (a fit, not Iris's code).

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:45:45 +05:00
Horizon agentandClaude Opus 5.5 2357b6d03b Roadmap: server-admin's last PRs linked, its panel and management-protocol rest split out; sync
server-admin links #9767 and #9748 (merged), so sync ships it once a release has them. The panel's later
features and vanilla's management protocol become server-panel-management (Later). sync also ships
languages (v0.2.65), end-city-banners (v0.2.67) and end-city-loot (v0.2.73).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:44:45 +01:00
Riccardo GuerrieroandClaude Opus 5.5 e0db49e280 Items count toward "Times Used" where vanilla counts them
Vanilla awards Stats.ITEM_USED in each item's own use/releaseUsing/useOn
(or in ItemStack.useOn); Horizon's ports left it out. The bow, crossbow,
trident, fishing rod (cast), eye of ender, boats, carrot and warped
fungus on a stick (no boost), empty map, equippables swapped from the
hand, books, flint and steel / fire charge on TNT, firework boosts and
the spyglass now count it, in vanilla's branches.

On the same paths: fire starters lighting a fire count the use and post
BLOCK_PLACE / BLOCK_CHANGE; sneaking with one on TNT lights a fire
instead of priming it; a placed boat posts ENTITY_PLACE; the spyglass
sound plays at the feet.

use_item_on gains used-statistic cases (4400/4400; 4054/4400 on main's
gameplay code). The oracle's stand-in player gets what these items read.

Fixes #9775

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 11:39:10 +01:00
masa-masa-0314andZCode 4a140d5629 Realms: the players tab with invites, operators and removal
The configuration screen's Players tab: the invited count, a row per player with
Op / Deop / Remove (the removal asks first), and Invite Player behind it.
RealmsInviteScreen: a name field whose blank or already-invited values are refused
before any request, the service's answer deciding otherwise.

RealmsClient gains getOwnRealm (GET worlds/{id}, the slots and players),
invite (POST worlds/{id}, OutboundPlayer's JSON), op (POST worlds/{id}/{uuid}),
deop and uninvite (DELETE worlds/{id}/{uuid} and worlds/{id}/invite/{uuid}), all
with the UUID as UndashedUuid's 32 hex digits. The tab reads again after an
operator change or an uninvite.

The Invite Codes button and its screens, and the player faces, follow in their
own PRs (the faces need the skin-face mechanism).

Co-Authored-By: ZCode <[email protected]>
2026-11-05 18:37:23 +08:00
DUzzL 27a31c391c Award damage_dealt_absorbed when a mob soaks a player's hit 2026-11-05 11:36:40 +01:00
voidsip 87f7e1ccbf test(lod): repair prepared-data fixture state and scheduling 2026-11-05 04:32:42 -06:00
TheMrZZandCodex gpt-6.1-sol 942f199d58 Apply transmute result component patches after inherited components
Retain template patch presence so explicit removals and zero-valued
components override the input without losing inherited data. Preserve
transient map postprocessing through assembly and add powered-crafter
and resource-free regression coverage.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 11:30:26 +01:00
Ente_3andClaude Sonnet 5.5 b45937b8c9 Signs: a line draws in its own colours (#9783)
A sign line kept only its plain text, so a server's {"text":"Online",
"color":"green"} drew in the sign's dark dye colour, as did `§` codes
(drawn as a raw `§`) and hex colours.

SignText now keeps each line's saved component beside the text it showed
when read, so the style survives loading, saving and the network. A line
edited since is a literal in the old line's root style, as
SignBlockEntity.setMessages makes it. List components and translate
components no longer lose their text.

sign_text_calls draws a line as one call per run of one colour: a glyph
takes its style's colour, else the sign's (Font.StringRenderOutput), with
`§` codes resolved as the font does. A line with no style is still the one
call it was. The outline of glowing text stays the dye's dark colour.

Bold, italic, underline, strikethrough and obfuscated text are kept but
not drawn yet.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 11:26:16 +01:00
Arslan ArmanulyandClaude Opus 5.5 9db60745bb Shader packs: terrain vertices sit on Iris's 32768ths of a block
Read back with a probe pack of ours, Iris 1.11.7's terrain gl_Vertex puts
each vertex's place in its section on the 32768th of a block below it:
water tops (8/9 - 0.001) at 29094/32768, plants offset by 13/15 and 0.1 at
28398/32768 and 3276/32768, on all three axes (a fit, not Iris's code).
Horizon passed the mesher's floats on, up to 1/32768 block from Iris's,
enough for a shadow-map coordinate on a texel boundary to fall the other
way. The pack's terrain vertex stage now takes the position down to that
grid; whole blocks and sixteenths are on it already.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:23:36 +05:00
SwuarezandOpenCode ae4a233d33 mobs: breeding experience draws from the parent's random inside the mob_drops check (fixes #8038)
Animal.finalizeSpawnChildFromBreeding draws the orb's 1-7 from the breeding parent's own random, and only when mob_drops is on. Horizon drew it first and from the level's random (llamas always, the other animals from world.random): with the rule off that consumes a draw vanilla does not make, and outside llamas, turtles and frogs the parent's and the level's later draws drift from vanilla.

Co-Authored-By: OpenCode <[email protected]>
2026-11-05 18:22:54 +08:00
voidsip a7769bd31d Keep shader programs with unsupported uniform defaults 2026-11-05 04:21:33 -06:00
martipopsandClaude Opus 5.5 6f682e763e Controllers: X and Y in their places on unmapped Xbox-style Linux pads
The kernel defines BTN_X as BTN_NORTH and BTN_Y as BTN_WEST, and gilrs' default
mapping takes those names literally, so a pad with no SDL row had X and Y crossed.
Sony and Nintendo pads send the same codes by real position and stay as they are.

Closes #9813

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:21:19 -05:00
Florian ERNSTandCodex efa0323824 Omit empty filled-map decoration defaults from saved patches
Co-Authored-By: Codex <[email protected]>
2026-11-05 11:20:57 +01:00
voidsip 8d8875c07c Bind firework lead-break sounds to the gliding player 2026-11-05 04:17:01 -06:00
DUzzL a783be5c83 Drop a Loyalty trident whose owner is dead or spectating 2026-11-05 11:12:20 +01:00
voidsip 4372977f0c Keep Microsoft account buttons inside the glass card 2026-11-05 04:11:34 -06:00
DUzzL 4c9a638a5c Use the team/nickname display name in join and leave messages 2026-11-05 11:11:27 +01:00
rison1337andCodex GPT-6 8de55d4548 Register merged client overlay and Host services as APIs 410 and 411
Generated with tools/stamp_native_hooks.py --only gui_render_post client_services. Leave the other 18 fragments pending while their contracts are reviewed.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 15:11:17 +05:00
rison1337andCodex GPT-6 82011f3c6f Allow selecting reviewed native hook fragments for stamping
Keep unselected contracts pending, preserve merge order within the selection, and reject unknown names before writing.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-05 15:11:17 +05:00
voidsip 5605b86b35 Reupload player skins when switching splitscreen renderers 2026-11-05 04:09:48 -06:00
DUzzL 14dde88a38 Apply Fire Aspect to players hit by a sweep attack 2026-11-05 11:09:16 +01:00
Vinícius MoreiraandCodex c603422a7d Shader packs: cover the horizon below the upper sky disc
The upper sky disc leaves directions near and below the horizon uncovered.
Shader packs keep the buffer's clear colour there, producing a sharp sky
band and pale distant terrain during fog compositing.

Draw an enclosing sky background before the existing disc. Keep its depth
at the far plane and preserve the foreground's colour and depth. Add an
original GPU probe covering 20 camera orientations and foreground occlusion.

References and provenance:
- Iris public format reference for gbuffers_skybasic:
  https://shaders.properties/current/reference/programs/gbuffers/
- Public rendering-uniform and depth-buffer references:
  https://shaders.properties/current/reference/uniforms/rendering/
  https://shaders.properties/current/reference/buffers/depthtex/
- Complementary's public user guidance, changelog and option descriptions.
- Java 26.3 + Iris 1.11.7 and Complementary Reimagined r5.9.3 were run
  as comparison inputs. The diagnostic shaders and Rust changes are original;
  no Iris or Complementary implementation source was opened or copied.
- After implementation and testing, PR descriptions and closure comments
  (including #8753) were consulted to check contribution provenance policy.
  No patch from those PRs was opened or reused. This is not a fresh-context
  or no-documentation implementation.

Validation: fast game build; 16 depth GPU tests including the new regression
on RX 9070 XT; renderer library Clippy with warnings denied; rustfmt.
Visual comparison used isolated game homes, the same world and camera,
32-chunk render distance, noon and clear weather.

Co-Authored-By: Codex <[email protected]>
2026-11-05 07:01:22 -03:00
voidsip e9fe40d2e5 Guide read-only launcher installs to the right update path 2026-11-05 03:57:42 -06:00
makscoder a1a9df806c Cushions keep their name when broken; infinite potion effects say so
Cushion.getCushionItemStackWithData copies the entity's CustomName onto
the dropped item, as the item frame beside it does; the cushion carried
no name at all. Add it, read and write it with the entity, and drop it.

PotionContents.addPotionTooltip appends MobEffectUtil.formatDuration,
which answers the infinite sign for an effect that never ends
(duration -1, from commands or datapacks); brewing's effect_lines only
appended a time past 20 ticks, so such an effect showed none.
2026-11-05 12:50:13 +03:00
Florian ERNSTandCodex 0e043eafae Drop guest cursor and crafting inputs on disconnect
Co-Authored-By: Codex <[email protected]>
2026-11-05 10:47:09 +01:00
Ludovic Marie 696d021699 Merge pull request #9767 from TiggerTakkyTTT/test/server-admin-cli-goldens
🤖 Agent: Queued after the agent fix: stopped-server ban replies now use vanilla's console lines, pinned by the golden test.
2026-11-05 10:40:48 +01:00
voidsip c320caedac fix(packs): resolve Windows folder paths before opening Explorer 2026-11-05 03:39:01 -06:00
Ludovic Marie 77e1ec40fe Merge pull request #9748 from TiggerTakkyTTT/test/server-admin-mcp-goldens
🤖 Agent: Queued. Test-only: it pins the server MCP's tool list, input schemas and error replies as a golden, so a renamed tool or field that breaks agents' calls now fails a test. Pinning that is right here, because MCP clients rely on those names.
2026-11-05 10:38:55 +01:00
Ludovic Marie 024f23e223 Merge pull request #9736 from lightcodegit/fix/splitscreen-reconfigure-race
🤖 Agent: Queued. The new queue drain runs only on split screen's present path and before a new window's surface configures, so single-window play has no extra wait. martipops's two review points are answered in 9fe888b76.
2026-11-05 10:38:51 +01:00
Horizon agentandClaude Opus 5.5 daf0206306 rustfmt lists.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:38:44 +01:00
Horizon agentandClaude Opus 5.5 a0eb1728d9 Server-admin CLI: stopped-server ban replies use vanilla's console messages
lists.rs promises vanilla's console lines; the new ban/pardon/ban-ip/pardon-ip replies used other words.
Now commands.ban.failed, commands.pardon.failed, commands.banip.success/failed/invalid and
commands.pardonip.success/failed from 26.3's en_us.json; goldens updated to match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:38:09 +01:00
makscoder 3b3c064069 Crafter: the recipe trigger uses the player's box, as vanilla's area does
CrafterRecipeCraftedTrigger tests a ServerPlayer's bounding box against
AABB.ofSize(center, 17, 17, 17); Horizon tested the eye within 9.5 on
each axis. Use the player's box and 8.5 to each face, so the reach is
the width and height reaching into the box, not a point 9.5 away.
2026-11-05 12:37:46 +03:00
makscoder c360630194 TNT: the tnt_explodes rule gates priming and the fuse's end
With the rule off, TntBlock.prime leaves the block alone: redstone, fire
and an unstable block breaking go through the level event, which now
refuses like a native mod does (the block comes back); flint and steel
says block.minecraft.tnt.disabled; and a fuse already lit goes out
without exploding, as PrimedTnt.tick does.
2026-11-05 12:36:38 +03:00
voidsip 4bc370f36f fix(launcher): make modpack row explanations translatable 2026-11-05 03:35:48 -06:00
voidsip 0cd02931b2 Show the social interactions hint on first multiplayer session 2026-11-05 03:34:34 -06:00
makscoder 2c39790297 Chest boats: roll the loot table a boat still holds on first open
ContainerEntity.unpackChestVehicleLootTable rolls the table saved in the
boat's NBT when a player opens it. Horizon read the table nowhere, so a
boat made by a command or a mod stayed empty and kept it. Roll it into
the slots on the open (only when they are empty), clear it from the raw
NBT, and fire player_generates_container_loot for the opener.
2026-11-05 12:31:39 +03:00
voidsip 875d1c9c45 fix(launcher): expose launch settings in glass instance menu 2026-11-05 03:30:23 -06:00
umcekoandClaude Opus 5.5 96d4726a17 Hoglins always save CannotBeHunted, like vanilla
Hoglin.addAdditionalSaveData writes putBoolean("CannotBeHunted", ...) every time, so a hoglin that can be hunted saves 0b instead of dropping the tag. save_roundtrip passes again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:26:59 +03:00
makscoder bd9909a074 Storage: a spectator opens no container that still has a loot table
BaseContainerBlockEntity.canOpen refuses a spectator while the container
still has a loot table to unpack; spectator_open_menu skipped that, so a
spectator could open an unrolled chest. The pending roll lives in the
chunk's saved rolled_loot until the first open consumes it.
2026-11-05 12:21:41 +03:00
makscoder f129bbcc48 Splitscreen: a pad that joins plays with it even when P1 has controllers off
The Controller setting is P1's own. A guest copied it, so after joining
from a pad its own pad was dead - on the profile screen it joins into,
with no way to pick one. Enable the controller in the joining player's
settings when the join came from a pad.
2026-11-05 12:19:41 +03:00
makscoder 9fe888b76c Splitscreen: the drain waits for the submit thread, not the device 2026-11-05 12:13:42 +03:00
Arslan ArmanulyandClaude Opus 5.5 a75884f992 Shader packs: samplers that aren't the program's read texture unit 0
OptiFine's shaders.txt puts gtexture on texture unit 0 in gbuffers programs and
colortex0 there in deferred, composite and final; its gbuffers table has no
colortex0 to colortex3. Our probe packs, run in Iris 1.11.7, read the block atlas
in gbuffers_terrain through a sampler name of their own and through each of
colortex0 to colortex3 and gnormal, and the scene in colortex0 in deferred,
composite and final through a name of their own and through gtexture, tex and
texture, also where the program declares no colortex0.

Horizon read its white stand-in for all of these, or, in gbuffers, the buffer.
With this, PotatoShaders' blocks keep their textures and its cave and Nether
come out as bright as in Iris.

Fixes #9763
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:10:25 +05:00
TiggerTakkyTTTandCodex 6388756c60 Server-admin: edit bans while stopped
Route CLI moderation through file-backed lists when no game is running, pin vanilla-style replies, and document the stopped-server behavior.

Fixes #9753

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-05 16:09:44 +07:00
Arslan ArmanulyandClaude Opus 5.5 beb30045fd Shader packs: a built-in declared with another GLSL type reads 0
Iris 1.11.7 disables a built-in uniform whose declared type is not the one it gives
("Wrong uniform type ... Disabling that uniform"), and the program reads 0. Our probe
packs saw this for floats declared int or vec2, ints declared float, ivec2s declared
vec2, int or ivec3, vec3s declared vec2 or vec4, a mat4 declared mat3, and uint; ints
and bools declared bool or int were kept. Horizon converted the value instead.

Fixes #9758
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:07:25 +05:00
Arslan ArmanulyandClaude Opus 5.5 8794c49a28 Shader packs: the block atlas blends its two nearest levels
Our probe packs read gtexture in gbuffers_terrain, and the block atlas as a custom
texture in composite, at levels 0.5 and 1.25 in Iris 1.11.7: each read was the two
nearest levels mixed (nearest texels within a level). Horizon's sampler took the
nearest level only. The atlas now gets a sampler of its own with linear mipmap
filtering; normals and specular keep theirs.

Fixes #9760
Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:07:25 +05:00
martipopsandClaude Opus 5.5 ff0a9f199f horizon-ctl: the pad command's arguments name connection too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:06:53 -05:00
HiuoyandClaude Opus 5.5 777a71b039 Content mods: a living type may copy the wandering trader
minecraft:wandering_trader joins the zombie and the villager as a living
template. It is the neutral one whose behaviour is all goals, so a native
mod's register_goals can replace every bit of it: a citizen, a guard or an
NPC, which a zombie (hostile, burns in daylight) and a villager (its brain
stays) can't be. Saving and entity data already follow the template's
class (class_id); the tests now cover a trader type too.

Part of #8622.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:06:20 +04:00
martipopsandClaude Opus 5.5 6408c7175f horizon-ctl: the pad command's usage names pad connection
The generated reference has it, so the usage it comes from has to.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:02:44 -05:00
makscoder 854db487d5 Fishing: draw the cast spread and the fish angle with vanilla's triangle
fishing.rs kept its own triangle on two next_float() calls; vanilla's
RandomSource.triangle draws two doubles, which also advances the random
differently. Use the core method.
2026-11-05 11:59:38 +03:00
AxxioandAntigravity 1d11a8b849 names: suppress default nametag when entity is ridden by text display or armor stand (fixes #9477)
Directed, architected and reviewed by Axxio.

Co-Authored-By: Antigravity <[email protected]>
2026-11-05 09:57:22 +01:00
i114rkandI114rk's AI agent 810d55aa22 Boot screen: paint through a memory bitmap so repainting does not flicker
Clearing the window and drawing the text straight into its DC shows the blank
frame in between, so the boot screen flickered each time the console's lines
arrived. The paint now goes to a compatible memory bitmap and reaches the
window as one BitBlt, the same frame the text was drawn into.

Co-Authored-By: I114rk's AI agent (OpenCode) <[email protected]>
2026-11-05 11:56:55 +03:00
Arslan ArmanulyandClaude Opus 5.5 cc6e8c65f7 Shader packs: shadowDistanceRenderMul keeps a cube around the camera, as in Iris
With shadowDistanceRenderMul set, the shadow pass drew only the chunks
within a circle of ceil(shadowDistance x mul / 16) chunks. Our probe pack's
Iris 1.11.7 shadow maps keep the sections whose box, grown by the same
1.15 blocks as for the casters, meets a cube of shadowDistance x mul around
the camera: out to the square's corners, a section 1 block past a side but
not 1.5, and nothing 70 blocks below a camera flying 50 blocks out. Over
12 scenes at shadowDistance 100 and mul 1, main dropped 33 columns Iris
keeps.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:53:41 +05:00
makscoder b21c3fb1ca Splitscreen: land the turn's frames before any surface reconfigure
Relands the frames on their submit threads at the top of Split::present:
a reconfigure there (a present mode switch, a new compositor, an
Outdated texture) waits for the device and errors on a submission made
meanwhile, and the compositor then copies views already submitted.
Simpler than gating on will_reconfigure, per martipops's review.
2026-11-05 11:49:16 +03:00
TheMrZZandCodex gpt-6.1-sol 067c293975 Detach old maps before adopting live frame Item data
Preserve the existing frame type during id-free live NBT adoption. Compare
count, item and components and remove the old map association before
replacement. Cover the Game command route and immutable-type adoption.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 09:47:54 +01:00
makscoder 67f66cf66b Camel: sprint works riding it, as on foot
LocalPlayer.aiStep runs its sprint decisions for a passenger too, but
the client only ran them on foot: `canSprint` and the local authority
stand in for food there. Run them for a vehicle with those conditions,
and give the ridden mount the controller's sprint state instead of the
raw key, so a double-tapped sprint starts and letting the key go keeps
it (Camel.getRiddenSpeed reads isSprinting()).
2026-11-05 11:46:47 +03:00
TiggerTakkyTTTandCodex 200716602c Server-admin MCP: pin tool contracts
Add protocol goldens for all MCP tools, schemas, safety annotations, confirmation requirements, and representative dispatcher errors.

Fixes #9741

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-05 15:46:31 +07:00
Arslan ArmanulyandClaude Opus 5.5 d652ec2f1b Shader packs: terrain is drawn from the camera in 65536ths of a block, as in Iris
Read back with a probe pack of ours, Iris 1.11.7's terrain gl_Vertex is
each block's position minus the camera with its fraction rounded to the
nearest 1/65536 block (seven cameras, up to 40000 blocks out), while
cameraPosition and the matrices keep the exact camera. Horizon drew from
the exact camera. Off by up to 2^-17 blocks, fragments on a texel boundary
of the shadow map read the other texel, and depths at the shadow test's
bias fell the other way: with this, our lit probe's depth ties at noon
go from 541 to 1 and its neighbouring-texel reads in y from 154 to 8.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 13:38:06 +05:00
Florian ERNSTandCodex ebc1d17ea3 Canonicalize bare map decoration component type IDs
Use the existing Identifier parser at preserved-component decode/encode boundaries. Own current Game/fresh Java focused gate94/103->103/103; unchanged full106 improves94/106->103/106 with exactly3 separate empty-elision reads retained. Core round-trip old-fail/fixed-pass,66item tests pass. Clippy, docs and content-root checks pass; existing Python release-fixture failures retained.

Co-Authored-By: Codex <[email protected]>
2026-11-05 09:33:08 +01:00
Alysia Zappavigna 6bd4443387 Fishing: draw the cast's spread from the hook's own random, as FishingHook.<init> does 2026-11-05 19:29:20 +11:00
makscoder 69d2c9c7df horizon-threads: wait for the job to start instead of sleeping past it
dropping_a_registration_waits_for_its_running_job slept 100 ms and
assumed a worker had taken the held job by then; on a busy runner it
had not, so the drop had nothing to wait for and returned early. Wait
until the job leaves the queue, and fail loudly if no worker picks it up.
2026-11-05 11:27:52 +03:00
martipopsandClaude Opus 5.5 b72ddf75c2 Controllers: a settings screen in the graphics style, and one notice per view
Graphics settings move their frame, list and key steps into settings_shell, with no change to how they behave,
and Controller Settings is its second user: General, Bindings and Device pages, search, per-row reset and Undo.
A controller notice is one slot per view rather than the split screen's own. A pad-focused options tooltip hangs
from the widget's bottom left, as the pad's hidden pointer sits mid-widget; mouse and keyboard keep vanilla's. A
focus whose widget goes away on the same screen moves beside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:14:31 -05:00
makscoder 6e3a860916 Splitscreen: drain the render queues before reconfiguring a surface
Surface::configure waits for the device to come idle and errors when a
submission lands while it waits: with the submit thread on (Linux by
default), adding a player could crash the game in Split::present's
set_vsync, in a new compositor's first configure, or on an Outdated
texture. Compositors now say when a reconfigure is coming or owed, and
Split drains every player's queue at those points only, keeping the
overlap the submit thread is for the rest of the time.
2026-11-05 11:12:10 +03:00
Riccardo Guerriero 88b7a8af1e Merge origin/main into fix/villager-stand-up 2026-11-05 09:11:13 +01:00
Riccardo GuerrieroandClaude Opus 5.5 a36307234a Villagers waking up stand beside the bed where vanilla puts them
LivingEntity.stopSleeping asks AbstractBedBlock.findStandUpPosition for
the entity's own type. The player's port of that search moves from
game/respawn_anchor.rs to stand_up.rs, generic over the entity type
(dimensions, player, fire immune), and villager::stop_sleeping uses it
with the villager's box and yaw, and vanilla's fallback. Villagers now
step onto shelves, slabs and carpets, pick vanilla's side of the bed,
and fall back to the top of the bed instead of staying lying on it.

WorldBorder.isWithinBounds(AABB) takes 1.0E-5F (a float) off the box.

use_item_on gains villager wake-up cases: 4400/4400 match.

Fixes #9502

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZzq8LUjYucp4kaN6UfSg7
2026-11-05 09:11:13 +01:00
voidsip 9f89e738a6 Reject ambiguous trigger axes without an Xbox-style button layout 2026-11-05 02:08:30 -06:00
JulesB40andClaude Opus 5.5 7920a520ee Motion: the tick after an elytra glide ends moves at sneaking speed
LocalPlayer.isMovingSlowly is also true while the player is visually
crawling: LivingEntity.isVisuallySwimming counts the fall-flying pose a
glide that just ended still holds. Horizon only counted the swimming
pose, so on the first air tick after a glide its input took the full
0.98 x 0.02 forward acceleration where vanilla's takes 0.3 of it: GrimAC
flagged Simulation 23 times on every elytra landing with jump held, at
an offset of exactly the difference (0.01372). Vanilla's client is clean
on the same input.

Fixes #8295

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 09:01:32 +01:00
HiuoyandClaude Opus 5.5 57561e449b Native mods: structure_template, a template's blocks for builders
Part of #8622 (item 4, structure placement for builders). A new helper,
native_structure_template(id, at, rotation, mirror) (Host::structure_template),
finds a structure template as StructureTemplateManager.get does (the world's
saved structures, then the game's and the content mods' data) and answers
what /place template would place at `at`: the size after the rotation and
each block's position, mirrored then rotated state and block entity data,
in StructureTemplate.placeInWorld's order and from the palette it picks
there. Nothing is placed: a builder places the blocks a tick at a time with
native_set_block. Server only; ids with a . or .. part are refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:57:03 +04:00
makscoder 5a837668c6 Format the splitscreen pause fix 2026-11-05 10:56:06 +03:00
ViníciusandCodex 77b8780ab8 Extras: enable chunk pregeneration from the settings menu
Save an opt-in pregen switch, apply it to integrated servers immediately, and refresh command completion while preserving --pregen and permissions.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-05 04:54:16 -03:00
Arslan ArmanulyandClaude Opus 5.5 da505c2b0e Shader packs: shadowProjection is Iris's for the pack's near and far planes
Read back with a probe pack of ours in Iris 1.11.7, a shadowNearPlane or
shadowFarPlane the pack sets is the orthographic projection's plane as it
is, with no 100-block offset; an unset near plane is -100.05 and an unset
far plane 156, each on its own. Horizon offset both by 100 blocks, so a
pack that sets either got another depth range. The projection's bits
(13 settings) fit JOML's setOrtho with zZeroToOne, its depth terms taken to
-1..1, in floats; the inverse is JOML's invertAffine. Before, the default
matrix's [3][2] was 4 ulps off and its inverse's signed zeros differed.

Horizon's own packs keep their depth range, now given as planes.

Adapts JOML 1.10.9's Matrix4f.invertAffine and setOrtho (MIT;
THIRD_PARTY_NOTICES.md).

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:52:10 +05:00
Arslan ArmanulyandClaude Opus 5.5 ea1fcbd314 Shader packs: sunPosition, moonPosition and upPosition are Iris's to the last bit
Read back with a probe pack of ours (25 ticks of day and night,
sunPathRotation 0 and 30, four view directions), Iris 1.11.7's sky
positions fit (0, 100, 0, 0) times gbufferModelView turned by JOML
quaternions in floats: rotationY(-90°), then for a body
rotationZ(sunPathRotation) and rotationX(its sky angle). Turned by
matrices, as Horizon did, the sun read 4e-6 where Iris has an exact 0
(e.g. at sunset), and the rest was 1 to 2 ulps off. All 50 readings of
all three now match, and shadowLightPosition with them.

Adapts JOML 1.10.9's Quaternionf.rotationX/Y/Z, Matrix4f.rotateAffine
and Vector4f.mulAffine (MIT; THIRD_PARTY_NOTICES.md).

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:51:43 +05:00
voidsip b8f05b083c Merge remote-tracking branch 'origin/main' into fix/7323-linux-triggers 2026-11-05 01:49:29 -06:00
Florian ERNSTandCodex 4efa91b049 Refresh honey dispatch with current main
Preserve both honey and pufferfish fixture options. Upstream stand changes are renderer-only and entity changes concern XP-orb sound; honey travel code is unchanged. Refreshed all-target Clippy, formatting and Python syntax pass. Runtime pair stays qualified to185156c0e.

Co-Authored-By: Codex <[email protected]>
2026-11-05 08:48:14 +01:00
Florian ERNSTandCodex 8770de9392 Apply honey side-slide movement to armor stands
Route the separately modeled stand through HoneyBlock movement after travel. Own current Game/fresh Java gate113/126->126/126, raw exact with unchanged scripts. Canonical travel regression old-fail/fixed-pass; honey suite11pass/1ignored. Broad readiness Rust/doc builds blocked by disk; formatting and Clippy pass.

Co-Authored-By: Codex <[email protected]>
2026-11-05 08:46:20 +01:00
Arslan ArmanulyandClaude Opus 5.5 a5fb848482 Shader packs: shadowModelView turns by Iris's angle between ticks
Iris 1.11.7 turns shadowModelView by (shadowAngle - 0.25) * 360 degrees in
floats, plus 360 when negative, not by the light body's own sky angle. Read
back with a probe pack of ours at 25 ticks of day and night (7295 to 7305
one by one), with sunPathRotation 0 and 30, this fits all 50 readings to
the last bit; the sky angle misses 16 of them by 1 to 6 ulps, which moves
the shadow map's texel boundaries.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:43:21 +05:00
GabrielNichols 1a6a403884 fix(windows): avoid repeated firewall prompts in fixtures and launcher updates
Keep same-machine test sockets on loopback and stage launcher-managed games
at a stable executable path per game home. Preserve runtime dependencies and
protect running installed/native versions from replacement and cleanup.

Refs #9685.
2026-11-05 04:42:32 -03:00
Ludovic Marie 41d5649ed5 Merge pull request #9713 from Hiuoy/feat/8622-mod-goals
🤖 Agent: with no mods the new hook costs one HOOKS.get() and a walk of an empty mod list per mob created, not per tick, and it runs before the float check, so a mod's goals::Float floats the navigation as FloatGoal does. The fragment stamps cleanly against main (test_stamp_native_hooks passes in pr_ready's Python tests). Pushed 9861c8352: living_mod_types now uses a temp folder per call, since parallel tests were deleting each other's folder. Merged with main, pr_ready --quick passes on Linux (4331 tests, launcher included).
2026-11-05 08:36:12 +01:00
makscoder 7eecf2aca1 Splitscreen: a pause menu no longer stops its window's ticks
set_paused deliberately lets the world run while splitscreen players
play, but pauses_game did not know about them, so the host window's
client ticks stopped behind its own menu: a laggy world and a rush
forward on resume. pauses_game is now false while split players are
joined, mirroring the server; scenario_pause checks both players'
tick counters against a solo game.
2026-11-05 10:34:40 +03:00
Ludovic Marie 36e1b42cb3 Merge pull request #9709 from glitchsnake/fix/9672-pufferfish-poison-tick
🤖 Agent: the sting now lands inside the fish's own tick (Pufferfish.aiStep → touch), so a target that ticks later in the same level tick counts its poison down that tick: 120→119→118, as in vanilla. Pushed 82648da3f, which removes the end-of-tick arm that could no longer run. Merged with main, pr_ready --quick passes on Linux (2863 client tests). The pufferfish contact harness is left to CI's parity run.
2026-11-05 08:28:26 +01:00
Ludovic Marie 8be47cdbca Merge pull request #9445 from martipops/fix/fake-player-close
🤖 Agent: the close paths (close_container, close_book) run while the fake inventory is still swapped in, so what a menu held goes back to the fake player and not to the server's own state. The ender chest's stand-in is put back afterwards, as a guest's turn does. Merged with main 27b9d342f (690 commits newer than its base), pr_ready --quick passes on Linux and the 4 ignored native_machines tests pass with prepared resources: trader, vehicle chests, shulker and ender lids, the restored state.
2026-11-05 08:28:22 +01:00
Ludovic Marie 8e1297dc73 Merge pull request #9711 from NerfPhaselock/perf/gpu-culling-g5
🤖 Agent: GPU culling stays off by default and DX12 frames still don't occlude, so only players who switch it on, on Vulkan, get the new passes. The new runs stamps survive a buffer regrowth (reserve_keeping copies the old buffer), so a grown table doesn't send every section back to phase 2 for a frame. On main 27b9d342f: fmt, clippy and 2419 client and render tests pass on Linux. The device tests of the box test itself aren't there, as the body says. Pushed dc3100917: a doc comment moved back onto its test.
2026-11-05 08:22:29 +01:00
Horizon agentandClaude Opus 5.5 9861c8352d living_mod_types: a temp folder per call, not per process
Tests run in parallel in one process: with three callers now (lan.rs and
two in mods.rs), one could remove the shared folder while another was
reading it (NotFound in a_mods_register_goals_replaces_its_types_goals).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 08:19:28 +01:00
TheMrZZandCodex gpt-6.1-sol 0d675f6dee Initialize wolf anger after live NBT writes
Convert the saved absolute end at the current level clock when adopting live
wolf data. Keep Bee state and the retained runtime fields unchanged. Cover
actual Game adoption and command writes with a separate NoAI drift diagnostic.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 08:19:19 +01:00
Ludovic Marie cc94ed7afe Merge pull request #9707 from arslanarm/shaderpacks/shadow-map-edge-columns
🤖 Agent: the 1.15-block margin is applied only to the caster-shape test, and the light frustum still tests the plain box, so the shadow pass can only keep more sections at the wedge's edges, never cull ones the light reaches. It costs two Vec3 ops per section test, only with a shaders/ pack's shadow culling. On main 27b9d342f: fmt, clippy and 1442 render tests pass on Linux.
2026-11-05 08:07:57 +01:00
Ludovic Marie 75fedf5a04 Merge pull request #9706 from arslanarm/shaderpacks/shadow-map-ties
🤖 Agent: the only outside code is JOML 1.10.9 (MIT, the library vanilla ships), credited in both file headers and THIRD_PARTY_NOTICES.md; Iris was only run as a black box with our own probe packs, so nothing from an LGPL source. The cost is a few float matrix ops per pack frame, and gbufferModelView now takes vanilla's Camera.getViewRotationMatrix path to the bit. On main 27b9d342f: fmt, clippy and 1443 render tests pass on Linux, the two last-bit tests included.
2026-11-05 08:07:53 +01:00
BPMichonandClaude Opus 5.5 1f866e2e51 Native hooks: ToastAddEvent (toast_add)
A client mod hears each new toast just before it is queued and may
cancel it: native_toast_add(kind, id, cancel). Kinds are advancement
(the advancement id), recipe (the first recipe id), tutorial (the
hint), system (narrator_toggle, pack_load_failure, pack_download,
low_disk_space, chunk_load_failure, chunk_save_failure) and
now_playing (empty id). Updates to a toast already queued or shown
are not adds. A cancelled toast isn't queued, shown or sounded; a
cancelled tutorial hint is kept off the slots so it isn't asked again
every frame. The startup menu's narrator toast is not covered: it
shows before Game, the hook's host, exists.

Fixes #9710

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BYf41fgTMRfUEXn3afqyTG
2026-11-05 07:03:09 +00:00
Ludovic Marie f090bb9821 Merge pull request #9689 from martipops/controller/menu-navigation
🤖 Agent: a menu frame without a controller does no new work: pad_menu_frame returns before the new resize/scroll-follow code unless the pad (or the keyboard focus) is in use, and the only always-on addition is one widgets::scroll_area push per drawn scrollbar. The superflat preset change matches vanilla (focus selects via AbstractSelectionList.setFocused, only Entry.select fills the share box). pr_ready --quick on main+PR on Linux: fmt, clippy, 4396 tests pass (the one Python failure is install.sh needing uname, absent from NixOS's PATH). tools/pad_menu_e2e.py not run: --offscreen still opens a winit window and needs a display.
2026-11-05 08:01:49 +01:00
masa-masa-0314 9bded00169 Merge remote-tracking branch 'upstream/main' into feat/realms-configure-screen 2026-11-05 14:59:42 +08:00
Horizon agentandClaude Opus 5.5 dc3100917c GPU culling tests: give records_keep_ids_page_counts_and_moves its doc comment back
the_shaders_validate was inserted between that test and its doc comment,
which then documented the new test instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:58:48 +01:00
Horizon agentandClaude Opus 5.5 82648da3f3 Pufferfish sting: drop the end-of-tick arm apply_brain_events now replaces
Every PufferfishSting comes from a mob's tick (sting_touching_mobs), and
apply_brain_events consumes it right after that tick, so handle_mob_events'
mob_hits_mob arm could no longer run. It joins StartFollowing and SchoolSize.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 07:58:28 +01:00
masa-masa-0314andZCode a7aeda8864 Realms: the owner's configuration screen and its subscription tab
The Configure button's screen was a placeholder (title, MOTD, open/close). It is now
RealmsConfigureWorldScreen: the four-tab bar (Worlds, Players, Subscription,
Settings, Settings off on an expired Realm), the footer's Play and Back, and
the Subscription tab: start date, time left, Extend, and Delete once expired
with its question.

Built on #7725's realms module and realms/client.rs, per the split on #7677.
RealmsClient.subscriptionFor (GET worlds/{id}/subscription) and deleteRealm
(DELETE worlds/{id}) join the client. The Worlds, Players and Settings tabs
follow in their own PRs (slot options, backups, invites, preferred region);
until then they show vanilla's loading line, and the open/close toggle stays
on the footer (vanilla keeps it on the settings tab).

The start date is a fixed local %Y-%m-%d where vanilla asks for the locale's
medium date (the process has no date locale), and the snapshot Realm's
subscription info line is not special-cased.

Co-Authored-By: ZCode <[email protected]>
2026-11-05 14:53:54 +08:00
NerfPhaselock 62fcccddd6 Merge remote-tracking branch 'upstream/main' into perf/gpu-culling-g5
# Conflicts:
#	docs/graphics.md
2026-11-05 01:30:43 -05:00
NerfPhaselockandClaude Opus 5.5 4d6f22efb6 Shader tests: the occlusion test's module is gpu_culling.wgsl's with gpu_occlusion.wgsl
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:28:50 -05:00
Ludovic Marie 27b9d342ff Merge pull request #9688 from yahya-nusseibeh/fix/registry-owner-ids
Mod registry: decide project ownership by numeric GitHub ID
2026-11-05 07:25:17 +01:00
Ludovic Marie e606196768 Merge pull request #9600 from NikitaMGrimm/hdr-light-design
Design note: linear light for HDR and shader API 0.2
2026-11-05 07:21:52 +01:00
Ludovic Marie dbf0d1c3c6 Merge pull request #9705 from horizon-rust/fix/fishing-hook-eye-spawn
🤖 Agent: at 2.5 blocks every triangle() throw enters the stand from outside on tick 2; at 3.0 a slower throw started tick 3 inside the margin box, where AABB.clip never hits.
2026-11-05 07:21:25 +01:00
MegaBytesMe eecdd28e83 Merge remote-tracking branch 'upstream/main' into codex/issue-9407-native-mods-arm64 2026-11-05 06:19:58 +00:00
Loki d49eb54168 Merge remote-tracking branch 'origin/main' into fix/9672-pufferfish-poison-tick 2026-11-05 15:16:32 +09:00
LokiandAntigravity c931ecb5a9 mobs: apply pufferfish sting poison during mob tick (fixes #9672)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 15:16:27 +09:00
andrescamilomartinez 1871f87982 perf(launcher): sample backdrop at main's density with 1-texel border 2026-11-05 01:14:41 -05:00
Arslan ArmanulyandClaude Opus 5.5 416704f151 Shader packs: the shadow pass keeps the edge columns Iris keeps
Our probe pack's Iris 1.11.7 shadow maps held 1 to 3 chunk columns at
the edges of the view's wedge that the shadow pass dropped (15 columns
over 8 noon scenes). Growing each section's box by a margin before the
caster test keeps them: the 31 edge columns of 8 noon scenes allow only
a growth of 1.12 to 1.19 blocks (a fit, not Iris's code); with 1.15, 19
of our 20 scenes hold exactly Iris's columns.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:13:43 +05:00
Arslan ArmanulyandClaude Opus 5.5 06dbd97b16 Shader packs: the camera and shadow matrices are Iris's to the last bit
Our probe pack read Iris 1.11.7's matrix uniforms back exactly. Horizon's
differed in their last bits (1.0 for 0.99999994, 6e-17 for 0), and a
fragment on a texel boundary of the shadow map then read the texel on
the other side: with nearest filtering, 13,066 pixels of ow_ground_noon
were lit in one game and shadowed in the other.

- gbufferModelView and its inverse: vanilla's Camera.getViewRotationMatrix
  (JOML Matrix4f.rotation of the conjugated rotationYXZ quaternion) and
  JOML's invertOrthonormal, in floats.
- gbufferProjection and its inverse, shadowModelView and its inverse,
  sunAngle and shadowAngle: fits of our probes' readings (JOML's float
  operations in a given order, or JOML's formula worked in doubles).

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:13:11 +05:00
Ludovic Marie fb7762d6f9 Merge pull request #9704 from arslanarm/shaderpacks/custom-smooth-infinity
🤖 Agent: The guard skips only a step whose value equals the held state, which leaves a finite value unchanged anyway, while NaN still steps because NaN != NaN, so only the inf - inf case changes. It's stacked on #9701 and lands after it; the shaders tests and clippy -D warnings pass on main merged with both.
2026-11-05 07:12:04 +01:00
Ludovic Marie 3395fc16c4 Merge pull request #9703 from arslanarm/shaderpacks/terrain-cutout-alpha-test
🤖 Agent: The two parts were already the game's (terrain_parts) and drawn this way for packs with the split programs, so a pack with only gbuffers_terrain now takes the same path. That's one more pipeline switch per terrain pass, not more geometry, and only with a shaders/ pack. 0.5 matches vanilla's ALPHA_CUTOUT for cutout terrain, so foliage gaps now match both Iris and vanilla. horizon-shaders' 298 tests and the ten terrain_parts tests pass on main merged with this head.
2026-11-05 07:12:00 +01:00
NerfPhaselockandClaude Opus 5.5 b1315c5a6e GPU culling (7/7): occlusion culling in its frames, on Vulkan
GPU frames ran no occlusion culling. On Vulkan, where cull.rs's test runs
with the depth test forced early, they now run the same two-phase test, each
section's part decided on the GPU: `cull` keeps per id the frames its box was
last seen and last tested at, puts in phase 1 the sections whose last test saw
them (retested every 8 frames) and lists the other boxes. The world pass draws
phase 1, then the listed boxes against its depth, each passing fragment
stamping its id (gpu_occlusion.wgsl); count2, scan2 and scatter2 write phase
2's draws, which a second pass draws before the CPU's own. Frames occlude
under cull.rs's gate (`cull::gate`), over the hidden share that comes back
with the statistics, now 12 words copied after the last pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:08:10 -05:00
Ludovic Marie 4729c5e995 Merge pull request #9702 from arslanarm/shaderpacks/custom-uniform-wrong-type
🤖 Agent: custom_index refuses only pairs where both sides are float/int/vecN and differ, so bool on either side keeps the old conversion, and so do types outside the list (ivecN, mat). A disabled uniform simply gets no slot, so it reads 0 and shows up in the program's "defaults or zero" notes. pack_post's tests, including custom_type_tests, and clippy -D warnings pass on main merged with this head.
2026-11-05 07:06:14 +01:00
Ludovic Marie 95350c2a1c Merge pull request #9701 from arslanarm/shaderpacks/custom-uniform-clamp
🤖 Agent: Applying the upper bound first and the lower one last only changes the result when min > max, so every in-range clamp a pack uses reads the same as before (the test's two in-range rows pin that). Fitting the order to your probe readings, not to Iris's code, keeps it clean-room. horizon-shaders' 222 tests and clippy -D warnings pass on main merged with this head.
2026-11-05 07:06:10 +01:00
Arslan ArmanulyandClaude Opus 5.5 b4eabf7ecf Shader packs: smooth() in custom uniforms keeps an infinite value
smooth() stepped as val + (state - val) * k, which turns an infinite value it
already holds into NaN (inf - inf). Iris 1.11.7 keeps it: our probe packs read
smooth(1.0 / 0.0, 5, 5) as +inf and smooth(-1.0 / 0.0, 5, 5) as -inf after 3 s
to 30 s in a scene (smooth(0.0 / 0.0, 5, 5) reads NaN in both games). A value
equal to the one held now stays as it is.

Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 11:01:06 +05:00
MegaBytesMe bf764d882a Merge remote-tracking branch 'upstream/main' into codex/issue-9407-native-mods-arm64
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 06:00:38 +00:00
Ludovic Marie d8800e9e4d Merge pull request #9700 from horizon-rust/fix/command-exec-fixture-leaks
🤖 Agent: a group that leaves an objective, forced chunk or gamerule behind now fails tests/test_command_fixture_state.py instead of parity-main's first Java guard.
2026-11-05 07:00:21 +01:00
Horizon agent 6e2198ef80 Merge remote-tracking branch 'origin/main' into fix/command-exec-fixture-leaks 2026-11-05 06:57:05 +01:00
Ludovic Marie bedc04f38e Merge pull request #9693 from yahya-nusseibeh/fix/launcher-import-cap
🤖 Agent: The cap counts bytes as they inflate, not the sizes the zip claims, and it lives in fill(), which import_into and import_mrpack_into both go through, so neither path can skip it. 8 GiB is well above any real .mrpack or shared instance. The launcher's fmt, clippy -D warnings --locked and all its tests (548 + 102, both bomb tests included) pass on main merged with this head.
2026-11-05 06:54:33 +01:00
andrescamilomartinez b7802f4df1 perf(launcher): optimize glass blur on scroll and polish UI layout
- Pre-bake full-window blurred background texture on resize to eliminate per-frame CPU bilinear resampling and allocation during list scrolling
- Adjust version card radius from PANEL to ROW (12px) with refined vertical padding and margins to resolve overlapping border stacking
- Add radius::CONTROL and overflow clipping to mods sort dropdown containers
2026-11-05 00:53:47 -05:00
Horizon agentandClaude Opus 5.5 3ae8128591 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:51:45 +01:00
Ludovic Marie 3c8a2352a7 Merge pull request #9691 from yahya-nusseibeh/fix/relay-proxy-from
🤖 Agent: Only relays started with RELAY_PROXY_PROTOCOL=1 change, and neither the Dockerfile nor horizon-relay.service sets it, so a default deploy behaves exactly as before. One that has it on now refuses to start until RELAY_PROXY_FROM is set, rather than dropping every connection. fmt, clippy -D warnings and the relay's tests, including proxy_from.rs, pass on main merged with this head.
2026-11-05 06:50:47 +01:00
Ludovic Marie b8f38ec702 Merge pull request #9690 from yahya-nusseibeh/fix/telemetry-hardening
🤖 Agent: Deploying this changes nothing for Railway's address counting: the Dockerfile names x-real-ip, the header the server already read first. The website fetches /v1/aggregate?days=30 and /v1/benchmarks/summary from the visitor's browser (benchmarks-page.js), so the new 600/h read bucket counts each visitor, not one site address. pr_ready skips this workspace, so I ran it on its own: fmt, clippy -D warnings --locked and every test pass on main merged with this head.
2026-11-05 06:50:43 +01:00
Ludovic Marie dd5c4a644b Merge pull request #9687 from TheMrZZ/fix/h314-allay-liked-player-damage
🤖 Agent: This follows Allay.hurtServer, which refuses only a player whose getUUID equals the LIKED_PLAYER memory. owner_damage_uuid passes a UUID only when the acting player is the server owner, so single player on its server thread gets the exact comparison, while LAN guests and dedicated-server players keep today's refuse-any-player filter: narrower than vanilla, but not a regression. The Game-level test runs against prepared resources and passes, and parity checks the corpus.
2026-11-05 06:48:41 +01:00
Horizon agentandClaude Opus 5.5 3919125b96 command_exec: crafter map extension takes its room back
It left its stone floor at y 199, a crafter, a chest and the player in
survival: the next group's `/setblock 10 199 10 stone` guard found stone
already there and stopped the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:48:21 +01:00
yahya-nusseibehandClaude Opus 5.5 449481dd49 Relay: take PROXY protocol lines only from listed load balancers
With RELAY_PROXY_PROTOCOL=1 a PROXY line was accepted from any TCP peer,
so a client reaching the port directly could choose the address its
per-address limits count.

- RELAY_PROXY_FROM: addresses or CIDR ranges (IPv4, IPv6, IPv4-mapped
  peers handled). A connection from anywhere else is closed unanswered.
- The relay refuses to start (a clear error, exit 1) when the protocol
  is on and the list is empty, or when an entry doesn't parse, instead of
  silently dropping every connection. Config::from_env returns a Result;
  Config::check says why.
- docs/play-with-friends.md lists the setting.

Split from #9129.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:45:15 +02:00
yahya-nusseibehandClaude Opus 5.5 9de4d4c169 Telemetry server: refuse to start behind a proxy without the client-IP header
With HORIZON_TELEMETRY_TRUST_PROXY=1 and no
HORIZON_TELEMETRY_CLIENT_IP_HEADER, every request would count as the
proxy's address: one shared bucket for /v1/batch, deletions, the live
count and reads, so intake would stop and players' deletion requests
would be refused, with only a line on stderr to say so (martipops's
review of #9129).

- The Dockerfile sets HORIZON_TELEMETRY_CLIENT_IP_HEADER=x-real-ip, the
  header the server read first before the setting existed, so the
  address the limits count on Railway stays the same.
- TRUST_PROXY=1 without the header now refuses to start (exit 1), as the
  relay does without RELAY_PROXY_FROM: a deployment missing it fails its
  /healthz check instead of going live with one bucket.
- README: both settings, and the refusal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:43:48 +02:00
Horizon agentandClaude Opus 5.5 18d5d5f53b Fishing hook spawns at the eye; its entity harness no longer rolls the hit
FishingHook(Player, ...) snaps the hook's position (its feet) to the owner's
eye; Horizon put it 0.125 lower. The harness cast at a stand 3 blocks away,
which vanilla's randomised throw reaches only on ~80% of casts: Horizon's own
run missed with the same odds and was never retried. At 2.5 blocks every
throw hooks the stand and the boat on its second tick, so the harness leaves
the known-flaky list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:43:26 +01:00
Horizon agentandClaude Opus 5.5 5aeedfa347 command_exec: crafter map extension releases its chunk; name isolated groups
crafter map extension forced chunk (0,0) and left it, which moved the next
`/forceload add` guard that covers it. The groups the full run leaves out
(reinforcements, recipe overlays) are now named once in the corpus, so the
harness and the fixture-state test skip the same ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:41:48 +01:00
HiuoyandClaude Opus 5.5 178bf207c6 Native mods: register_goals, a new mob's goals (Mob.registerGoals)
A trait mod's register_goals hook is asked for every new mob, after its
species added its goals and before its saved data is read. It may clear
the goal and target selectors and add Goal types of its own, which steer
the mob with its navigation (nav_move_to, create_path_to) and controls.
A mod's living type from #9667 thus gets its own AI. The hook runs before
the float check, so a mod's Float goal floats the navigation as
FloatGoal's constructor does.

The hook is a free function in the client library, so it goes through
NativeClientHooks as living_breathe does. mod_api re-exports Mob,
MobWorld, Goal, GoalSelector and Cx; the generator's dispatch tests get
a Mob stub and the catalog test knows mobs/ai.rs.

Part of #8622.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 09:41:21 +04:00
Ludovic Marie 43fdfb9de4 Merge pull request #9679 from yahya-nusseibeh/fix/reviewer-ids
CI commands: authorize trusted reviewers by numeric GitHub ID
2026-11-05 06:40:53 +01:00
Ludovic Marie 9756f9dcf7 Merge pull request #9686 from DUzzL/fix/9680-tick-freeze-visuals
🤖 Agent: This matches Minecraft.runTick and tick: the texture clock advances only inside isLevelRunningNormally, while GameRenderer.tick (the lightmap flicker) runs on every unpaused tick, and Horizon only calls tick() when !self.pause, so a paused game still freezes both. The flicker draws from FogState's own random, so moving the call doesn't shift any other draw.
2026-11-05 06:40:30 +01:00
Horizon agent c93c226ed6 Merge remote-tracking branch 'origin/main' into fix/command-exec-fixture-leaks 2026-11-05 06:38:57 +01:00
Horizon agentandClaude Opus 5.5 ff1d51d66a command_exec: mob suffocation group restores mob_griefing
mob partial block suffocation set mob_griefing false and random_tick_speed 0
and left them, so every later group ran without mob griefing: no piglin in
piglin pickups picked anything up on either side, and the gold's fate became
a race. A unit test now checks every group leaves the world rules it found.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:33:39 +01:00
Arslan ArmanulyandClaude Opus 5.5 e0e48c363e Shader packs: cutout terrain cuts at alpha 0.5 and solid terrain not at all, as in Iris
A pack without gbuffers_terrain_solid/_cutout drew all terrain in one pass
with an alpha test of 0.1, and alphaTestRef read 0.1. Iris 1.11.7, with probe
packs of ours writing one alpha per screen strip, drew solid terrain with no
alpha test (alpha 0.0 kept) and alphaTestRef 0, and cutout terrain keeping
alpha above 0.5 only (0.5 and 0.49 discarded, 0.51 kept) with alphaTestRef
0.5, through gbuffers_terrain alone and through the split programs alike.
Vanilla 26.3 cuts cutout terrain at 0.5 too (RenderPipelines.CUTOUT_TERRAIN).

Every pack now draws solid and cutout terrain apart, as packs with the split
programs already did: without them, both parts draw with gbuffers_terrain's
chain. The cutout part's default test is alpha > 0.5.

Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:33:24 +05:00
TheMrZZandCodex gpt-6.1-sol aa99b2a2b0 Merge current main after owner-damage validation
Retain upstream item, recipe and rendering changes alongside the scoped
Allay identity bridge. The owner damage and Allay source paths are unchanged.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 06:27:35 +01:00
Horizon agentandClaude Opus 5.5 d6644b03ad Scores leave the board with any destroyed entity, not only mobs
ServerLevel.EntityCallbacks.onDestroyed calls Scoreboard.entityRemoved for
every entity kind. Only dying mobs did here, so an item, an area effect
cloud or a projectile that had a score kept it after it was gone, and
/scoreboard players list kept listing its UUID (command_exec's scores and
social groups after carpet farms and loaded potion clouds). Once a tick,
the UUIDs that hold a score or a team are checked against the live
entities; unloaded and travelling ones leave persist.uuids first, so they
keep theirs, as in vanilla. Nothing runs while no entity holds one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:26:29 +01:00
Horizon agentandClaude Opus 5.5 5507e73158 command_exec: keep three checks off state the two sides load differently
- entities, piglin pickups: their falling-block and item selectors spanned
  the whole world. The Rust side block-ticks a ring of terrain (--radius 4)
  that the oracle never loads, and at chunk (-5, -2) generated kelp breaks
  and gravel falls into a flooded cave there, as vanilla's own does once
  that chunk ticks (checked with both forced loaded). Those drops are no
  fixture's: the checks now look near the fixture.
- execute: `execute in the_end if block 0 64 0` expected an unloaded chunk.
  The oracle's settle keeps its tickets for the rest of the run, so since
  gateway passengers settles in the End, 0 64 0 stays loaded on the Java side
  only. The check keeps its point (another dimension, an unloaded position)
  at 1000 64 1000.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:26:29 +01:00
Horizon agent 88d9a1e58b Merge main into ci/trusted-reviewer-ids 2026-11-05 06:23:06 +01:00
Ludovic Marie 3c945f9d30 Merge pull request #9682 from glitchsnake/fix/9604-xp-orb-lava-burn-sound
🤖 Agent: As in Entity.lavaHurt, the orb draws its pitch from its own random only after a hurt that lands (the early health <= 0 return covers isInvulnerableToBase's isRemoved), so the random order matches vanilla. The sound goes through the same audio.play path as the boats' lava_hurt_sound. Orbs don't track Silent yet, so a /summon'd Silent:1b orb still sizzles; that's a small gap left for later.
2026-11-05 06:21:33 +01:00
Ludovic Marie b7235f00c3 Merge pull request #9681 from masa-masa-0314/fix/debug-options-interrupt
🤖 Agent: This follows KeyboardHandler.handleDebugKeys: an open DebugOptionsScreen closes, otherwise canInterruptScreen (shouldCloseOnEsc by default) decides, and the interrupted screen runs its own close first, so a container gives its items back before the debug screen opens. The credits keep their screen, a deliberate gap from vanilla because their close can fail; the doc comment says so.
2026-11-05 06:21:29 +01:00
Ludovic Marie 3b47b51f81 Merge pull request #9678 from NerfPhaselock/fix/gpu-culling-device-loss
🤖 Agent: The flag is set only when the last frame before the loss was GPU-culled (old.gpu_frame()), so a device lost in an ordinary CPU-culled frame still gets GPU culling offered after recovery, and the saved setting is never written. It's a Relaxed AtomicBool read once per renderer build, so it costs nothing per frame.
2026-11-05 06:21:25 +01:00
Ludovic Marie a90988dc29 Merge pull request #9677 from Hiuoy/fix/9671-world-recipe-paths
🤖 Agent: In 26.3 recipes are a registry, and RecipeManager.finalizeRecipeLoading builds the stonecutter list and unpackRecipeInfo numbers the displays by walking recipes.values(), so sorting by registration_key in every table (and in the LAN copy the guest's stonecutter picks from) gives the order vanilla shows. The three reads moved to the world's table were the only places still showing the base table's result while handing over the pack's.
2026-11-05 06:21:21 +01:00
Ludovic Marie 4e166dc57e Merge pull request #9675 from arslanarm/shaderpacks/gl-fog-density
🤖 Agent: gl_Fog.density now reads the fogDensity value Horizon already sends, which is 0 everywhere, so a pack's exp-fog maths sees what Iris gives it in the scenes your probe measured. Nothing else in the struct changes, and packs without shaders/ never read it.
2026-11-05 06:21:17 +01:00
MegaBytesMe 53eb50dd02 Merge remote-tracking branch 'upstream/main' into codex/issue-9407-native-mods-arm64 2026-11-05 05:15:53 +00:00
Arslan ArmanulyandClaude Opus 5.5 4c4558d84d Shader packs: a custom uniform declared with another GLSL type reads 0
A custom uniform of shaders.properties was converted to whatever type the
program's GLSL declared it with. Iris 1.11.7 disables it instead ("Wrong
uniform type for X: Iris is providing FLOAT but the program expects INT.
Disabling that uniform.") and our probe packs read 0: a float declared int,
an int declared float, a vec3 declared vec2 and a vec2 declared vec3. A float,
int, vecN custom uniform now binds only to a GLSL uniform of its own type
among those; pairs involving bool keep the conversion (a float of 2.5
declared bool read true in both games).

Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:15:34 +05:00
martipopsandClaude Opus 5.5 4828bea53c Controllers: tabs, lists, stick and crosshair reach everything in a menu
The bumpers switch every tabbed screen, and turn the recipe book's pages; its triggers change category.
The right stick only scrolls: the focus stays on its row, no value changes, and nothing scrolls under the
on-screen keyboard. The D-pad walks a list's hidden rows, stops at a list's edge without changing a value, and
a held direction stops at the edge of the slots it's on. The crosshair stays on its target as the screen changes,
A held with the D-pad spreads a stack, and a list's scrollbar says how far its rows moved.

The arrow keys share the focus step, so they walk hidden rows too, and the superflat and single-biome rows
become focus targets for them. Focus on a preset selects it and leaves the share box to a press, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:14:05 -05:00
yahya-nusseibehandClaude Opus 5.5 65ce92e233 Nightly: sign each build for its own version (SHA256SUMS.v2.sig)
Every Nightly was signed for the constant "horizon release nightly", and
the version a launcher installed it as came from the unsigned GitHub
title, so anyone able to edit the release could serve an older signed
Nightly as tonight's.

- nightly-build.yml signs SHA256SUMS twice with the same key(s):
  SHA256SUMS.sig for the channel, as before, for installed launchers;
  SHA256SUMS.v2.sig for nightly-<date>-<commit>, the version the title
  names (sign_release.sh ALSO_FOR). Both are verified before publishing.
- horizon-release-sign: nightly_title_version() parses the title
  strictly (no fallback to published_at); nightly_digest() takes that
  version and checks SHA256SUMS.v2.sig for exactly it; is_nightly_build();
  SIGNATURE_V2. `verify --signature <file>` checks the v2 file.
- Every installer that checks a Nightly moves in the same step: the
  launcher, horizon-server update and packaging/server/install.sh refuse
  a Nightly whose title doesn't parse and install it only with a v2
  signature for that version. install.ps1 checks no signature (only
  GitHub's SHA-256), so it has nothing to move.
- An older Nightly still installs on request as a rollback with a
  warning; the launcher's update never moves the selection back to it,
  and horizon-server prints a rollback notice for --version.
- Nightlies from before this change have no v2 signature: the refusal
  now says so and names `horizon-server rollback`, and docs/security.md
  "Nightly signatures" describes the changeover (martipops's review of
  #9129). The launcher's new messages go through trf(), in every
  language table in English until translated.
- tests/install_sh_signature.sh: a v2 Nightly installs as its version; a
  Nightly with only the channel signature, an older Nightly retitled as
  a newer one, and an unreadable title are refused (all three installed
  before this change).

Split from #9129, as martipops's review suggested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:12:20 +02:00
Ludovic Marie 57ddb7c5c7 Merge pull request #9676 from yahya-nusseibeh/fix/nightly-apply-args
Nightly: call apply() with the six arguments it takes
2026-11-05 06:11:01 +01:00
TheMrZZandCodex gpt-6.1-sol 6ff0152b94 Merge main and retain synchronous command reinforcements
Keep the reinforcement request collection and post-hit drain around the
verified-owner damage adapter, and preserve all upstream corpus groups.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 06:09:51 +01:00
TheMrZZandCodex gpt-6.1-sol f8b6c21390 Compare liked-player UUIDs in owner Allay damage routes
Pass verified owner identity through the existing command and normal attack
adapters while preserving the legacy filter for unresolved damage callers.
Cover live command feedback/state and both adapter and compatibility APIs.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 06:09:06 +01:00
DUzzL a68e475a4c Hold animated textures under /tick freeze, keep light flicker running (#9680) 2026-11-05 06:08:45 +01:00
Arslan ArmanulyandClaude Opus 5.5 b67bb9d9cd Shader packs: clamp() in custom uniforms takes the lower bound when the bounds cross
A custom uniform's clamp(x, min, max) applied min first and max last, so with
min > max it gave max. Iris 1.11.7 gives min: our probe packs read
clamp(5.0, 3.0, 1.0), clamp(0.0, 3.0, 1.0) and clamp(2.0, 3.0, 1.0) as 3, and
clamp(vec2(5.0, 0.5), vec2(3.0, 0.0), vec2(1.0, 1.0)) as (3, 0.5), in three
scenes. Apply max first and min last.

Part of #9307

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 10:08:38 +05:00
Ludovic Marie ea14b8b65b Merge pull request #9646 from TheMrZZ/fix/h312-noai-allay-healing
🤖 Agent: This matches Allay.aiStep: the heal sits after super.aiStep with no NoAI gate (only isClientSide and tickCount % 10). The unit test pins both paths: NoAI heals and keeps its hold timers, and normal AI still heals only once.
2026-11-05 06:08:11 +01:00
Horizon agentandClaude Opus 5.5 806a600656 Command harness: settle refreshes the chunk tickets
Since #9482, /execute if loaded reads the ticket levels, which only a tick
recomputed. The harness's settle loads terrain without ticking, so a group
that moved the player and forced chunks (large fireball fire) still saw the
previous group's tickets: chunk column x=5 was resident and lit but not
entity-ticking. The oracle's settle takes its tickets at once (getChunk runs
the DistanceManager updates), so the probe's settle now refreshes them too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:07:46 +01:00
Horizon agentandClaude Opus 5.5 7c397e8587 /item … from block: a non-container block entity is no source
ItemCommands.getContainer rejects any block entity that is not a Container
("Source position … is not a container"). The source check only asked
whether a block entity existed, so a sign, lectern, campfire or beacon got
"The source does not have slot …" instead. Use the same container test as
the target side.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:07:46 +01:00
Ludovic Marie 33686f33ce Merge pull request #9530 from abit-foggy/codex/studio-a2a3-browser
🤖 Agent: Recording is opt-in (/studio or the control socket), filters before writing and fails closed on unknown packets. With no recording running, the network thread now pays one uncontended lock per packet instead of a copy of every payload.
2026-11-05 06:05:42 +01:00
yahya-nusseibehandClaude Opus 5.5 f2ec6394cc Launcher: cap what a shared instance or a .mrpack may unpack to
import.rs inflated every entry of a dropped .mrpack or game-folder zip
with no byte limit, so a few kilobytes of zip bomb could fill the disk
before the half-made instance was removed.

- fill() counts the bytes actually inflated across all entries (a zip's
  sizes are its own claim) and stops past MAX_IMPORT (8 GiB); the error
  removes the instance as before.
- The cap sits in fill(), which import_any() calls for both
  import_into() and main's newer import_mrpack_into() (a Java instance
  takes every path a pack carries), as martipops's review of #9129
  asked when rebasing.
- The message goes through trf(), in every language table in English
  until translated.
- Tests with a 16 KiB limit: a compressed bomb is refused and leaves
  nothing, for a shared instance and for a .mrpack; the limit is for all
  files together.

Split from #9129.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:05:39 +02:00
Horizon agentandClaude Opus 5.5 332c3170e8 Merge main into fix/h312-noai-allay-healing
groups(): keep main's crafter map extension group and the allay one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 06:05:05 +01:00
yahya-nusseibehandClaude Opus 5.5 337cb194af Mod registry: owners and owner_ids must name the same accounts
Once an entry has owner_ids, only an ID in them makes an owner, so adding
a co-owner to `owners` alone, the obvious edit, would silently lock them
out, and the refusal would list their own login among the owners
(martipops's review of #9129).

- Entry::check_fields refuses an entry whose owner_ids is non-empty and
  not as long as owners, with how to find the missing ID, so the registry
  CI catches it when the change is made.
- docs/api/owner-update-v2.md: the registry repository has no
  owner-update workflow yet, so nothing merges on its own today; and the
  backfill script runs only once a maintainer has read it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 07:00:11 +02:00
yahya-nusseibeh 7aceb1cbf9 Mod registry: decide project ownership by numeric GitHub ID
Owners were GitHub logins, and a login can be claimed by someone else once
its owner renames or deletes the account. For a project without a key, a
reclaimed login could publish an owner update that merges on its own.

- Entry gains `owner_ids` (#[serde(default)], omitted when empty). The
  form records the lister's ID for new projects (file and repository
  listings). The registry review notes any change to it.
- ownership(): when an entry has owner_ids, only an ID in them is an
  owner, so a matching login on another account is refused. Without
  owner_ids (older entries) the login matches, but the update always waits
  for a reviewer and the pull request says so.
- The automatic owner update requires an ID match.
- update_trailer is v2: `owner-update-v2` message with the submitter's
  numeric ID, and `format: v2` / `submitter_id:` lines in the trailer.
- tools/registry_backfill_owner_ids.py: one-off, dry run by default,
  resolves owners' logins to IDs, cross-checks them against the IDs the
  form recorded in the entry's commits, and leaves alone (and prints)
  any entry it can't resolve completely.
- docs/api/owner-update-v2.md: what the registry repository's
  owner-update workflow must check, and the deployment order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 526bfcbbe1)
2026-11-05 06:59:35 +02:00
Ludovic Marie 1fe029d88c Merge pull request #8941 from elythepreciouscat/perf/entity-occlusion-culling-pipeline
🤖 Agent: Entity occlusion culling stays Off by default. prepare_occlusion_culling only builds a culler when the mode isn't Off, so the default frame skips both the voxel paths and the new section-graph rejection. A budget that runs out still fails open (visible). One push from me: main's new ItemFrame.custom_name field added to the PR's bounds test, which was what broke verify. Desktop: clippy -D warnings is clean (client, client-lib, client-render) and 2835 client tests pass. Benchmarks: the author's stationary and moving runs above.
2026-11-05 05:54:51 +01:00
rdbtCVS d9340502f3 fix(telemetry): box blocking task error responses
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
(cherry picked from commit 75b4ce1062)
2026-11-05 06:54:39 +02:00
yahya-nusseibeh 6c019a2349 Telemetry server: read the client's address from one configured header
With TRUST_PROXY=1 the rate limits were keyed on X-Real-IP, else the last
X-Forwarded-For entry, whatever the platform guarantees. Railway's edge
has let clients set X-Real-IP in the past, and puts the CDN's address
there when Fastly is in the path.

- HORIZON_TELEMETRY_CLIENT_IP_HEADER names the one header the proxy
  overwrites (x-real-ip on Railway); its last comma-separated entry is
  the client. No other header is read.
- TRUST_PROXY=1 without it counts the TCP peer, and the server prints a
  warning at start saying every client then shares one bucket; a header
  set without TRUST_PROXY is reported as ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit bd420535e0)
2026-11-05 06:54:39 +02:00
yahya-nusseibeh 3483117549 Telemetry server: keep SQLite off the async workers, bound the totals' cost
The server runs two async workers and did every SQLite call on them, and
GET /v1/aggregate had no rate limit and cached per `days` (366 keys),
while DELETE /v1/installs/<any id> emptied every cache: a cheap way to
stall every handler.

- blocking(): batch storage (zstd inflate included), install deletion,
  the totals, the benchmarks summary, the live history, the upkeep and
  the live count's minute sample run on tokio's blocking pool.
- /v1/aggregate and /v1/benchmarks/summary take a per-address read
  bucket (READS_PER_HOUR, 600); uncached totals builds have a global
  budget (AGGREGATE_BUILDS_PER_HOUR, 600) and answer 503 past it.
- AGGREGATE_DAYS (e.g. 7,30,90,366) restricts the windows answered; unset
  keeps every value from 1 to 366. It is left unset by default because
  the website's own requests (private horizon-site) couldn't be checked.
- A deletion invalidates the cached totals only when it deleted rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit a29c16a169)
2026-11-05 06:54:38 +02:00
Horizon agentandClaude Opus 5.5 7f509a7229 Entity bounds test: give the item frame its custom_name
Main added ItemFrame.custom_name (a named frame drops with its name); the test initializer predates it and stopped verify from compiling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:52:59 +01:00
Loki 3202ebb565 Merge remote-tracking branch 'origin/main' into fix/9604-xp-orb-lava-burn-sound 2026-11-05 13:52:08 +09:00
LokiandAntigravity d199e5ece5 entities: play generic burn sound when xp orb takes lava damage (fixes #9604)
Co-Authored-By: Antigravity <[email protected]>
2026-11-05 13:52:05 +09:00
Horizon agentandClaude Opus 5.5 a9b34d89b0 Studio: record only while a recording runs; translate the browser
The privacy filter ran on every packet before the recorder was looked at, so
with no recording it still copied each payload (chunk data too) and
re-encoded every AddEntity with a fresh random UUID. It now runs only while a
recording is active, and keeps the packets it allows without copying them.

The recordings browser's title, empty state, buttons and server labels go
through the language table (gui.done, menu.singleplayer and
menu.multiplayer are vanilla's). The row's "Edit" hint is removed: this
PR has no editor to open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:48:50 +01:00
Ludovic Marie 5f064c53b4 Merge pull request #9670 from TheMrZZ/fix/noai-pufferfish-mob-contact
🤖 Agent: The NoAI branch now calls only sting_touching_mobs, after super.aiStep's work, as Pufferfish.aiStep does without a NoAI gate, and the unit test checks that ordinary AI still stings exactly once. The 119 vs 118 poison duration your diagnostic keeps is #9672.
2026-11-05 05:48:20 +01:00
Ludovic Marie a9d017d822 Merge pull request #9669 from DUzzL/fix/9660-spectate-f5
🤖 Agent: The name tags now use posed_rotation, which camera_orientation already trusts and which is already turned round for the front view. The only change is that a spectated entity's rotation is kept even when it equals the vanilla view, so the mouse no longer turns the tags.
2026-11-05 05:48:16 +01:00
Ludovic Marie e38e3af593 Merge pull request #9667 from Hiuoy/feat/8622-living-entity-templates
🤖 Agent: class_id() is the type's own id for every vanilla type, so mob_to_nbt and mob_meta change nothing without content mods. The loot-table fix (ruby:entities/ghoul) matches EntityType.getDefaultLootTable. The 25 slow content-root tests pass with prepared resources.
2026-11-05 05:48:13 +01:00
Ludovic Marie 0fcc1c5f72 Merge pull request #9663 from arslanarm/shaderpacks/shadow-map-terrain
🤖 Agent: The caster test can only add planes to the light's box, and a plane that doesn't fit the array is dropped, which keeps more, so the shadow pass never loses terrain the old box drew for a view it can shade. It applies only to Iris-format packs with shadow.culling unset. Fitting Iris's maps from screenshots, not its code, keeps this clean-room.
2026-11-05 05:48:08 +01:00
Ludovic Marie 6e714b46ff Merge pull request #9373 from TheMrZZ/fix/crafter-map-extension
🤖 Agent: The crafter now matches map-extension recipes against the level's live saved maps (MapExtendingRecipe.matches reads the map's scale). An unknown or max-scale map no longer extends, and the lookup goes through the world's recipe table. The author's refresh has the same Rust as my tested merge, where clippy -D warnings is clean and the crafter, menu and command_harness tests pass. Its corpus builds 117 groups with no duplicates.
2026-11-05 05:48:04 +01:00
Horizon agentandClaude Opus 5.5 5c4e9a381d Merge main into fix/h312-noai-allay-healing
groups() in command_exec_corpus.py: main added the zombie reinforcement
groups where this branch adds the allay one; keep all of them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:47:46 +01:00
Horizon agent 070422e205 Merge remote-tracking branch 'origin/main' into cg42b/8941 2026-11-05 05:46:30 +01:00
NerfPhaselockandClaude Opus 5.5 37fcc000cd Keep GPU culling off after a device loss in one of its frames
The game rebuilds the renderer after a device loss, and the new one offered
GPU culling again: a fault in its frames could lose the device after every
recovery, until a second loss within a minute ended the session. A loss while
the last frame was GPU-culled now makes every renderer after it report
GpuCullingOffer::DeviceLost, until Horizon restarts; the switch greys out and
says why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 23:44:57 -05:00
rdbtCVS dd63f7ba94 ci: resolve existing reviewer and owner accounts to numeric IDs
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
(cherry picked from commit 52612f0c35)
2026-11-05 06:44:12 +02:00
yahya-nusseibehandClaude Opus 5.5 c0f53a324f CI commands: authorize trusted reviewers by numeric GitHub ID
/nightly, the triage commands and /claim decided who may act by the
comment author's login. GitHub hands a renamed or deleted account's login
to whoever claims it next, so a reclaimed reviewer login could pick
unreviewed pull requests into the signed Nightly.

- trusted-reviewers.txt lines become `<id> <login>`; one shared parser
  (nightly_set.read_reviewers) for all three commands. Any other line,
  bare logins included, grants nothing and is reported on stderr.
  `paused` keeps its meaning.
- All three workflows pass AUTHOR_ID (github.event.comment.user.id);
  without it the scripts refuse. Ludovic is OWNER_ID, and his threads are
  recognised by user.id in triage.
- tools/ci/resolve_reviewer_ids.py fills in the IDs (and OWNER_ID);
  --check reports renames and deleted accounts.

No review requirement is added: a Nightly still takes any open pull
request to main, as decided on 2026-10-30 (NoReviewNeededTests).

Split from #9129, as martipops's review suggested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 06:44:04 +02:00
masa-masa-0314 556864a921 Merge remote-tracking branch 'upstream/main' into fix/debug-options-interrupt 2026-11-05 12:43:25 +08:00
masa-masa-0314andZCode ab43fcc32e Debug options: F3+F6 interrupts every screen its Esc would close (canInterruptScreen)
Vanilla 26.3 (KeyboardHandler.handleDebugKeys, the keyDebugDebugOptions branch)
closes an open DebugOptionsScreen, else when Minecraft.canInterruptScreen()
passes closes the current screen (Screen.onClose; canInterruptWithAnotherScreen
defaults to shouldCloseOnEsc) and opens the debug options over it. Horizon only
opened over the game and the bare pause menu (Fixes #9664).

- debug_options_interrupts: the canInterruptScreen classification, one arm per
  Screen; the death screen, the level teardown, the text-box screens, the
  credits (fallible close) and a playing overlay keep theirs.
- toggle_debug_options closes the interrupted screen the way its Escape does
  (a container hands its state back, creative clears pad focus and the carried
  stack, a mod's screen hears the close) before opening.
- The F3+F4 switcher passes F3+F6 on to the debug options screen.
- A unit test pins the classification.

Co-Authored-By: ZCode <[email protected]>
2026-11-05 12:43:24 +08:00
HiuoyandClaude Opus 5.5 d1a4f0a6b2 Recipes: world table for the grid, dyes and campfire; registration order for every recipe list
- The crafting grid's result, breeding's dye mixing and the campfire's
  cooked item read the world's table, as menu_click and the campfire's
  placement already did.
- Recipes::from_sources sorts every table (crafting, cooking, mods' own
  types) in registration order, the key #9406 sorted crafting by, now
  crafting::registration_key; StationRecipes sorts stonecutting and
  smithing by it.
- RecipeIndex numbers recipe-book displays in registration order.
- The LAN copy sends the stonecutter list and numbers groups in that
  order, so a guest's stonecutter button indexes the host's list.

Fixes #9671.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 08:43:16 +04:00
MegaBytesMe e8cc426080 Merge remote-tracking branch 'upstream/main' into codex/issue-9407-native-mods-arm64
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 04:42:54 +00:00
yahya-nusseibehandClaude Opus 5.5 62572a0b18 Nightly: call apply() with the six arguments it takes
command() still passed read_reviewers(reviewers).logins to apply() after
c527d60b7 dropped that parameter, so every /nightly comment that got past
authorize(), status included, ended in TypeError and was never answered.

- Drop the leftover argument.
- Test: command() run as nightly-command.yml runs it, with GitHub and the
  state branch stubbed, answers /nightly status. It fails on main with the
  TypeError.

Split from #9129, as martipops's review suggested.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 06:39:53 +02:00
Arslan ArmanulyandClaude Opus 5.5 cf56244594 Shader packs: gl_Fog.density reads 0, as in Iris
The compatibility gl_Fog gave packs a density of 1. Iris 1.11.7 gives 0
there, in gbuffers_terrain and in deferred, in a cave and in the Nether,
where fogDensity also reads 0 in both games (read back with a probe pack
of our own).

Fixes #9674

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 09:38:49 +05:00
ElyTheCat 76e92e12de Merge branch 'main' into perf/entity-occlusion-culling-pipeline 2026-11-05 08:05:40 +03:30
Ludovic Marie 185156c0eb Merge pull request #9662 from horizon-rust/agent/security-fixes-from-9129
🤖 Agent: Four standalone fixes from #9129, with authorship kept. The control token now comes from getrandom. Token files are written owner-only (write_private). Ban-list dates are checked instead of taken as they are. A relay bridge frees its slot when it ends. None of them is on a hot path. The server-files, relay, server and control tests pass on playhorizon.
2026-11-05 05:33:24 +01:00
Florian ERNSTandCodex 55b2752d15 Keep map and light observations after reinforcement merge
All-target Clippy, formatting, Python options and Java oracle compilation pass at main 61afe4fa8. Captured runtime proof remains unchanged.

Co-Authored-By: Codex <[email protected]>
2026-11-05 05:31:21 +01:00
Ludovic Marie 61afe4fa8e Merge pull request #9452 from TheMrZZ/fix/command-damage-zombie-reinforcements
🤖 Agent: A zombie hit by /damage now sends its reinforcements before the command returns, as vanilla's hurtServer does inside the command, so a following command sees the reinforcements. The isolated reinforcement groups run only with --only, so the generic corpus is unchanged. The author's refresh onto main keeps both fixture isolations. Clippy -D warnings is clean and the reinforcement tests pass.
2026-11-05 05:29:09 +01:00
Florian ERNSTandCodex 470503d7a9 Preserve world recipe lookup and map context on crafter refresh
Keep H295 active world recipe selection with H298 saved-map matching and system post-process. Preserve all shared corpus groups, datapack fixtures, map copy controls and player observations. Formatting, all-target Clippy, Python and Java compile checks pass; original runtime qualifications remain unchanged.

Co-Authored-By: Codex <[email protected]>
2026-11-05 05:27:59 +01:00
Horizon agentandClaude Opus 5.5 9bc2a60c10 Remove leaked command_exec objectives and forced chunk
Java's fixture guards read counts: `/scoreboard objectives add` returns how
many objectives exist after the add, and `/forceload add` how many chunks it
newly forces. Four groups left state behind for the next ones:

- mob baby attribute reads kept h227 and allay invalid Brain retention kept
  h226, so rewrite_hit, hitstate and audit got 3 instead of 1;
- both advancement loot groups kept chunk (0, 0) forced, so item
  stack-size override merges forced 7 chunks instead of 8.

parity-main stopped on "Java fixture control failed" for each of them once
#9514 fixed the /fill floor guard (#9587). Each group now removes what it
added. A unit test replays every group's objectives and forced chunks and
checks each count guard against what earlier groups leave.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:27:39 +01:00
Florian ERNSTandCodex 5801a60185 Keep both reinforcement and recipe fixture isolation on main refresh
Formatting, all-target Clippy, Python options and Java oracle compilation pass at main 898d3e165. Preserve captured runtime qualifications.

Co-Authored-By: Codex <[email protected]>
2026-11-05 05:25:55 +01:00
Florian ERNSTandCodex f60a109738 Refresh reinforcement command fix against main
Keep reinforcement and advancement-loot corpus groups, all diagnostic options, and light/player observations. The reinforcement fixture function is byte-identical. Formatting, all-target Clippy, Python compilation/options/registration and Java oracle compilation pass. Original runtime proof remains qualified to its captured base.

Co-Authored-By: Codex <[email protected]>
2026-11-05 05:24:59 +01:00
Ludovic Marie 898d3e1654 Merge pull request #9406 from TheMrZZ/fix/crafting-recipe-selection-order
🤖 Agent: The recipe order now follows ResourceManagerRegistryLoadTask's sort by file id (path, then namespace). It runs once per load, and find_with is unchanged, so lookups cost nothing more. The prepared 26.3 recipe folder has no '-' or '.' names, so vanilla's own recipes keep their order. Main merged in, both fixture sets kept.
2026-11-05 05:24:49 +01:00
Florian ERNSTandCodex 1ab3333cef Refresh H313 with server API test coverage
The final main checkpoint changes server API tests only; the checked pufferfish source and fixture are unchanged.

Co-Authored-By: Codex <[email protected]>
2026-11-05 05:21:20 +01:00
Ludovic Marie 998b4710a4 Merge pull request #9655 from Vaspyyy/perf/collision-scan-memo
🤖 Agent: The one memo keeps vanilla's chunk requests (a missing chunk is asked again unless the world promises it stays missing), and may_reach_out, now asked once per non-empty section, is a pure query. An A/B on the desktop (dedicated server, 1500 pigs on the ground, 3 interleaved rounds, main f5565f1 against main plus this PR) gave a 5.30 ms median per tick on main and 5.33 ms with this PR: below the run-to-run noise (±0.3 ms), so your instruction counts are the finer measure. The far-move cost (+16%) is a fair trade for every mob's ground move.
2026-11-05 05:20:42 +01:00
Ludovic Marie 5c06efc139 Merge pull request #9398 from NikitaMGrimm/deterministic-capture
🤖 Agent: With HORIZON_STILL off, every new branch reduces to the old expression and the variable is read once through a OnceLock, so there's no per-frame cost. The main merge keeps the border ticking only while the level runs, as main has it. Checks: clippy -D warnings, shader tests, clock tests.
2026-11-05 05:20:37 +01:00
Ludovic Marie 30482e153f Merge pull request #9338 from NerfPhaselock/perf/gpu-culling-g4
🤖 Agent: Off by default and in no preset; with the setting off there is no readback buffer and no poll. The device-loss follow-up from the review (turn GPU culling off for the session after a loss in a GPU frame) is worth its own PR. Union build passed.
2026-11-05 05:20:34 +01:00
Horizon agentandClaude Opus 5.5 370f0314be Merge main into fix/h312-noai-allay-healing
Both sides add a group to command_exec_corpus.groups(): keep both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:19:15 +01:00
Florian ERNSTandCodex 0fcaef94e3 Refresh H313 against current mob and UI changes
Upstream changes add frog travel, player equipment visibility and UI/server work; the NoAI pufferfish caller and sting handler are unchanged. Preserve all fixture options and qualify the captured runtime pairs to d158e4473.

Co-Authored-By: Codex <[email protected]>
2026-11-05 05:19:07 +01:00
Horizon agent 8380643533 Merge commit 'refs/cg42/a9398' into cg42b/9398 2026-11-05 05:17:57 +01:00
Horizon agent e384f63540 Merge remote-tracking branch 'origin/main' into cg42b/9406
# Conflicts:
#	tools/command_exec_corpus.py
#	tools/test_command_exec_parity.py
2026-11-05 05:13:08 +01:00
Ludovic Marie 28d25be3f0 Merge pull request #9159 from TiggerTakkyTTT/test/server-api-auth-command-goldens
🤖 Agent: main merged in. The new auth/command goldens test now sits beside main's observability and config golden tests in api.rs, and both sides are kept whole. It drives the real HTTP listener, so missing/wrong token, wrong Host and malformed commands are checked over the wire. horizon-server: 150 tests pass, clippy -D warnings is clean.
2026-11-05 05:12:28 +01:00
Florian ERNSTandCodex 26ec6c900b Refresh H313 from main and verify overlapping cod contact
Preserve the unchanged canonical 218 and strict 220 scripts. Add an independently validated same-position pig/cod control rather than infer contact from the original cod placement. Keep landed H311 player contact and all current fixture options.

Co-Authored-By: Codex <[email protected]>
2026-11-05 05:11:44 +01:00
Horizon agent 7b0a85e775 Merge remote-tracking branch 'origin/main' into cg42b/9398
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-11-05 05:11:17 +01:00
Ludovic Marie 614ebb30cd Merge pull request #9661 from horizon-rust/agent/finished-8918-world-canvas
🤖 Agent: Carries #8918 unchanged, with main merged in. Only the mod_api export list conflicted, and both sides are kept. The depth-tested world text and atlas quads go through the existing native world-draw hook, so a game without mods costs nothing extra. It passed fmt, clippy and tests in union3.
2026-11-05 05:10:20 +01:00
Ludovic Marie dc5173c2bd Merge pull request #9384 from DUzzL/fix/9368-riptide-dry-use
🤖 Agent: Matches TridentItem.use: FAIL (about to break, or Riptide while dry) doesn't start using and lets the off hand try, as Minecraft.startUseItem does. game.rs's single-player use chain now also falls through on FAIL. Union build passed.
2026-11-05 05:10:16 +01:00
Ludovic Marie 5280f96229 Merge pull request #9365 from Joansitoh/t3/pig-strider-boost-sync
🤖 Agent: The boost now starts from the synced DATA_BOOST_TIME (index 18, after AgeableMob's two), as ItemBasedSteering.onSynced does, instead of the rider's client guessing it. Union build passed.
2026-11-05 05:10:12 +01:00
Ludovic Marie 3b49e970f0 Merge pull request #9362 from Joansitoh/t3/armor-stand-minecart-push
🤖 Agent: Matches ArmorStand.pushEntities: only RIDABLE_MINECARTS within distanceToSqr <= 0.2 are pushed, and the stand riding the cart is skipped as Entity.push skips a shared vehicle. Union build passed.
2026-11-05 05:10:09 +01:00
Ludovic Marie 51b70481c0 Merge pull request #9360 from Joansitoh/t3/item-frame-custom-name-drop
🤖 Agent: Matches ItemFrame.getFrameItemStackWithData: the dropped frame (glow frames too) takes CUSTOM_NAME from getCustomName, and the name now round-trips through the frame's NBT. Union build passed.
2026-11-05 05:10:04 +01:00
Ludovic Marie ab41e568b1 Merge pull request #9359 from Joansitoh/t3/frog-travel-in-water
🤖 Agent: Matches Frog.travelInWater: moveRelative(getSpeed()), move, then scale 0.9, with no gravity or water drag. Minor: the test's `let _ = ran;` passes silently without vanilla data. Union build passed.
2026-11-05 05:10:00 +01:00
Ludovic Marie 3f61f093a5 Merge pull request #9351 from Joansitoh/t3/fix-mob-visibility-head-range
🤖 Agent: Matches LivingEntity.getVisibilityPercent: each worn stack's mob_visibility applies only when its equippable slot matches and the looker's type is listed, then Mth.clamp(0, 10). The chest/legs/feet fields cost three Arc clones per PlayerView. Union build passed.
2026-11-05 05:09:56 +01:00
Ludovic Marie 153d4f654d Merge pull request #9344 from MSedgeMC/test/explosion-victims
🤖 Agent: The non-player victims carry NoGravity so the blast's push is the only motion compared, and Pos/Motion are compared at 9 decimals as server_knockback does. Union build passed.
2026-11-05 05:09:52 +01:00
Ludovic Marie 3325da4253 Merge pull request #9340 from Joansitoh/fix/9336-honey-landing
🤖 Agent: HoneyBlock.fallOn plays the slide sound (the guest's own client plays it for the guest) and broadcasts event 54 to everyone, the lander included. Union build passed.
2026-11-05 05:09:48 +01:00
Ludovic Marie 508f933a63 Merge pull request #9331 from BPMichon/feat/health-hud-state-helpers
🤖 Agent: native_health_hud_state hands out &mut HealthHud, so a mod calling layout twice a frame double-advances the heart blink; the docs say once a frame, which is the right contract. Stamp test and union build pass.
2026-11-05 05:09:44 +01:00
Ludovic Marie 7cfa9f9f7c Merge pull request #9319 from lightcodegit/fix/peaceful-despawn
🤖 Agent: Matches Mob.checkDespawn: peaceful discards a disallowed type on its own despawn check, even when persistent; Horizon's check_despawn already does that first, so the three instant purges were the only difference. Union build passed.
2026-11-05 05:09:40 +01:00
DUzzL 7658eb5a2d Billboard name tags and camera-facing effects to the spectated camera entity 2026-11-05 05:09:23 +01:00
Horizon agent adaf038bd3 Merge remote-tracking branch 'origin/main' into cg42b/9159
# Conflicts:
#	crates/horizon-server/src/api.rs
2026-11-05 05:07:26 +01:00
Ludovic Marie 6c5eece90d Merge pull request #8700 from Akirabane/feat/7484-control-read
🤖 Agent: main merged in after #9062. The config golden now lists the new [transfer] section with its defaults (enabled false, expect 10 s, max 60 s, 64 players, freeze 30 s). horizon-server tests: 149 pass.
2026-11-05 05:04:28 +01:00
Ludovic Marie 3a919c7d32 Merge pull request #9381 from gigamans442/feat/weather-depth-contract-20261104
🤖 Agent: An explicit rain.depth=false now skips the weather depth write; omitted and malformed values keep main's behaviour. Spectrum and Luculent are MIT and disclosed. Union build passed.
2026-11-05 05:04:24 +01:00
Ludovic Marie 524aa03ee9 Merge pull request #9176 from arslanarm/shaderpacks/terrain-solid-cutout
🤖 Agent: Each part clips the other's quads in the vertex stage, so packs with gbuffers_terrain_solid/_cutout pay double terrain vertex work (noted in the earlier review); packs without them take the old single draw. Union build with 44 PRs passed, including horizon-shaders and render tests.
2026-11-05 05:04:20 +01:00
Ludovic Marie a6937b0eae Merge pull request #9652 from voidsipp/fix/9382-sound-entity-lookups
🤖 Agent: Mob sounds now return the mob-index state before level_entity runs, so the dragon's metadata Mob can't shadow its Dragon, and silence and removal still come from the Mob. The once-per-tick cache is keyed by id inside one call, and a removed sound never revives when its id is reused.
2026-11-05 05:04:16 +01:00
Ludovic Marie a09f16910f Merge pull request #9659 from horizon-rust/fix/9239-review-fixes
🤖 Agent: queued; a chunk unload now drops only that chunk's sleepers, so idle furnaces elsewhere stay asleep instead of all ticking again.
2026-11-05 05:04:11 +01:00
Ludovic Marie 049c9d25db Merge pull request #9658 from horizon-rust/fix/9271-review-fixes
🤖 Agent: queued; a dolphin now drowns if kept under water past 4800 ticks, as in vanilla (it is not in #can_breathe_under_water).
2026-11-05 05:02:12 +01:00
Ludovic Marie 8c45bbac89 Merge pull request #9656 from horizon-rust/fix/9222-9229-review-fixes
🤖 Agent: queued; the drop_tool key is the machine's own position, so a neighbour broken in the same call no longer rolls the machine's tool or its silk touch.
2026-11-05 05:02:09 +01:00
Horizon agentandClaude Opus 5.5 8ea4669674 Config golden: add the [transfer] section
#9062 pinned the config reply before this PR added [transfer]; the GET reply now lists it with its defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:02:07 +01:00
Ludovic Marie ea93759d41 Merge pull request #9457 from voidsipp/fix/9382-sound-port-ledger
🤖 Agent: Ledger only: SoundInstance::tick and the bound-entity note match what #9382 merged. Nothing else found.
2026-11-05 05:02:04 +01:00
Ludovic Marie c015efcdc3 Merge pull request #9645 from HrafnaValkyrja/fix/8589-raid-sweat
🤖 Agent: entity_event_burst is now the only list, and no other branch of the EntityEvent handler draws for 38 or 42, so nothing draws twice. The cost is the existing mob search, now for every entity event instead of eight ids; entity events are rare enough that this doesn't show.
2026-11-05 05:01:59 +01:00
Ludovic Marie 42417e3d4b Merge pull request #9453 from GabrielNichols/codex/stale-harness-verdict
🤖 Agent: The old verdict is removed before carry mutates content, and an interrupted harness can no longer leave a stale green one. Union build passed.
2026-11-05 05:01:55 +01:00
Ludovic Marie 6c2c0f9c5b Merge pull request #9441 from NikitaMGrimm/windows-exact-sin
🤖 Agent: Measured on the desktop: exact::sin + cos takes 110 ns a pair against libm's 10 ns (11x). The 27 jmath sin/cos call sites are per mob or per feature, never per vertex, so that is microseconds a tick, on Windows only. Union build passed.
2026-11-05 05:01:50 +01:00
Ludovic Marie 3497ab4654 Merge pull request #9429 from itarqos5/perf/far-light-one-span-a-run
🤖 Agent: A previewed far-terrain column spends one span; the dampening is per run, measured −13 % far sampling memory. Union build passed.
2026-11-05 05:01:46 +01:00
Ludovic Marie a9b1bc2649 Merge pull request #9420 from phexuss/fix/skins-transient-failures
🤖 Agent: A failed cape no longer drops the skin, a failed download retries after 60 s instead of never, and only bodies that decode reach the disk cache. Union build passed.
2026-11-05 05:01:42 +01:00
Ludovic Marie d4fe63cbb7 Merge pull request #9415 from DUzzL/fix/9390-game-master-break
🤖 Agent: Matches ServerPlayerGameMode.destroyBlock: a GameMasterBlock stays without canUseGameMasterBlocks. Vanilla also sends a block update there; the guest's break acknowledgement covers that here. Union build passed.
2026-11-05 05:01:38 +01:00
Ludovic Marie 7d8176bc00 Merge pull request #9394 from oxi-does-dev/fix/control-chat-client-commands
🤖 Agent: The control socket's chat and command go through handle_chat_input and the client mod command path, the same as typed chat, so client mods see them. Union build passed.
2026-11-05 05:01:35 +01:00
Ludovic Marie 1d5ec41a8a Merge pull request #9393 from TheMrZZ/fix/h294-mob-nbt-hit-cooldown
🤖 Agent: Entity.load mutates the existing living entity and never reads its damage cooldown or last hurt amount, so a /data merge no longer resets them. The corpus case covers it against Java. Union build passed.
2026-11-05 05:01:30 +01:00
Ludovic Marie 67d37c698e Merge pull request #9388 from phexuss/fix/png-size-guard
🤖 Agent: The header's width × height is checked before decoding, so a crafted PNG can no longer ask for a huge allocation. Union build passed.
2026-11-05 05:01:26 +01:00
Ludovic Marie 62242ccd02 Merge pull request #9372 from arslanarm/shaderpacks/smooth-untaken-branch
🤖 Agent: if() now evaluates conditions up to the first true one plus that branch only, so a smooth() in an untaken branch keeps its state. Iris was only run with your own probe packs, which our rules allow. Union build passed.
2026-11-05 05:01:23 +01:00
Ludovic Marie 51a1af2436 Merge pull request #9366 from Joansitoh/t3/dragon-flying-hit-knockback
🤖 Agent: Matches EnderDragon.knockback: LivingEntity.knockback only when the phase isn't sitting, power 0.4F from dealDefaultKnockback, and the vertical stays (noPhysics never sets onGround). Arrow hits still don't push, as before. Union build passed.
2026-11-05 05:01:19 +01:00
HiuoyandClaude Opus 5.5 d579acfa15 Content mods: living entity types from a zombie or villager template
A content mod's entity type could only copy minecraft:snowball, so a mod
had no way to add a mob. It may now copy minecraft:zombie or
minecraft:villager, as NeoForge's EntityType.Builder.of(Zombie::new, ...)
makes a type of a vanilla class, with its own default attribute values
(EntityAttributeCreationEvent) in an `attributes` map, for attributes the
template has. A thrown type still names its item; a living one has none.

The mod's row in entity-types.json is its template's with `template`
added and its attribute bases in place of the template's, every other
value kept as the template's text. MobTypes::load makes a row naming a
template into that species' MobType under the mod's id, so registry and
network ids, /summon and remote spawns, which already went through the
table and the registry, pick it up. What a class saves and syncs of its
own (a zombie's IsBaby, CanBreakDoors and conversion times, its entity
data, the villager's) follows MobType::class_id, the template's id for a
mod type; the saved and sent id stays the mod's.

A mob's default loot table, and /loot ... kill, now keep the type's
namespace (EntityType.getDefaultLootTable): ruby:ghoul rolled
minecraft:entities/ruby:ghoul, and rolls ruby:entities/ghoul. Nothing
changes without content mods: no row has a template, and class_id is the
type's own id.

First slice of #8622; goals, navigation and structure placement stay
open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 08:00:38 +04:00
Nikita Maximilian GrimmandClaude Opus 5.5 052e130539 Merge origin/main into deterministic-capture
Keep main's world border rule from #9396 (it ticks only while the level
runs, which already covers HORIZON_STILL's hold) instead of the
branch's hold gate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 05:00:25 +01:00
Ludovic Marie 1802dc9713 Merge pull request #9301 from degepie/fix/custom-enchantment-names
🤖 Agent: The synced enchantment table is built once per registry sync, not per tooltip; a literal-text description of a data-pack entry now shows instead of the raw id. Union build passed.
2026-11-05 04:57:22 +01:00
Ludovic Marie 4cc927ec8e Merge pull request #9199 from Mayonesu0/fix/8779-powered-wither-wind-charge
🤖 Agent: Matches WitherBoss.hurtServer: a powered wither refuses a direct WindCharge, and a breeze's BreezeWindCharge (a sibling class) still hurts it; the test pins exactly half health. The only parity failure was #9511, fixed by #9514 (merged), so parity runs again.
2026-11-05 04:57:18 +01:00
Ludovic Marie e7f54ff0da Merge pull request #8924 from Delete228/create-owned-sounds-pr
🤖 Agent: Merged in a 45-PR union build (fmt, clippy, tests pass). Ticking::Native only changes who advances a sound instance; vanilla's SoundEngine.tickInGameSound order is unchanged for every non-native sound.
2026-11-05 04:57:14 +01:00
Ludovic Marie 3747a6c2cb Merge pull request #9421 from phexuss/fix/wardrobe-session-reuse
🤖 Agent: I merged main (wardrobe tests on both sides kept, 95124d4). The session is reused until the service answers 401, then signs in once more; Mojang's join is made once per service session instead of on every refresh. Union build passed.
2026-11-05 04:57:10 +01:00
Ludovic Marie 4a67ce5b0c Merge pull request #9427 from JulesB40/perf/surface-rule-once
🤖 Agent: I merged main and carried main's new OreVein positive_y through fold_biomes (3bbcda7); it depends on the rule, not the chunk. The measured win is in the carvers stage (−16 to −18 % ms/chunk), with snapshots identical to main. Union build passed.
2026-11-05 04:57:07 +01:00
Ludovic Marie f52f7edf1b Merge pull request #9364 from Joansitoh/t3/firework-elytra-leash-break
🤖 Agent: Matches FireworkRocketItem.use: dropAllLeashConnections(null) while fall-flying, then LEAD_BREAK when something dropped. I fixed the ignored resource test, which no longer compiled after main made the audio capture a struct (86d09bf). Union build passed.
2026-11-05 04:57:02 +01:00
Horizon agent 10209ee6bb Merge remote-tracking branch 'origin/main' into cg42b/8700 2026-11-05 04:57:00 +01:00
Ludovic Marie f9d353f860 Merge pull request #9450 from DUzzL/fix/9437-consume-no-swing
🤖 Agent: The tamable arm was the wrong way round: Wolf/Cat/Parrot.mobInteract answer SUCCESS for sitting down, and for food that heals a hurt tame wolf or cat or tames a cat, so those swing. AbstractHorse.fedFood and Panda answer SUCCESS_SERVER (no client swing). Fixed in a pure mob_swings with a test (0d8d49d), main merged. Union build passed.
2026-11-05 04:56:58 +01:00
Ludovic Marie baa7e75c90 Merge pull request #9405 from degepie/fix/dp-block-nbt
🤖 Agent: I merged main. block_entity_nbt now reads a live moving piston first, as #9617 made /data do, so block predicates see the same NBT (de85911). Union build passed.
2026-11-05 04:56:54 +01:00
Ludovic Marie 993843b164 Merge pull request #9403 from degepie/fix/dp-entity-tags
🤖 Agent: I merged main and changed one thing: a registry whose pack tags fail to load now keeps the game's tags. Before, it stored an empty table, so even vanilla's #tags matched nothing in advancement predicates (167f156). Union build passed.
2026-11-05 04:56:49 +01:00
Ludovic Marie 50c220db84 Merge pull request #9304 from degepie/fix/dragon-death-vibration
🤖 Agent: Matches EnderDragon.kill and tickDeath: remove, then gameEvent(ENTITY_DIE). I merged main, which now asks is_fight_dragon before the dragon is taken (0b6621e). Union build passed.
2026-11-05 04:56:46 +01:00
Ludovic Marie 9c09a461b8 Merge pull request #9349 from TheMrZZ/fix/h293-mob-team-command-names
🤖 Agent: I merged main (corpus groups from both sides kept, every def checked present). Mob command names now take the team's prefix, suffix and colour, as Entity.getDisplayName via PlayerTeam.formatNameForTeam. Union build passed.
2026-11-05 04:56:42 +01:00
Ludovic Marie e8b8a0103f Merge pull request #9356 from TheMrZZ/fix/advancement-world-loot-rewards
🤖 Agent: Advancement loot rewards now read the world's data-pack tables, as ServerAdvancementManager's reward resolution does through the reloadable registries. Union build passed.
2026-11-05 04:56:38 +01:00
Ludovic Marie 9c3c363f0d Merge pull request #9397 from NikitaMGrimm/entity-own-phase
🤖 Agent: Item bob and end crystal phases come from the entity's own random, as the ItemEntity and EndCrystal constructors draw them, instead of a shared stream. Union build passed.
2026-11-05 04:56:34 +01:00
Arslan ArmanulyandClaude Opus 5.5 a66f8fa25a Shader packs: the shadow pass draws only terrain that can shade the view
Our probes' Iris 1.11.7 shadow maps (shadow.culling unset) fit keeping
only the sections whose shadow can fall into the camera's view: under a
noon sun a wedge of chunks in front of the camera, with a low sun ahead
nothing behind it. Horizon's held every section in the light's box.

For Iris-format packs, the shadow pass now also culls sections with the
camera's frustum stretched without end toward the light (the faces turned
toward the light, plus a plane along the light through each silhouette
edge), and tests whole 16-block sections there, a guess that matched our
probe with the camera looking straight up. shadow.culling=false and the
voxel distance of shadow.culling=reversed keep everything, as before.
Horizon's own packs are unchanged.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 08:56:30 +05:00
Ludovic Marie 244e5320b0 Merge pull request #9396 from NikitaMGrimm/frozen-sky-flash
🤖 Agent: Matches ClientLevel.tick: the sky flash fades every tick, and the world border lerps only while the level runs. Union build passed.
2026-11-05 04:56:30 +01:00
Ludovic Marie 2eb57c2ad6 Merge pull request #9151 from TiggerTakkyTTT/test/server-api-observability-goldens
🤖 Agent: Test-only: the uptime line is masked to <dynamic>, the rest of the metrics text stays exact. Union build passed.
2026-11-05 04:56:26 +01:00
abit-foggy 6d295f04b8 Merge remote-tracking branch 'upstream/main' into codex/studio-a2a3-browser 2026-11-04 17:53:09 -10:00
Ludovic Marie f5565f1a3c Merge pull request #9075 from TiggerTakkyTTT/test/server-api-moderation-goldens
🤖 Agent: Test-only: pins the moderation replies' status codes and bodies with independent literals. Union build passed.
2026-11-05 04:50:53 +01:00
Ludovic Marie 740d472acb Merge pull request #9072 from TiggerTakkyTTT/test/server-api-event-goldens
🤖 Agent: Test-only: the event replies' goldens are written by hand, so a serializer change has to show up here. Union build passed.
2026-11-05 04:50:49 +01:00
Ludovic Marie 437ff774c7 Merge pull request #9062 from TiggerTakkyTTT/test/server-api-config-goldens
🤖 Agent: Test-only: the config replies are pinned against literal JSON, not the serializer under test. Union build with 44 other PRs passed.
2026-11-05 04:50:45 +01:00
abit-foggyandCodex bb61e27267 fix(studio): harden recording privacy
Exclude packets that carry player identity or private text, randomize entity UUIDs in recorded AddEntity packets, and fail closed on unrecognized packets. Keep recording metadata free of server addresses and player names. Correct the recordings browser tooltip to match its current behavior.

Co-Authored-By: Codex <[email protected]>
2026-11-04 17:48:42 -10:00
Florian ERNSTandCodex 9fbc1c6398 Dispatch puffed pufferfish mob contacts with NoAI
Reuse the existing contact helper from the NoAI Mob.tick branch without changing ordinary-AI order or accepted-hit handling. Add actual Mob.tick and guarded Game/Java contact regressions; retain the independent poison-duration diagnostic.

Co-Authored-By: Codex <[email protected]>
2026-11-05 04:47:11 +01:00
Ludovic Marie 36134aa077 Merge pull request #9640 from voidsipp/fix/9346-sculk-setup
🤖 Agent: Ten inactive ticks in a row is longer than any vibration in flight takes to arrive inside the 8-block range, so the walk starts from a quiet sensor. The movement and activation checks on both clients are unchanged, and the three trace tests cover the vibration still travelling, a second vibration and a setup that never settles.
2026-11-05 04:46:09 +01:00
VaspyyyandClaude Opus 5.5 1174737b8f Collision scan: one memo for the last cell's chunk and section
#9598 made far moves cheap by passing empty sections and missing
chunks a row segment at a time, but every cell of a mob's box paid for
it: a second cache search on top of the reach cache, and more state in
the cell loop. On the ground a mob's move cost about 7.5% more
instructions than before #9598.

The scan now keeps one memo, the last cell's chunk with its section's
facts (empty, may reach out), so a cell in the same chunk and section
costs one comparison. Both facts live in one eight-section cache,
asked once per section. Where the world's chunks stay as they are for
the scan (missing_chunks_stay_missing), it also keeps the chunk before,
so a row across a chunk border goes back and forth without asking
again. Worlds without that promise ask as vanilla's BlockCollisions
does, including again after a missing chunk.

Fixes #9653

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:45:11 +01:00
Ludovic Marie cfe6b9ef76 Merge pull request #9638 from KBroneZ/claude/fix-9629-coverage
🤖 Agent: The headline is counted from the rows' own status, class and tier, so it can't disagree with the numbers under it, and the launcher's tr() lines are tested against Summary::lines. The per-row plain sentences are still English report data, like `reason`; translating them is #9644.
2026-11-05 04:42:16 +01:00
Horizon agentandClaude Opus 5.5 266d52a6cd Mobs: dolphins hold their breath, copper golems breathe under water
Dolphins ran WaterAnimal's air rule, which set their air to 300 every tick
in water: air never fell under 140, so they never surfaced for air nor
drowned, and #9271 now sent that 300 to every viewer. Dolphin's
handleAirSupply is empty and it isn't in #can_breathe_under_water, so it
now takes LivingEntity.baseTick's air from its 4800 (and stops taking
drowning damage on land on top of drying out). The breathing list follows
the tag: copper golems in, ghasts, blazes and snow golems out (no override
keeps their air; the iron golem's decreaseAirSupply does). A saved mob
without an Air tag loads with its species' maximum (Entity.load), not 300.
From martipops's review of #9271.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:33:32 +01:00
Horizon agentandClaude Opus 5.5 7218bd0680 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:33:32 +01:00
Horizon agentandClaude Opus 5.5 37477f99ec Sleeping block entities: tests run the real tick on every idle case; chunk unloads keep sleepers asleep
The idle tests restated the predicate. The furnace, brewing stand and
campfire tests now run the tick the block runs on each idle case and
check nothing changed, and that an awake one changes, so an idle() that
drifts from its tick fails. Unloading chunks used retain, which wakes
every sleeping block entity of the level, so with moving players most
idle machines ticked anyway: unload removes only the gone ones. From
martipops's review of #9239.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:33:13 +01:00
voidsip ba1e658704 Resolve bound sounds from live indexed entity state 2026-11-04 21:31:59 -06:00
Horizon agentandClaude Opus 5.5 5890a75541 docs: Host carries play_sound and send_particles; change_mod_block_entity on a client is local
Also fixes the borrow in drop_resources_now from the machine-tool commit
(the target check is a bool now, so popping can borrow the level).
From martipops's review of #9222.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:29:55 +01:00
TheMrZZandCodex gpt-6.1-sol b696d86470 Merge current main while retaining Allay healing coverage
Keep all upstream corpus groups and restore the Allay fixture area after its measurements.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 04:26:05 +01:00
ElyTheCat be4c480e9c Merge branch 'main' into perf/entity-occlusion-culling-pipeline
# Conflicts:
#	crates/horizon-client/src/game/item_draw.rs
2026-11-05 06:53:56 +03:30
ElyTheCatandCodex gpt-6.1-sol e62b4a1eae perf(entity-culling): hide entities in sections the graph never reached
Reject an entity whose padded render bounds lie entirely in terrain sections the renderer's visibility graph cannot reach, before any per-entity occlusion query runs; the graph must know the camera's own section, and unknown cells fail open to the existing paths. Add tests for the graph's answer and the bounds-to-section span, and tidy the fully-qualified occlusion calls in the culling tests.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 06:51:14 +03:30
HrafnaValkyrjaandClaude Sonnet 5.5 051322cd22 Entity events: the particle table decides which ones draw
The client forwarded an entity event to the particle code only if its id was
in a hand-written list in remote.rs. The table that says what each event draws
(entity_event_burst) already had rows for villager 42 (raid sweat) and dolphin
38, but the list did not, so neither showed. The list repeated the table and
had drifted from it. It is removed: entity_event_particles already looks the
event up in the table and draws nothing when there is no row.

Fixes #8589

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-04 22:19:45 -05:00
Horizon agentandClaude Opus 5.5 af82f65855 mod API: scenes shade and cull block entity quads as the world does; a stale state id can't crash
Mod quads, cubes, items and text come with their colour baked; the scene
shaded them a second time (an east face of a cube at 0.30 against the
block model's 0.6, glowing text dimmed). It now shades only what
entity.wgsl shades (|normal.w| >= 0.5) and culls only what it culls
(CULL_BACK, SHADED_CULL_W), so a flat quad or text seen from behind and a
mirrored part draw as in the world. A degenerate quad takes its vertex
normal. A state the registry doesn't know no longer panics in the face
culling. The docs list the scene's limits. From martipops's review of
#9229.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:19:25 +01:00
Horizon agentandClaude Opus 5.5 9f83f20c74 mod API: chunk tickets refuse chunks past the world's edge; held chunks generate between ticks
A ticket past /forceload's 30,000,000-block edge is refused (the ring's
arithmetic overflowed near i32::MAX). pump_chunks kept the workers fed
between ticks for /forceload only; a mod's held chunks get the same. The
docs say where tickets differ from NeoForge's and what they can't do yet
(lazy Nether/End, pausing servers, free owners, cost). From martipops's
review of #9226.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:18:41 +01:00
Horizon agentandClaude Opus 5.5 00922e5c8d mod API: a machine's tool rolls only its own block; no ore experience with silk touch
drop_tool and drop_into applied to every dropResources inside the
machine's with_level, so blocks popping off with the break (grass on the
dirt) dropped with the machine's tool and went into its list. They now
drop as with no tool and pop, as Level.destroyBlock does for neighbours.
A silk touch tool no longer collects ore experience
(EnchantmentHelper.processBlockExperience, after the xpRange draw), which
made an unlimited experience farm. From martipops's review of #9223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:18:05 +01:00
Horizon agentandClaude Opus 5.5 2ec49e8bc2 mod API: Host::equipment(MainHand) is the selected slot in an off-hand pass
held() is the acting hand: during the off-hand pass of a use it is the
off hand, so equipment(MainHand) answered the off-hand stack.
LivingEntity.getItemBySlot doesn't depend on the hand acting. From
martipops's review of #9222.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:17:18 +01:00
MegaBytesMe 6e3073863e Merge remote-tracking branch 'upstream/main' into codex/issue-9407-native-mods-arm64 2026-11-05 03:17:05 +00:00
voidsip 50cad6717c Wait for setup vibrations before checking player sculk movement 2026-11-04 21:14:00 -06:00
yahya-nusseibeh 0785a988df Relay: reserve a bridge slot at the match, give it back on every exit
max_bridges compared against bridges already running, so joins waiting
(up to attach_wait) for their host's attach all passed the check and a
burst could exceed RELAY_MAX_BRIDGES.

- BridgeSlot, taken under the lookup lock when a guest is matched with
  its host, counts toward max_bridges from then on; dropping it gives it
  back, so every way out of a join (no attach, a refused or forged
  attach, an error inside bridge(), a panic) releases it.
- counts().bridges now includes matched joins awaiting attach.
- tests/bridge_slots.rs: a waiting join holds its slot (a second guest
  gets Full); the count returns to 0 after a timeout, after a forged
  attach, and after a bridge closes; refusals never take one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 91d35fcae0)
2026-11-05 04:11:20 +01:00
yahya-nusseibeh e42e8b5497 Ban lists: checked arithmetic for hand-edited dates
parse_date multiplied and added the fields of banned-players.json and
banned-ips.json dates as plain i64, so a huge year, month, day or time
panicked in a debug build and wrapped in a release one, turning a ban's
expiry into an arbitrary date. Every step is now checked, years are bounded
before days_from_civil, and a date out of range is None, like one that
doesn't parse. Lenient month overflow (month 13) still works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 184ea41194)
2026-11-05 04:11:20 +01:00
yahya-nusseibeh 0b974a2f9c Write token files through a private temporary file and a rename
write_private (the control API's .horizon/api.json, the game's control
endpoint, the management protocol's generated key) opened the path with
create+truncate and mode 0600, which applies only when the file is new:
an existing, wider-readable file kept its permissions and got the new
token, and a link at the path was followed.

- horizon_server_files::private_file::write_private, shared by
  horizon-server and horizon-client: writes a create_new 0600 temporary
  file beside the path, syncs it and renames it over the path (a link
  there is replaced, not followed); the temporary file goes on failure.
- Tests: an old 0644 file comes out 0600; a link's target is untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit 9cb9d76054)
2026-11-05 04:11:20 +01:00
yahya-nusseibeh 55345332a3 Game control endpoint: draw its token from the OS random generator
The TCP control endpoint's token (it gates full control of the running
game) was two SipHash outputs of RandomState over the time and the process
ID. RandomState's keys are OS-seeded, but std doesn't promise it is a
cryptographic generator. It now comes from getrandom::fill, as
horizon-server's and the relay's tokens do (getrandom 0.3, already in the
lockfile, added to horizon-client-lib, which compiles control.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stH5q6PgMAVUSmJqKDe
(cherry picked from commit aa89153fc0)
2026-11-05 04:11:20 +01:00
TheMrZZandCodex gpt-6.1-sol e34841176c Heal Allays without enabling their AI
Keep passive healing on the ten-tick entity cadence for NoAI Allays. Reuse the existing heal operation and preserve normal-AI ordering and the independent duplication countdown.

Add guarded command-oracle coverage for both AI modes and health-cap boundaries, plus a reachable Mob tick regression.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-05 04:10:38 +01:00
Horizon agentandClaude Opus 5.5 86d09bfcb7 Read the captured lead snap through CapturedSound's fields
Main made the audio capture a struct; the ignored resource test still
compared tuples and no longer compiled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:08:08 +01:00
Horizon agent eb7e1af600 Merge remote-tracking branch 'origin/main' into cg42b/9364 2026-11-05 04:07:48 +01:00
Horizon agent 95124d4a75 Merge remote-tracking branch 'origin/main' into cg42b/9421
# Conflicts:
#	launcher/src/wardrobe/tests.rs
2026-11-05 04:07:33 +01:00
Horizon agentandClaude Opus 5.5 3bbcda7ad5 Carry an ore vein's positive_y through the shared rule's biome fold
Main gave CompiledRule::OreVein the y bounds of its positive density; the
fold copies them, as they depend on the rule and not on the chunk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 04:05:37 +01:00
Horizon agent a434181f20 Merge remote-tracking branch 'origin/main' into cg42b/8918
# Conflicts:
#	crates/horizon-client/src/mod_api.rs
2026-11-05 04:01:46 +01:00
Horizon agent ba520f9237 Merge remote-tracking branch 'origin/main' into cg42b/9450
# Conflicts:
#	crates/horizon-client/src/game/remote_mob_use.rs
2026-11-05 04:00:26 +01:00
Horizon agent 5ef75a930e Merge remote-tracking branch 'origin/main' into cg42b/9427
# Conflicts:
#	crates/horizon-world/src/worldgen/material_stage.rs
2026-11-05 03:58:07 +01:00
Horizon agent 7c3e86ec82 Merge remote-tracking branch 'origin/main' into cg42b/9349
# Conflicts:
#	tools/command_exec_corpus.py
2026-11-05 03:53:56 +01:00
Horizon agentandClaude Opus 5.5 de859115ed Merge origin/main into #9405; block_entity_nbt reads a live moving piston first
#9617 made /data read a live moving piston's block entity before the ordinary
ones. The shared getter keeps that order, so block predicates see it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:53:15 +01:00
Horizon agentandClaude Opus 5.5 167f156560 Merge origin/main into #9403; a registry whose pack tags fail keeps the game's own
A load_tags error used to store an empty table for that registry, so every
#tag of it, vanilla's included, matched nothing in advancement predicates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:52:41 +01:00
KBroneZandClaude Sonnet 5.5 32a64fc70c feat(launcher): say in plain words whether a modpack will work
The coverage report read "0 ported, 52 partial, 264 not yet" with reasons such
as "needs mod API 0.4". A player could not tell what comes over.

coverage.md and the Modpacks page now open with a headline counted from the
report's own rows and tiers: how many of the mods that need porting come over,
how many more Port can carry over, how many need mod support Horizon does not
have yet, and how many are not sorted yet; plus what "partial" means. Each row
gets its reason in plain words (`plain` in coverage.json, a last column in
coverage.md, a line above the technical one on the page). Counts, statuses and
the technical `reason` are unchanged. An older coverage.json without `plain`
still reads.

The three headline lines go through tr() with English values in the language
tables; the per-row sentences are English, like the reasons they sit beside.

Fixes #9629

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-05 04:52:39 +02:00
Horizon agent 0b6621e274 Merge remote-tracking branch 'origin/main' into cg42b/9304
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-05 03:52:07 +01:00
Florian ERNSTandCodex 79271db8c0 Refresh crafting-priority branch from main
Keep isolated recipe worlds, advancement controls and current shared command fixture routes together.

Co-Authored-By: Codex <[email protected]>
2026-11-05 03:47:08 +01:00
Horizon agentandClaude Opus 5.5 0d8d49d825 Swing for a tame wolf, cat or parrot sitting down, and for food that heals or tames
Wolf.mobInteract and Cat.mobInteract answer SUCCESS for sitting down, for
food that heals a hurt tame one and for food that tames a cat, before
Animal.mobInteract is reached. AbstractHorse.fedFood and Panda answer
SUCCESS_SERVER, which swings on the server only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:46:03 +01:00
Florian ERNSTandCodex 50fd4a8d1f Refresh world-loot reward branch from main
Preserve the focused, full inline, named admission and valid-empty diagnostics alongside current shared command fixtures.

Co-Authored-By: Codex <[email protected]>
2026-11-05 03:44:35 +01:00
abit-foggyandCodex ba7f8a3f40 Merge upstream/main into codex/studio-a2a3-browser
Co-Authored-By: Codex <[email protected]>
2026-11-04 16:39:48 -10:00
Florian ERNSTandCodex 123d271505 Refresh crafter map branch while preserving shared item-use fixtures
Retain map metadata and max-scale controls alongside current fixture groups and explicit boat-use packet actions.

Co-Authored-By: Codex <[email protected]>
2026-11-05 03:37:57 +01:00
Florian ERNSTandCodex 37b303bb1d Keep reinforcement and moving-piston fixture options together
Co-Authored-By: Codex <[email protected]>
2026-11-05 03:33:55 +01:00
Florian ERNSTandCodex becdc3d7d3 Refresh reinforcement command branch from main
Keep current shared oracle and corpus fixtures alongside the unchanged H300 room, light observations and immediate/once diagnostics.

Co-Authored-By: Codex <[email protected]>
2026-11-05 03:33:35 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 acdf8d5304 docs: HDR design note: decisions for Ludovic with recommendations
Turn the open questions into three decisions, each with options, a
recommendation, the reason and the step it blocks. State what the HDR
pass does with a shaders/ pack's alpha today, and move the full-screen
peak and light-source info for post stages to the later additions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 03:29:53 +01:00
MegaBytesMe a0a3ac2524 Merge remote-tracking branch 'upstream/main' into codex/issue-9407-native-mods-arm64
# Conflicts:
#	docs/native-mods.md
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 02:28:56 +00:00
MegaBytesMe 2daf7fd6fe fix(launcher): statically link ARM64 Rust unwinder 2026-11-05 01:58:53 +00:00
abit-foggyandCodex 185826582d feat(studio): add saved recordings browser
Co-Authored-By: Codex <[email protected]>
2026-11-04 15:58:15 -10:00
ElyTheCatandOpenAI Codex 5e9c0a708f bench: add entity occlusion release captures
Add FPS-overlay captures from the release OpenGL benchmark for PR #8941. The measured source revision is d87992b.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-05 04:39:35 +03:30
Nikita Maximilian GrimmandClaude Opus 5.5 c724742f38 docs: HDR design note: clear coverage on blur, headroom read per frame
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:00:06 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 fc15467786 docs: HDR design note: HUD coverage in its own target, alpha left as today
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:57:02 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e770b6a684 docs: HDR design note: HUD in gamma on a unorm backdrop with explicit coverage, blur keeps light
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:52:59 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 207adf0d13 docs: HDR design note: linear blending in 0.2, exact HUD composite
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:42:18 +01:00
MegaBytesMe 1f55f9ecfc fix(launcher): select ARM64 resource tools for native builds 2026-11-05 00:41:18 +00:00
abit-foggyandCodex 99c629e96e feat(studio): add recording capture
Co-Authored-By: Codex <[email protected]>
2026-11-04 14:29:52 -10:00
ElyTheCatandOpenAI Codex d87992bdf3 perf(entity-culling): reduce occlusion query overhead
Skip doomed async work while the camera moves, reduce synchronous voxel-plane lookups, and cull dynamic block entities before generating their geometry. Reuse chest state and remove dye-prefix sorting from the block-entity hot path.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-05 03:23:26 +03:30
Nikita Maximilian GrimmandClaude Opus 5.5 10566037aa docs: HDR design note: per-channel tint factor for the vignette
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:46:43 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 66ec8716b3 docs: HDR design note: keep the vignette on light above white
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:44:02 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 670c72c42c docs: HDR design note: keep 0.1 on today's path, HUD as today, rebuild on frame changes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:41:01 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 cf82881271 docs: design note for linear light in HDR and shader API 0.2
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:29:14 +01:00
MegaBytesMe 52186ab1ff feat(launcher): build native mods on Windows ARM64 2026-11-04 21:40:29 +00:00
Delete228andCodex a085aa324b Merge main while preserving native and entity-bound ticking sounds
Keep the vanilla EntityBound reference/canPlaySound path alongside native-owned ticks. Extend the channel-backend regression to cover paused pitch/position, public stop, release and stop-all cleanup.

Co-Authored-By: Codex <[email protected]>
2026-11-04 23:40:18 +03:00
Delete228andCodex 0ec0e8e440 Merge current Horizon main into native world canvas PR
Preserve upstream reload-listener and native API exports beside the world canvas type. No rebase or force-push.

Co-authored-by: Codex <[email protected]>
2026-11-04 23:40:05 +03:00
Florian ERNSTandCodex c940740feb Isolate crafting parity runs and retain qualified advancement recipe IDs
Co-Authored-By: Codex <[email protected]>
2026-11-04 21:11:03 +01:00
voidsipandCodex 60244bf97e Update entity-bound sound port ledger after #9382
Co-Authored-By: Codex <[email protected]>
2026-11-04 13:59:12 -06:00
degepie b42692f7a2 Merge remote-tracking branch 'origin/main' into fix/dp-block-nbt
# Conflicts:
#	crates/horizon-client/src/advancements.rs
2026-11-05 00:54:47 +05:00
Florian ERNSTandCodex fff83b42e3 Merge current main before crafting-priority review fixes
Co-Authored-By: Codex <[email protected]>
2026-11-04 20:52:33 +01:00
GabrielNichols 99b0ab1f18 fix(port-pack): invalidate stale harness verdicts 2026-11-04 16:49:44 -03:00
Florian ERNSTandCodex 6650b343b3 Dispatch accepted command-hit reinforcements synchronously
Co-Authored-By: Codex <[email protected]>
2026-11-04 20:49:07 +01:00
DUzzL abe568d446 Don't swing the hand when a mob interaction is CONSUME on the client 2026-11-04 20:48:05 +01:00
Florian ERNSTandCodex 794a3c3531 Merge current main after frozen reinforcement checks
Co-Authored-By: Codex <[email protected]>
2026-11-04 20:32:32 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 5cea1b3475 Math.sin and Math.cos on Windows round like vanilla's
jmath::sin and jmath::cos used the platform's libm on x86-64, which on
Linux (glibc) rounds like HotSpot's x86-64 intrinsics for all but about
0.2% of arguments. Microsoft's rounds differently for about 3%, so on
Windows item drops, mob goals and worldgen features came out a last bit
off vanilla's, and drops_match_vanillas_motion_bit_for_bit failed there.

On Windows they are now correctly rounded (jmath::exact, double-double
arithmetic with a fast path), which HotSpot's are for all but about 0.1%.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:25:34 +01:00
Florian ERNSTandCodex a8f829f9c8 Merge current main before reinforcement dispatch change
Co-Authored-By: Codex <[email protected]>
2026-11-04 20:16:08 +01:00
Arslan ArmanulyandClaude Opus 5.5 e7e4c7c2d3 Shader packs: terrain parts after a failed part, program.enabled, buffer sizes
Review of #9176:

- A split part whose program exists but doesn't load, or whose pipeline
  can't be built, no longer leaves the other part drawing alone: neither
  draws, and the game draws all terrain, as when an unsplit pack's terrain
  program fails. Iris 1.11.7 turns the whole pack off for our probe whose
  gbuffers_terrain_cutout doesn't compile.
- A part with no program in its chain still draws nothing while the other
  draws alone, now with a note: Iris 1.11.7 left the other part undrawn
  with our probes having only gbuffers_terrain_solid, or only
  gbuffers_terrain_cutout.
- Loader::has honours program.<name>.enabled, as load does: Iris drew all
  terrain with gbuffers_terrain for our probe that turns its
  gbuffers_terrain_solid off with program.gbuffers_terrain_solid.enabled=false.
- Post::every_program lists terrain_cutout, so buffer_minimums counts the
  storage blocks the cutout program binds.

Part of #9137

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 00:15:41 +05:00
Nikita Maximilian GrimmandClaude Opus 5.5 ea94327688 HORIZON_STILL: a native mod's chat line too, a warning in the log, wait a second after a step
From review: a native mod's chat line was stamped with the client's
ticks while chat follows the world's under the switch, so it could
vanish at once. The switch now logs a warning when it is on, the
docs say to wait a second after a step (the server's time arrives
once a second), and reseed's doc comment no longer sits between
random_glyph and its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 20:07:42 +01:00
Florian ERNSTandCodex 2edaa4fad5 Merge current main and retain weather and light observations
Co-Authored-By: Codex <[email protected]>
2026-11-04 19:59:44 +01:00
Florian ERNSTandCodex 53090512e3 Add frozen reinforcement admission controls
Observe spawn-rule light and live chance/accepted-hit prerequisites before changing event dispatch.

Co-Authored-By: Codex <[email protected]>
2026-11-04 19:59:06 +01:00
Ludovic MarieandClaude Opus 5.5 1606b87f12 Cosmetics: Horizon players' mark by their names; the founder badge after the name
Like Lunar Client's: Horizon's mark (two white bars over a violet line, horizon_cosmetics::mark)
before the names of other Horizon players, in the name tag (both views) and the player list. Who
plays Horizon comes from the cosmetics service in the same cached /v1/worn answers ("horizon": a
list of the batch's UUIDs beside "players", which released games ignore): no new request, nothing
on the wire to Minecraft servers.

- Extras > Cosmetics: "Horizon logos" and "Show me as Horizon", both on by default. On, the game
  signs in (Mojang join, as for My cosmetics) and sends POST /v1/presence at start and every hour;
  off, it sends listed=false and the service forgets the player at once.
- A name tag style's badge moves after the name (the mark goes before it); the launcher's Wardrobe
  draws the name tag the same way.
- Name tag mark and style line in the HUD batch's RGBA: the style line drew #a98fff as pink.
- The player list keeps a mark column once anyone listed has it, so names stay aligned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:58:07 +01:00
Florian ERNSTandCodex gpt-6.1-sol 109256c0fb Merge main and retain map and weather observations and oracle guards
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 19:57:09 +01:00
JulesB40andClaude Opus 5.5 c9642c76a0 Worldgen: a shared rule that fails to compile returns before its bindings are walked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:56:23 +01:00
JulesB40andClaude Opus 5.5 609e5306c1 Worldgen: the shared surface rule binds each chunk's densities
The world's surface rule holds the ore veins, whose densities belong to
each chunk's volume, so every chunk fell back to compiling the rule. The
shared rule now keeps the densities compile binds, in its order, and each
chunk's worker binds them to its own volume the same way: the indices,
and the samples, are compile's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:56:22 +01:00
JulesB40andClaude Opus 5.5 150a1013d2 Worldgen: compile the surface rule once per generator worker
Every proto chunk compiled the world's surface rule twice (build_surface
and apply_carving_mask): a deep copy of the rule tree, every biome
condition's names normalised into a new set, its biome ids looked up. A
generator worker now compiles it once, without the chunk's possible
biomes, and keeps it with the noises it bound; each chunk's worker takes
it and folds the biome conditions its possible biomes decide, as compile
folds them, so the tree, its bands and every block are the same. Rules
that bind densities, workers that already hold rules, and possible biomes
the surface doesn't number compile as before.

Fixes #9218

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:56:22 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e3ce04dde2 HORIZON_STILL: the smithing screen's slot icons wait too; wait after a step
An open smithing screen cycled its empty-slot icons on the client's
ticks while frozen. And the docs now say to wait a moment after a
/tick step before a capture, as after a turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:55:58 +01:00
MegaBytesMe 9e9b1a72ca Merge remote-tracking branch 'upstream/main' into codex/issue-9407-native-mods-arm64 2026-11-04 18:54:19 +00:00
NerfPhaselockandClaude Opus 5.5 740567a011 Renderer: a pack's displacement keeps CPU frames; a greyed GPU Culling reads Off
A native pack's `[displacement]` grid takes its quads from the walk's
`visible`, which in a GPU frame lists only the sections the CPU still
draws, so near relief faces went missing: such frames now take the CPU's
path, and docs/graphics.md lists it with the other exceptions.

The GPU Culling switch reads Off wherever it isn't offered, as Translucent
Batching and Smooth Gradients do, so the box, the narrator and the widget
tree no longer report a saved On; the UI test checks the switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:53:47 -05:00
martipopsandClaude Opus 5.5 c269066ca3 Native mods: a fake player's use closes what it opened
A machine's fake player restored its swapped fields but never ran the close
side of the use. A trader stayed in its session, a vehicle kept its menu's
stand-in, and a shulker box's opener count stayed up. It now runs the real
close paths while the fake inventory is in place, and swaps station,
craft_table, ender_open, hook, experience and the respawn point too.

Closing keeps the use as a player's: the click counts, and the same events
and sounds fire. Refusing to open would need a gate at every open site.

Fixes #9409

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
2026-11-04 13:51:58 -05:00
itarqos5andClaude Opus 5 ad491f1bf0 perf(far terrain): a previewed column spends one span on a dampening run
`shade_sky` records a dampening run's skylight a span per light level, so
a sea 15 blocks deep or more becomes 16 spans to carry a gradient down
its own side. Over real seed-1234 terrain that is 64% to 78% of every
span a far column stores: 18.7, 12.3, 12.0 and 12.6 spans a column at
tile levels 0, 2, 4 and 6, where the runs themselves are 4.1 to 4.5.

A previewed column now spends one span on the run and drains the same
light out of the bottom of it, so every span under the run is lit exactly
as before -- which is what the per-level walk was for ("lit seabeds and
the ground under canopies"). It reaches no face that is drawn: the spans
inside a run have neither a top nor a bottom face (`hides` matches on
state, not light), and a run's side is exposed at its top, where its
light is brightest, with `add_side` raising anything above the
neighbour's top to the neighbour's own sky.

Chunk columns keep the per-level walk. They are what `Column::reduced`
merges, and a reduction takes the brightest light of each slice, so one
bright span for a whole sea would light its bed as brightly as its
surface. A previewed column already stands for a whole far cell and is
never reduced against its neighbours.

Measured on 4448 far tiles at lodDistance 256, Full far detail, two runs
each with the heap census: `Far sampling` 302.9 and 309.6 MiB against
264.5 and 268.2, and the resident set 1386 MiB against 1252 and 1242.
Four pinned views are identical across the change to within the
animation noise floor, while two runs of the same build differ by 7% of
their pixels -- far-tile resolution is not reproducible between
launches, so the controls are in the pull request.

`shade_runs` takes the block-state test as a predicate so the shading
can be tested without prepared resources.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-04 18:50:56 +00:00
Florian ERNSTandCodex gpt-6.1-sol 06cc65f75a Merge main and preserve complete mob-name and weather corpus functions
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 19:50:24 +01:00
phexuss e49d129642 fix(launcher): reuse the Wardrobe session until it expires 2026-11-04 19:44:18 +01:00
Florian ERNSTandCodex 13864e6417 Merge latest main before H295 submission
Co-Authored-By: Codex <[email protected]>
2026-11-04 19:34:11 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 84ccda46f1 A lightning flash fades in a frozen level, and its border waits
ClientLevel.tick fades the sky flash on every tick, and moves the world
border's size change only while the level runs normally. The client did
it the other way round: after a /tick freeze, a flash that had just
struck lit the sky for as long as the level stayed frozen, and a border
change went on shrinking or growing on this side while the server's
held.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:31:03 +01:00
phexuss e2c6c17bd7 fix(skins): keep the skin when only its cape fails, and retry failed textures 2026-11-04 19:30:18 +01:00
DUzzL 05a8366017 Refuse breaking game master blocks without game master permission 2026-11-04 19:29:55 +01:00
Florian ERNSTandCodex f234c3adc3 Merge main and isolate crafting-priority fixture worlds
Preserve advancement and recipe fixture catalogs; the standalone parity runner prevents overriding recipes from affecting generic command-count controls.

Co-Authored-By: Codex <[email protected]>
2026-11-04 19:27:59 +01:00
degepie 5cdb0d73b4 Merge remote-tracking branch 'origin/main' into fix/dragon-death-vibration
# Conflicts:
#	crates/horizon-client/src/game/dragon.rs
2026-11-04 23:27:16 +05:00
Nikita Maximilian GrimmandClaude Opus 5.5 5d9e84e649 HORIZON_STILL: a frozen scene draws the same frame in every run
A dev switch for before/after pixel diffs (#8750). The world starts frozen
before its first tick, and what the frame shows of time follows the
world's ticks: animated textures, the glint and world border scroll, the
darkness pulse, the dragon's crystal beam, the player model's idle swing,
the HUD (hearts, chat, titles, the locator bar, the tab list), a shader
pack's clock and randoms (smoothed uniforms at their targets, custom
uniforms' random, cave mood, obfuscated text, a spinning clock or compass)
and the camera's partial tick. While frozen, the client's own entity
effects and countdowns wait for the world too, as a frozen level's
entities don't tick. `/tick step N` moves the scene on by whole ticks.
Off by default; nothing changes without it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:24:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 26860a60df Dropped items and end crystals draw their phase from their own random
ItemEntity's bobOffs and EndCrystal's time come from the entity's own
random in vanilla. The client drew them from the level's random, shared
with splashes, eating and other client effects, so the same dropped item
could bob and spin from a different phase from run to run. Each now draws
from a random seeded from its id and position, as a client mob's is
(Mob::new, now mobs::entity_random).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 19:24:17 +01:00
Florian ERNSTandCodex gpt-6.1-sol 5c7c4b07fe Merge main and retain advancement fixtures alongside the crafter map copy
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 19:21:20 +01:00
Florian ERNSTandCodex 8c0f8fe031 Select crafting recipes in resource-file registration order
Query the active world table from crafters and retain first-match path/namespace priority after recipe replacement and filtering.

Co-Authored-By: Codex <[email protected]>
2026-11-04 19:18:36 +01:00
TheMrZZandCodex gpt-6.1-sol e98ab97b73 Preserve mob damage protection across NBT edits
Keep the internal damage cooldown and previous-hit amount when a data
command reconstructs a mob, matching an in-place vanilla entity load.
Continue loading explicitly edited Health and HurtTime values from NBT.

Add guarded command regression cases for equal hits, stronger hits,
unchanged writes, fresh actors and measured cooldown expiry.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 19:17:26 +01:00
Florian ERNSTandCodex gpt-6.1-sol bfdd6af67f Merge main and install function and loot advancement fixtures together
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 19:16:14 +01:00
oxi-does-dev 487d76b3f2 Run client mod commands from the control socket
The control socket's chat line went straight to the server on a connected world, skipping the client mod commands, the Chat option's restrictions and the recent list that typed chat goes through. It now uses handle_chat_input like the chat screen, so /modcmd opens the mod's screen on a server and the rest of the line is sent only when no mod owns it.

The control socket's command line also tries a client mod's command before the server console: the console cannot see a mod that is compiled into the client, so hudplus or another client mod's command failed there.

AI agent: oxi-does-dev via OpenCode, GLM 5.3.
2026-11-04 21:10:01 +03:00
gigamans442andOpenAI GPT-6.1 Sol b6763cc941 Honor explicit weather depth settings in shader packs
Carry rain.depth through Post into weather pipeline depth writes.
Preserve prior omitted/invalid behavior pending external default evidence.
Add original property and hardware GPU regression coverage.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-11-04 13:03:21 -05:00
phexuss 22ca656476 fix(render): check a fetched PNG's size before decoding it 2026-11-04 19:01:29 +01:00
DUzzL 41be700c66 Fail dry Riptide trident use without dipping the hand (TridentItem.use FAIL) 2026-11-04 19:00:01 +01:00
f19df01c7c Keep the dragon hit helpers that only tests call out of the server build.
The game hits through hurt_part_from, so the no-attacker wrappers were dead code without cfg(test).

Co-authored-by: Cursor <[email protected]>
Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 18:56:29 +01:00
Florian ERNSTandCodex gpt-6.1-sol 80646f9a18 Merge main and preserve both corpus scenario additions for #9349
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 18:56:02 +01:00
Florian ERNSTandCodex eee1ffee60 Merge current main and preserve loot and bell fixtures
Co-Authored-By: Codex <[email protected]>
2026-11-04 18:55:13 +01:00
Florian ERNSTandCodex 9f5d2ea594 Merge current main shulker-space fix
Co-Authored-By: Codex <[email protected]>
2026-11-04 18:53:08 +01:00
Florian ERNSTandCodex 1c3dae2a17 Merge latest main before H298 submission
Co-Authored-By: Codex <[email protected]>
2026-11-04 18:52:48 +01:00
Florian ERNSTandCodex 7f0f1a93ec Merge latest main and retain both parity corpus groups
Co-Authored-By: Codex <[email protected]>
2026-11-04 18:51:40 +01:00
4af07cfcf1 Drop the needless return at the end of the frog water-travel test.
Clippy rejects a return that only ends a unit test.

Co-authored-by: Cursor <[email protected]>
Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 18:49:09 +01:00
Arslan ArmanulyandClaude Opus 5.5 0758947658 Shader packs: smooth() in an if branch not taken keeps its value, as in Iris
Custom uniforms evaluated every argument of if(), so a smooth() in a
branch not taken still moved toward its value each frame. Our probe pack
in Iris 1.11.7 (if(fmod(frameTimeCounter, 4) < 2, smooth(sq, 10), 0.5)
over a 16 s square wave) reads values that fit "frozen while the branch
isn't taken" within 0.008, and miss "updated every frame" by up to
0.14. if() now evaluates only the conditions up to the first true one
and that branch.

The same probes also time smooth() over a square wave from if(),
eyeBrightness, worldTime and rainStrength, fades 0.5 to 100 and the
default: their Iris values fit a half-life of 0.1 s per fade unit, as
custom.rs already has (a fit to our probes, not Iris's code). A test
pins worldTime steps and the default fade.

Fixes #9371

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 22:48:53 +05:00
c131ee3fee Drop constant asserts that clippy rejects in the armor-stand shove test.
The 0.2 gate is already checked by whether the cart is shoved.

Co-authored-by: Cursor <[email protected]>
Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 18:47:14 +01:00
ElyTheCat 3ec390a6b8 Merge branch 'main' into perf/entity-occlusion-culling-pipeline 2026-11-04 21:14:37 +03:30
Florian ERNSTandCodex 55a00b627a Merge latest main for H298 readiness
Co-Authored-By: Codex <[email protected]>
2026-11-04 18:40:16 +01:00
Florian ERNSTandCodex 53fef18ae4 Extend crafter maps using live saved-map context
Reuse map post-processing before dispensing and register the derived saved map. Cover actual map use, scheduled crafting, and backed/missing/max-scale controls.

Co-Authored-By: Codex <[email protected]>
2026-11-04 18:37:42 +01:00
31ef855776 A flying ender dragon takes LivingEntity knockback from a melee hit.
EnderDragon.knockback skips that push while the dragon is sitting, including a hit that makes it take off.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 18:28:34 +01:00
820b8fe8f5 Start a ridden pig or strider's boost when the client receives its total.
The rider's client simulates the mount, and ItemBasedSteering.onSynced starts that timer from the synced int. Without it the boost factor stays 1.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 18:28:09 +01:00
28778ffc3b client(fireworks): free leads when a glide boost starts
A firework used while fall-flying has to drop every lead on the player and play the snap, or the mob stays tied and nobody hears it.

Co-authored-by: Cursor <[email protected]>
Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 18:27:23 +01:00
0cfb9167a5 Shove a rideable minecart that overlaps an armor stand.
ArmorStand.pushEntities moves a plain minecart inside the stand when it is within 0.2, and the stand tick never did, so the cart stayed put.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 18:25:42 +01:00
34f506d0d1 A named item frame drops with its custom name.
ItemFrame.getFrameItemStackWithData copies the entity name onto the frame item before dropItem spawns it. Glow item frames use that same stack.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 18:25:28 +01:00
553d0d26e9 Swim frogs with Frog.travelInWater instead of generic fluid travel.
A frog in water was using LivingEntity.travelInFluid, so its speed and drag did not match vanilla.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 18:22:59 +01:00
Florian ERNSTandCodex 6a0ac7a198 Merge latest main for H292 readiness
Co-Authored-By: Codex <[email protected]>
2026-11-04 18:19:48 +01:00
1c615062f6 Apply worn mob_visibility when mobs measure detection range.
A skeleton skull, zombie head, creeper head or piglin head should halve how far that mob notices the player. getVisibilityPercent multiplies by the item's component, and the targeting paths only scaled sneaking and invisibility.

Co-Authored-By: Grok <grok-4.7> <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-11-04 18:17:34 +01:00
TheMrZZandCodex gpt-6.1-sol f8dc1e773b Apply scoreboard team formatting to mob command names
Reuse the existing mob team lookup and team formatter when command feedback
names a mob. Preserve its custom name or translated type and the player paths.

Extend the real command corpus with guarded zero-tick join, custom-name,
prefix/suffix clearing, color-only and leave controls against Java 26.3.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 18:12:27 +01:00
MSedgeMC d733ed6a34 test: the bed explosion harness watches a stand, an item and a boat
`tools/test_bed_explosion_parity.py` compared one victim: Alice's health after
the blast and the push her client is sent. What an explosion does to anything
else went unobserved, and `game/explosion.rs` is the one place in the game
where the entity side of `ServerExplosion` lives.

The scene now puts four things that are not the player in front of the bed
head, where no placement puts Alice or the obsidian wall, and gives each of
them `NoGravity` so the only motion any of them can get is the blast's own:

- `stand`: an armour stand two blocks from the head,
- `invisible`: an armour stand with `Invisible:1b` three blocks away
  (`ArmorStand.ignoreExplosion` asks `isInvisible`),
- `item`: a stone item entity four blocks away,
- `boat`: an oak boat two blocks to the side.

After the blast each one is read back with `/execute if entity` (did it
survive), and with `Pos` and `Motion` where it did. `compared()` keeps those
answers and gives the lists nine decimals the way
`test_server_knockback_parity.py` reads its mob: a blast writes arbitrary bits
into `Pos` and `Motion`, and NBT prints the shortest form that round-trips.

What the first corpus says in vanilla 26.3, in all twelve cases: the visible
armour stand, the item and the boat are destroyed, and the invisible armour
stand survives untouched at its summon position with zero motion - the
`ArmorStand.ignoreExplosion` rule, seen from the outside.
2026-11-04 17:06:26 +00:00
Florian ERNSTandCodex b722a4949d Merge latest main for advancement loot validation
Co-Authored-By: Codex <[email protected]>
2026-11-04 18:02:33 +01:00
4986dcaf60 Play the honey landing slide and show its particles.
HoneyBlock.fallOn plays the slide (and the block's fall sound when the landing hurts) and broadcasts entity event 54, ten honey crumbs. The guest turn dropped both, and clients only drew the five crumbs of a slide.

Co-authored-by: Cursor <[email protected]>
Co-Authored-By: Grok <grok-4.7> <[email protected]>
2026-11-04 17:57:13 +01:00
degepieandClaude Opus 5.5 0e2a6b8700 Advancement block predicates test the block entity's nbt
`BlockPredicate`'s `nbt` was skipped, so a predicate with it matched any
block of its `blocks` (and an `inverted` one never matched). As
`BlockPredicate.matchesBlockEntity` does, the pattern now has to be in
the block entity's `saveWithFullMetadata` (`NbtUtils.compareNbt`), and a
block without a block entity fails. The predicates' `World` asks for the
data `/data get block` builds, moved into `Game::block_entity_nbt`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 21:49:49 +05:00
degepieandClaude Opus 5.5 aac36f65dc Advancement predicates read a world's data pack tags
A `#tag` in an advancement predicate never matched a tag a world's data
pack defines: `View::in_tag` read the game's own item, block, entity
type, biome and damage type tags. The world's tables now also keep the
entity type, biome and damage type tags with the packs' overlay
(`TagLoader.loadTagsForExistingRegistries` binds every registry's tags
from the pack stack), and `in_tag` reads them, and the item and block
tags through `Game::items()`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 21:49:08 +05:00
Florian ERNSTandCodex dbabdeb01d Merge latest main for advancement loot lookup
Co-Authored-By: Codex <[email protected]>
2026-11-04 17:48:25 +01:00
Florian ERNSTandCodex a152e92cec Use world datapack tables for advancement loot rewards
Co-Authored-By: Codex <[email protected]>
2026-11-04 17:48:24 +01:00
BPMichonandClaude Opus 5.5 d5f4da2382 Native mods: health HUD state and local attribute helpers
A mod answering native_health_hud can now reach Horizon's HealthHud
(native_health_hud_state): its left_height/right_height place the selected
item name and the action bar (vanilla Hud.leftHeight/rightHeight), and its
layout keeps Horizon's own rows. native_local_attribute(name) reads an
attribute of this client's player (LivingEntity.getAttributeValue), such
as minecraft:armor_toughness. Both through a helper-only API fragment, so
mods no longer read Game's private fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BYf41fgTMRfUEXn3afqyTG
2026-11-04 16:47:16 +00:00
Arslan ArmanulyandClaude Opus 5.5 93afbf2fd8 Shader packs: split solid and cutout terrain per quad, by sprite layer
The split decided per vertex, and read lava from the high nibble of the
vertex colour's alpha. With separateAo=true that byte holds each vertex's
own ambient occlusion, so the corners of one leaf quad landed in different
parts: each part pushed its other corners off screen, and in our separateAo
probe the leaves drew as speckled slivers of both parts. Lava had no flag
there and went to the cutout part.

The test now reads only the vertex's sprite layer, which all four vertices
of a quad share: OPAQUE_LAYER, or one of lava's two sprite layers, which
the game hands the renderer. In Iris 1.11.7 our separateAo probe draws
leaves whole with gbuffers_terrain_cutout and lava with
gbuffers_terrain_solid; the branch now does the same.

Part of #9137

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 21:44:56 +05:00
NerfPhaselockandClaude Opus 5.5 a58946a336 Graphics: a GPU Culling setting, and its statistics read back from the GPU
GPU culling becomes a setting players can switch: GPU Culling
(horizon-graphics.txt's gpuCulling, off by default and in no preset) sits in
the Performance page's Culling group right after Cave Culling, and applies at
once: every settings apply calls set_gpu_culling, which does nothing when
nothing changes. Where it isn't offered the switch is greyed out, reads Off
and says why: the device can't (no count draws the GPU wrote, compute shaders
or working first instances, or vanilla frames drawn flat), DX12 with flat
page groups, whose count draws lose each draw's base vertex (not offered there
since #9142: "try the Vulkan graphics API"), a software adapter (WARP,
lavapipe), whose pass would run on the CPU beside the frame, or its buffers
stopped fitting in video memory, until the next renderer. macOS
doesn't show it. The control word gpu-culling on|off now sets the setting in
memory, unsaved, as lod-distance does, so the switch, the word and rebuilt
renderers agree.

The statistics are exact again. Each GPU frame copies the pass's two counters
(the sections it kept, their draws) into one of three small readbacks, with
the CPU's part of the same frame: the sections the walk listed less those
whose solid quads the pass drew and counted, its draws, and every call. The
readback is mapped from the frame's after-submit hook, on the submit thread
too, and taken in a frame or a few later; nothing waits for it. So
drawn_sections, section_draws and section_draw_calls always describe one
real frame, the newest whose numbers are known: a CPU frame's at once, a GPU
frame's when its counts come back, never an older frame's over a newer one's.
The status says how old they are (stats_lag) and what is offered
(gpu_culling_offer). A readback whose mapping fails, or whose buffer the
device lost, goes FAILED and is never copied into or unmapped again, as the
GPU timer's readbacks since #6354.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:38:47 -05:00
makscoder 9f2d88fb57 Let peaceful discard hostile mobs through Mob.checkDespawn, not instantly
Switching to peaceful removed every hostile mob in the same tick with
`mobs.retain(|m| !m.hostile())` in the difficulty command, the LAN/menu
difficulty change, and the dedicated-server start. Vanilla only sets the
difficulty there; each hostile mob is discarded by its own Mob.checkDespawn
on the next tick when the level is peaceful and the type is not allowed in
peaceful, so mobs in unloaded chunks persist until loaded and the wither and
ender dragon follow their own exceptions.

Drop the three retain calls: the per-tick check_despawn already discards the
same mobs (difficulty.effective == 0.0 && !allowed_in_peaceful), with vanilla's
timing and exceptions.

Closes #9283
2026-11-04 19:31:11 +03:00
Ludovic MarieandClaude Opus 5.5 463f3eff0b Test: World.sprite_rects (from #9057) in the terrain parts fixture
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:34:20 +01:00
Ludovic Marie b0546aee34 Merge remote-tracking branch 'origin/main' into cg32/fix-9176 2026-11-04 16:34:08 +01:00
Ludovic MarieandClaude Opus 5.5 6535ef4545 transfer: the frozen-chat test sends an ack that moves the window before a chat that counts on it
The test sent a chat, then an ack of offset 0, which validates in either
order. It now sends an ack of 1 and then a chat acknowledging the message
the ack brought into the window, which fails validation (and kicks) when
the ack is applied after the chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:12:47 +01:00
degepieandClaude Opus 5.5 189b8b2c95 Ender dragon: post GameEvent.ENTITY_DIE when it is removed (#8740)
EnderDragon.tickDeath (at 200 death ticks) and EnderDragon.kill (/kill)
both end with gameEvent(ENTITY_DIE) at the dragon; Horizon posted none.
Sculk sensors now hear the death, a catalyst within reach blooms (no
charge: the dragon's xpReward is 0), and on /kill a warden can hear it
too (after the death flight Warden.canTargetEntity refuses a dragon with
no health left).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 20:11:18 +05:00
AkirabaneandClaude Opus 5.5 51cd08cd3b transfer: the export empties the 2x2 grid, chat acks in order, a failed login gives its expectation back
martipops's second review of #8700.

The export closes the inventory's own 2x2 crafting grid too
(InventoryMenu.removed): guest_close_all empties it only on a real
ContainerClose(0), and a frozen client's is dropped, so its items were
in no record and lost with `player-release moved`.

A frozen player's chat acknowledgements are handled in the same pass as
the last-seen updates of its chat packets, in the order they came, so an
ack followed by a message in one tick still validates.

A login that used up an expectation and fails before it reaches play
(its connection drops in configuration, or the client refuses a
required pack) gives it back with its own deadline, so the proxy's next
attempt within that time is still expected.

The export's reply gives `freeze_ticks_left` beside the record while the
player is frozen, so a proxy does not commit on the target too late for
its release to come in time.

Refs #7484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 16:05:53 +01:00
degepieandClaude Opus 5.5 0a9aa16802 Name a server's data-pack enchantments by their synced description
Tooltips read the enchantment table built from the game's own files, so an
enchantment a server's data pack adds fell back to its raw id
("enchex:drill II"). The server sends that entry's definition in the
registry sync; join the entries the game lacks to the table the tooltip and
the enchanting clue read (Enchantment.getFullname), as client_biomes does
for biomes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 19:44:12 +05:00
rdbtCVSandCodex gpt-6.1-sol 1ca0b67be3 Merge main while retaining lifecycle and observability API goldens
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 14:10:41 +00:00
TiggerTakkyTTTandCodex d32a0cfbf8 Merge upstream main into auth API goldens
Resolve the api.rs test-module overlap while retaining the auth and command goldens alongside upstream lifecycle coverage.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 21:10:04 +07:00
rdbtCVSandCodex gpt-6.1-sol bbedf3b9ec fix(render): collapse skinned entity culling condition
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 13:55:34 +00:00
Mayonesu0andClaude Opus 5.5 2f6d9c7dc4 mobs: a powered wither refuses a player's wind charge
WitherBoss.hurtServer refuses a direct AbstractArrow or WindCharge once
isPowered. A wind charge hurts mobs as DamageKind::Thrown, which
wither::accepts let through, so every charge took a point off the armor.

The check is on the projectile, not the damage kind. A breeze's
BreezeWindCharge deals the same damage but is not a WindCharge (both only
extend AbstractWindCharge), so it still hurts, unlike what the issue asks.
/damage with the wind_charge type has no direct charge and goes through too.

tools/test_wither_wind_charge_parity.py compares the three cases against a
vanilla dedicated server.

Fixes #8779

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:54:22 +01:00
Arslan ArmanulyandClaude Opus 5.5 a50d9bbef4 Shader packs: draw solid and cutout terrain with gbuffers_terrain_solid and gbuffers_terrain_cutout
A pack with either program now draws terrain in two parts over the same
draws: the quads over opaque texels (and lava) through
gbuffers_terrain_solid, the cutout ones through gbuffers_terrain_cutout,
each falling back to gbuffers_terrain's chain. Solid terrain has no
default alpha test; cutout terrain keeps the 0.1 one. Packs with neither
program keep the single draw.

This is what Iris 1.11.7 drew with our probe packs (both programs, only
one of them, alpha 0.05 outputs, renderStage).

Fixes #9137

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 18:25:10 +05:00
TiggerTakkyTTTandCodex 6e1ce1b2a4 Server API: pin auth and command replies
Add HTTP goldens for bearer authentication, Host validation, command input errors, stopped-server rejection, and route misses.

Fixes #9156

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 20:07:37 +07:00
Ludovic MarieandClaude Opus 5.5 648630f0d3 dedicated: the [transfer] start-up error reads as one sentence
The message had a run of 18 spaces in the middle of the string (a line
break that was not a string continuation); a backslash continuation keeps
the source wrapped and the message on one line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:00:30 +01:00
ElyTheCatandCodex gpt-6.1-sol 8f5ba77ab3 Fix entity culling review safety cases
Bind hidden proof reuse to the exact camera and use render-complete bounds for non-mob entities. Keep the async worker active independently of the mob precheck and preserve shader-pack shadow spans.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 16:22:39 +03:30
AkirabaneandClaude Opus 5.5 020dc51cfe transfer: an opt-in [transfer] module, a hand-over id, and the review's fixes
Kacper's review of #8700 and martipops's on #8700 and #8730.

The module is off by default. `server.toml` gets a `[transfer]` section
(enabled = false, expect_seconds = 10, max_expect_seconds = 60,
max_expected = 64, max_freeze_seconds = 30) in place of the constants in
lan/arrivals.rs. horizon-server passes it to the game as `--transfers`,
beside `--control-socket`, not as a server.properties key. While it is off
every player-* request is answered "transfers are off on this server",
the hub has no table and a tick does nothing for transfers. With it on,
the game refuses to start without Velocity forwarding, so a player can
only come in through the proxy. The section is kept apart from vanilla's
accepts-transfers.

Record version 1 gets a `handover` id. An export with `freeze` starts a
hand-over: a random id, and the record kept with the player. A repeat
export while it lasts answers with the same id and the same bytes and
does not restart the freeze, so a proxy that lost the reply can ask
again. An export without `freeze` is a read, with a null id. The record
also carries the player's statistics (`stats`), as the stats file has
them.

The export now closes the player's menu in the player's own level, as
the close of every guest does on leaving, and refuses when that level
cannot be opened. It copies the live mod attachments into the player
first, so the compound is what a save would write. A player who is
leaving cannot be exported.

An expectation is used up when the login starts, on the network thread,
once the bans, the whitelist and the player limit let it in. A login
still in configuration when the time runs out keeps it.

A frozen player is not kicked for floating, and its chat and signed
commands are dropped after the messages they say the client has seen are
applied, so its chat still validates after the freeze; chat
acknowledgements are kept.

docs/server.md gives the section, who can ask (the endpoint's limits,
the token being all or nothing) and how a server reads a record: another
version or data version is refused, the NBT in `player` wins over the
plain fields, unknown fields are ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:49:51 +01:00
rdbtCVSandCodex gpt-6.1-sol 65b76b1a17 Merge main while retaining both frame culling and shader entity types
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:26:32 +00:00
AkirabaneandClaude Opus 5.5 aa4869c86c dedicated: player-expect and player-export on the control endpoint
The read side of a seamless transfer (#7484, step 2). A proxy moving a
player between two servers tells the target who is coming and reads the
player out of the source.

`player-expect <uuid> [seconds]` records that the player will log in
within that time (10 s by default, 60 at most, 64 players at once). It
refuses a bad id, a time that is not a whole number above zero, a player
already connected and a full table. The login uses the expectation up;
one no login used is reported when it runs out. The player still logs in
as anyone else: holding them is a later step.

`player-export <uuid> [freeze]` closes the player's menu as vanilla does
(AbstractContainerMenu.removed puts the carried stack back into a
connected player's inventory) and answers with record version 1: the
player compound as a save writes it (base64 NBT), the advancements JSON
and a few plain fields. It refuses a player who is not connected, has not
finished joining, is riding, asleep or dead. With `freeze`, the player's
own packets are dropped and its acknowledgements still handled, until the
freeze is let go after 600 ticks with a warning.

docs/server.md gives both requests and the record's shape.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:58:41 +01:00
TiggerTakkyTTTandCodex ce098ebe9d Server API: pin observability replies
Add JSON and Prometheus golden cases for status, logs, sampled metrics, cursor handling, and stopped or running health output.

Fixes #9083

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 17:31:18 +07:00
TiggerTakkyTTTandCodex 1ddca546f0 Server API: pin moderation replies
Add JSON golden cases for stopped-server operator and whitelist edits, no-op outcomes, unavailable running-only actions, and representative validation errors.

Fixes #9073

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 16:50:43 +07:00
TiggerTakkyTTTandCodex 4d260ccdac Server API: pin event replies
Add JSON golden cases for event polling, cursors, type filters, Last-Event-ID resumption, invalid selectors, and exact server-sent event framing.

Fixes #9066

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 16:33:56 +07:00
TiggerTakkyTTTandCodex a225724ed4 Server API: pin config replies
Add JSON golden cases for redacted config reads, stopped and running config patches, protected settings, malformed bodies, invalid ranges, invalid value shapes, and unknown settings.

Fixes #9056

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 16:20:04 +07:00
ElyTheCat abb852a057 Merge branch 'main' into perf/entity-occlusion-culling-pipeline 2026-11-04 06:37:50 +03:30
ElyTheCat df94a9512f Change OpenGL version in README
Updated the OpenGL version in the README from 4.6 to 4.2.
2026-11-04 06:34:36 +03:30
ElyTheCatandOpenAI Codex 70411290d6 Optimize entity occlusion before model generation
Add conservative AABB prechecks, a non-blocking two-stage async ray and plane pipeline, and reusable voxel/proof caches for dense scenes. Keep synchronous culling on the exact mesh path, expose an async-only threshold, and add focused tests plus release/OpenGL benchmark evidence for issue #7549.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-04 05:54:46 +03:30
deleteandCodex 77bb0af3b1 Exercise generated world canvas dispatch with shared draw state
Add the world canvas fixture type to workspace dispatch tests and verify ordered mutation through the generated event.

Co-authored-by: Codex <[email protected]>
2026-11-04 05:01:36 +03:00
deleteandCodex c0591d4215 Support tickable sounds owned by compiled native code
Apply owner-updated gain, pitch and position through ordinary sound-engine channels without attaching vanilla entity or music behavior. Verify paused updates, stop and release with the channel backend.

Co-authored-by: Codex <[email protected]>
2026-11-04 04:33:04 +03:00
deleteandCodex 4df6818e3a Add native world text and atlas quad canvas
Submit camera-relative world overlays independently of block entities, using normal depth-tested atlas and font rendering. Preserve RGBA quads and draw eight-offset outlined text with the existing polygon-offset front pass.

Co-Authored-By: Codex <[email protected]>
2026-11-04 04:32:59 +03:00
ElyTheCat 946bfb559c Merge origin/main into entity occlusion culling pipeline
Keep the latest main particle culling and graphics settings alongside the shared synchronous and asynchronous entity occlusion path.\n\nCo-Authored-By: OpenAI Codex <[email protected]>
2026-11-04 04:41:47 +03:30
ElyTheCatandOpenAI Codex f28e838da8 Invalidate worker voxel caches on snapshot refresh
Key retained ray and plane traversal grids by the snapshot lifetime, not only world revisions, so refreshed chunk visibility cannot reuse stale opaque cells. Add a regression test for a wall becoming transparent.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 20:34:24 +03:30
ElyTheCatandOpenAI Codex 2716f761c4 Remove obsolete occlusion test filename
The tests now live in occlusion_culling_tests.rs, matching the feature test naming convention.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 20:06:14 +03:30
ElyTheCatandOpenAI Codex d3ae3e9f46 Reuse async occlusion traversal buffers
Keep DDA worker scratch and result vectors across jobs, reset voxel state on snapshot changes, and move culling tests into standard feature test files.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 20:05:10 +03:30
voidsipandCodex 0f4313d19c fix(input): read unmapped Linux trigger axes
Read conventional Linux trigger axes when no trigger button is mapped.
Preserve mapped button input and exclude the 8BitDo stick-axis layout.
Cover pull values, absent input and mapping precedence.

Co-Authored-By: Codex <[email protected]>
2026-11-03 07:38:31 -06:00
ElyTheCatandOpenAI Codex 3ea5d68c2a Add configurable entity precheck slider
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 13:13:13 +03:30
ElyTheCatandOpenAI Codex d387eb8c3c Apply entity precheck threshold to both culling modes
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 13:03:52 +03:30
ElyTheCatandOpenAI Codex a38ef04585 Make entity precheck threshold configurable
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 13:02:30 +03:30
ElyTheCatandOpenAI Codex 7e22c998dd Split async occlusion into ray and plane workers
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:49:33 +03:30
ElyTheCatandOpenAI Codex d5abaf67c8 Fix async entity occlusion cache reuse
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:40:36 +03:30
ElyTheCat 6dca822e6a Merge remote-tracking branch 'origin/main' into perf/async-entity-occlusion-tuned 2026-11-02 22:37:03 +03:30
ElyTheCatandOpenAI Codex 37380065b7 Optimize async entity occlusion hot paths
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 22:34:15 +03:30
ElyTheCatandOpenAI Codex 4e069b1e21 Merge origin/main before occlusion performance follow-up
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 21:04:45 +03:30
ElyTheCatandOpenAI Codex c13e224a44 Merge origin/main into entity occlusion branch
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 20:10:58 +03:30
ElyTheCatandOpenAI Codex 03ef18163d Format entity occlusion culling module
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 19:45:19 +03:30
ElyTheCatandOpenAI Codex 1068905d3d Add entity occlusion benchmark evidence
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 18:49:10 +03:30
ElyTheCat 7bee9ec3cf Merge origin/main into entity occlusion branch 2026-11-02 18:47:38 +03:30
ElyTheCatandOpenAI Codex a9a08ef1a6 Separate async entity occlusion rewrite
Isolate the entity occlusion cache and worker from particle visibility. Keep entity modes in the existing settings, submit inflated entity AABBs before model generation, and refine visible entities with their emitted bounds. Preserve fail-open handling and add async culling telemetry.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 18:47:26 +03:30
ElyTheCatandOpenAI Codex a71e1d0665 Translate particle culling graphics settings
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 13:57:47 +03:30
ElyTheCatandOpenAI Codex e6ce596144 Update mouse input fixture for trace logging
Stub the new input trace types and no-op recorder so the focused device-event fixture continues to compile when it extracts Game::device_event.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 13:09:26 +03:30
ElyTheCatandOpenAI Codex 8d603dc51f Refresh async culling benchmark evidence
Record both mode orders on the merged head and make the single-scene and cache-counter comparison limits explicit.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 11:55:38 +03:30
ElyTheCat 627309b80a Merge remote-tracking branch 'origin/main' into perf/async-occlusion-culling 2026-11-02 11:40:05 +03:30
ElyTheCatandOpenAI Codex 9c10104446 Optimize asynchronous entity and particle culling
Replace the collision-prone four-way visibility cache with a bounded sorted cache and validate repeated occlusion proofs through frame-local ordered lookup. Keep entity visibility conservative and off block entities, and add batched particle culling modes sharing the occlusion worker.

Add a reproducible benchmark note and screenshots for Sync and Async modes.

Fixes #6684

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 11:40:02 +03:30
ElyTheCatandOpenAI Codex 778dad1e4a Keep entity occlusion culling off block entities
Keep block entity frustum checks and section cache rendering independent of the ordinary entity occlusion culler. Show mode-specific culling help in the graphics UI.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-02 08:04:05 +03:30
814 changed files with 71982 additions and 6752 deletions

No files matched your search

+15 -9
View File
@@ -1,10 +1,16 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# Trusted reviewers: one list for three things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py);
# - `/unclaim @login` releases someone else's claim, and listed reviewers have no claim limit (tools/ci/claim_command.py).
# One reviewer per line: `<numeric GitHub ID> <login>`, with who added it and when. The ID decides; the login is only
# for people reading this file and for messages. A login is a name anyone can claim once its owner renames or deletes
# the account; an ID is never reused. Get it with `gh api users/<login> --jq .id`, or run
# `python3 tools/ci/resolve_reviewer_ids.py` to fill in the IDs and `--check` to catch renames.
# A line in any other form (a bare login included) grants nothing. Ludovic is always allowed (OWNER_ID in
# tools/ci/nightly_set.py) and isn't listed. Removing a line revokes that account at once. A line with only `paused`
# stops all three for every reviewer (Ludovic's still work).
54669067 kacpherek # added by Ludovic, 2026-10-27
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)
+14 -1
View File
@@ -242,6 +242,18 @@ jobs:
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
- name: Build a native-mod game with the launcher's managed toolchain
working-directory: launcher
shell: pwsh
env:
HORIZON_LAUNCHER_HOME: ${{ runner.temp }}\horizon-native-arm64-toolchain
HORIZON_NATIVE_TARGET_DIR: ${{ runner.temp }}\horizon-native-arm64-toolchain\target
run: |
$ErrorActionPreference = 'Stop'
cargo test --release --locked --target aarch64-pc-windows-msvc --lib rust_toolchain::tests::managed_arm64_toolchain_builds_the_game -- --ignored --exact --nocapture
$client = Join-Path $env:HORIZON_NATIVE_TARGET_DIR 'fast/horizon-client.exe'
python ../tools/check_pe_arch.py arm64 $client
python ../tools/check_imports.py $client
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
@@ -264,7 +276,7 @@ jobs:
run: |
changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]] || [[ " $LABELS " == *" frame-identity "* ]]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "The pull request runs its harnesses: ${harness:-label run-parity}"
else
@@ -279,6 +291,7 @@ jobs:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
+2
View File
@@ -50,6 +50,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
REPO: ${{ github.repository }}
+15 -2
View File
@@ -165,11 +165,15 @@ jobs:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
- name: Sign the checksums for the nightly channel (SHA256SUMS.sig) and for this build (SHA256SUMS.v2.sig)
# SHA256SUMS.sig is for launchers from before v2 (signed for "nightly"); SHA256SUMS.v2.sig is signed for
# nightly-<date>-<commit>, the version the release title names, which current launchers and servers
# require (docs/security.md, "Nightly signatures"). The publish step titles the release from the same values.
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
ALSO_FOR: nightly-${{ needs.pick.outputs.date }}-${{ needs.pick.outputs.short }}
run: |
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
@@ -208,12 +212,17 @@ jobs:
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
- name: The signatures, checked as launchers check them
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
DATE: ${{ needs.pick.outputs.date }}
SHORT: ${{ needs.pick.outputs.short }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
# The version's own signature, which current launchers require: the title below names the same version.
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "nightly-${DATE}-${SHORT}" \
--signature SHA256SUMS.v2.sig --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
@@ -230,12 +239,16 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
SHORT: ${{ needs.pick.outputs.short }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
[[ -f dist/SHA256SUMS.v2.sig ]] && signature+=(dist/SHA256SUMS.v2.sig)
# The title is what launchers parse (strictly) into the version SHA256SUMS.v2.sig is signed for.
[[ "${SHA:0:7}" == "$SHORT" ]] || { echo "::error::the build's commit isn't the one signed"; exit 1; }
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
arm64=()
if [[ -f dist/horizon-windows-arm64.zip ]]; then
+2
View File
@@ -41,6 +41,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+12
View File
@@ -20,6 +20,12 @@ on:
busy the job waits for it.
type: boolean
default: false
frame_identity:
description: >-
The pull request carries the frame-identity label: it must leave the picture alone (HDR, shader packs), so
the frame_identity harness, which no automatic selection picks, runs too.
type: boolean
default: false
permissions:
contents: read
env:
@@ -87,6 +93,12 @@ jobs:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.merge.outputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: The pictures this change must leave alone
if: ${{ !cancelled() && inputs.frame_identity }}
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.merge.outputs.base }}
run: python3 tools/parity.py frame_identity --timeout 2400
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v7
+1
View File
@@ -27,3 +27,4 @@ jobs:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
frame_identity: ${{ contains(github.event.pull_request.labels.*.name, 'frame-identity') }}
+2
View File
@@ -43,6 +43,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
# Who may act is decided by this numeric ID: a login can be claimed by someone else after a rename.
AUTHOR_ID: ${{ github.event.comment.user.id }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
+5 -2
View File
@@ -9,8 +9,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
folders in `HORIZON_HOME` (logs, crash reports, telemetry consent, recordings; not saves), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Saves, `options.txt` and
caches go in the game folder, by default the working directory: start the game from a scratch folder with
`--game-dir` pointing at it, or they land in the repo root, hidden by `.gitignore`. For the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
@@ -33,6 +35,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
## Crates
Generated
+4
View File
@@ -1318,6 +1318,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"dunce",
"flate2",
"fs4",
"gilrs",
@@ -1400,6 +1401,7 @@ dependencies = [
"chrono",
"flate2",
"fs4",
"getrandom 0.3.4",
"gilrs",
"glam",
"horizon-anticheat",
@@ -1411,6 +1413,7 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
@@ -1715,6 +1718,7 @@ dependencies = [
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"horizon-world",
"libc",
"p12-keystore",
"png",
+14 -33
View File
@@ -206,7 +206,7 @@ Manage a server from a console, ban and pardon players, and drive it from other
<details><summary>Technical</summary>
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The last pieces merged: bans edited while the server is stopped ([#9767](https://github.com/horizon-rust/horizon/issues/9767)) and the MCP tools' contracts pinned by a test ([#9748](https://github.com/horizon-rust/horizon/issues/9748)). The panel's later features and vanilla's management protocol are their own item (server-panel-management). [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
@@ -396,6 +396,16 @@ A Datapacks tab beside Mods and Resource packs: install into a chosen world's da
### Servers and multiplayer
#### Server panel extras and vanilla's management protocol
More of a server managed from its web panel (bans, worlds, logs and crash reports, modpacks), and vanilla's own server-management tools working with a Horizon server.
<details><summary>Technical</summary>
The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. Split from server-admin. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
@@ -600,16 +610,6 @@ An output device list in Music & Sounds, falling back to the system default. [#1
</details>
#### Play in your language
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn.
</details>
### Rendering
#### A light default shader pack
@@ -860,30 +860,11 @@ More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/pe
</details>
### Gameplay parity
#### End city banners keep their pattern
Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
</details>
#### Enchanted loot in End cities
Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
</details>
## Recently shipped
- **Enchanted loot in End cities** (v0.2.73): Armour and tools in End city chests should come enchanted, as in vanilla. [#1437](https://github.com/horizon-rust/horizon/issues/1437) · [#1660](https://github.com/horizon-rust/horizon/issues/1660) · [#1911](https://github.com/horizon-rust/horizon/issues/1911) · [#2037](https://github.com/horizon-rust/horizon/issues/2037)
- **End city banners keep their pattern** (v0.2.67): Banners in End cities show as plain magenta instead of with their pattern. [#1438](https://github.com/horizon-rust/horizon/issues/1438) · [#526](https://github.com/horizon-rust/horizon/issues/526) · [#1928](https://github.com/horizon-rust/horizon/issues/1928)
- **Play in your language** (v0.2.65): Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English. [#1806](https://github.com/horizon-rust/horizon/issues/1806) · [#1594](https://github.com/horizon-rust/horizon/issues/1594) · [#1672](https://github.com/horizon-rust/horizon/issues/1672)
- **Chickens flap while they fall** (v0.2.67): Chickens should flap their wings while they fall, not stand still in the air. [#1706](https://github.com/horizon-rust/horizon/issues/1706) · [#1892](https://github.com/horizon-rust/horizon/issues/1892)
- **Ctrl+Backspace and scroll bars in menus** (v0.2.67): Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable. [#1764](https://github.com/horizon-rust/horizon/issues/1764) · [#1782](https://github.com/horizon-rust/horizon/issues/1782) · [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
- **A pointing hand over buttons** (v0.2.67): The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3. [#1347](https://github.com/horizon-rust/horizon/issues/1347) · [#1897](https://github.com/horizon-rust/horizon/issues/1897)
+42
View File
@@ -170,3 +170,45 @@ BlueMap is licensed under the MIT license:
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
> THE SOFTWARE.
## JOML
`crates/horizon-client-render/src/camera.rs` (`joml::Mat4::rotation`, `invert_orthonormal`) and
`crates/horizon-client-render/src/gfx/pack_uniforms.rs` (`invert_generic`, `invert_affine`, the
formulas of `pack_projection` and `shadow_ortho`) adapt the float arithmetic of
`Matrix4f.rotation(Quaternionfc)`, `invertOrthonormal`, `invertGenericNonThis`, `invertAffine`,
`setPerspective` and `setOrtho` from
[JOML](https://github.com/JOML-CI/JOML/tree/faf9eef2511242122beb6055fe3da92daed214c5) 1.10.9, the
library vanilla 26.3 uses, so that shader packs get the same matrices to the last bit. Changes:
ported to Rust; the pack_uniforms.rs functions drop JOML's property flags, and
`pack_projection` works the formula in doubles. The copyright line and license notice also head
both files.
`camera.rs` also adapts `Quaternionf.rotationX`, `rotationY` and `rotationZ`,
`Matrix4f.rotateAffine` and `Vector4f.mulAffine` (`joml::Quat::rotation_x`/`_y`/`_z`,
`joml::Mat4::rotate_affine`, `transform_affine`), for the sky's positions shader packs read.
Changes: ported to Rust; `rotate_affine` keeps only the affine case.
```
The MIT License
Copyright (c) 2015-2026 Richard Greenlees
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
@@ -0,0 +1,42 @@
# Entity occlusion split measurements — 2026-11-02
These measurements came from the pre-split integration branch for PR #7054. That build contained the entity-occlusion rewrite and particle light lookup cache; Particle Culling was Off. They are useful comparison evidence, but they are not a benchmark of this isolated entity-only commit. The cache-only run in PR #7391 measured effectively the same CPU time on the ordinary scene, but the combined result still cannot isolate interactions.
Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap. Two opposite-order passes measured for 8 seconds after warmup. Values are means. CPU is harness average CPU ms per frame; p99 is frame time. The driver supplied no usable GPU timing.
## Ordinary scene
The scene had 24 mobs, roughly 9,400–10,100 rain particles and campfire smoke.
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, entity culling Off | 88.59 | 1.829 | 24.51 |
| Pre-split integration branch, all culling Off | 88.33 | 2.063 | 24.51 |
| Main, entity culling Synchronous | 89.34 | 1.965 | 23.99 |
| Pre-split integration branch, entity Synchronous | 89.13 | 2.113 | 24.15 |
This normal-scene run does not show a large FPS change; its CPU differences are small relative to frame time and not attributable to the entity-only change by themselves.
## Opaque-wall scene
A 32×32 platform at Y=300, a 7-block wall with a one-block vertical opening, and 192 pigs spawned behind the wall (216 mobs in the captured scene). The player looked toward the opening from the near side. The same world and camera path were used for the main and integration builds.
| Build and mode | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, Off | 89.08 | 2.423 | 13.52 |
| Integration branch, Off | 89.23 | 2.355 | 13.27 |
| Main, Synchronous | 92.81 | 2.566 | 14.60 |
| Integration branch, Synchronous | 93.21 | 2.550 | 12.84 |
| Main, Asynchronous | 92.73 | 2.364 | 13.04 |
| Integration branch, Asynchronous | 92.62 | 2.314 | 13.18 |
The asynchronous mode reduced measured CPU time slightly in this wall scene but did not improve FPS beyond run-to-run spread. This is a synthetic occlusion-heavy scene, not a broad performance claim.
## Captures
The HUD performance overlay is visible in each capture. The integration-build screenshots were recorded with the same wall and camera position in Off, Synchronous, and Asynchronous modes.
- Main, Off: `main-off.png`
- Integration branch, Off: `branch-off.png`
- Integration branch, Synchronous: `branch-sync.png`
- Integration branch, Asynchronous: `branch-async.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

@@ -0,0 +1,42 @@
# Entity occlusion pipeline — release/OpenGL run (2026-11-04)
This is the final local smoke benchmark for the entity-occlusion pipeline before opening the PR. It was run from the branch after the release build completed, with the OpenGL backend selected explicitly. The same process created each scene, switched the culling mode, flew the camera for two seconds, collected frame/CPU telemetry, and captured the screenshot after the scene settled.
**Host:** Intel Core i7-3770S, Intel HD Graphics 4000, Mesa OpenGL 4.2
**Window:** 1280×720, offscreen, VSync disabled
**World:** fresh temporary home, 32×32 platform at Y=300; wall scenes add a solid stone wall and an optional one-block slit
**Entities:** 24 pigs in the normal scene, 192 pigs in wall scenes, 512 pigs in stress scenes
**Settings:** render distance 8, `entityCullingPrecheck:64`, FPS overlay on, no resource-pack occlusion assumptions
**Build:** `cargo build --release -p horizon-client`
## Results
The values below are branch-only measurements. They are useful for comparing the three modes under identical geometry and for checking that the worker is doing useful work. A fresh main binary was not built in this short pass, so these numbers do not claim a controlled main-to-branch delta.
| Scene | Off FPS / CPU p50 | Sync FPS / CPU p50 | Async FPS / CPU p50 | Async entities culled |
| --- | ---: | ---: | ---: | ---: |
| `entity_normal` (24 visible) | 240.5 / 0.586 ms | 233.5 / 0.762 ms | 239.3 / 0.686 ms | 0 |
| `entity_wall` (192, solid) | 228.8 / 1.245 ms | 318.6 / 1.342 ms | **324.5 / 0.478 ms** | 124,608 |
| `entity_wall_slit` (192, one-block slit) | 231.4 / 1.102 ms | 265.0 / 1.387 ms | **281.2 / 0.716 ms** | 102,826 |
| `entity_stress` (512, solid) | 190.3 / 2.467 ms | 299.2 / 2.593 ms | **325.5 / 0.691 ms** | 290,234 |
| `entity_stress_slit` (512, one-block slit) | 190.4 / 2.476 ms | 262.2 / 2.573 ms | **262.1 / 1.297 ms** | 227,301 |
Async telemetry confirmed that the render thread stayed non-blocking (`try_recv`/`try_send`, `worker_blocking_wait_ms: 0`). The latest wall/slit run completed 562 worker jobs, queued 3,982 queries, avoided about 4.0 million entity bytes, and had 379 ns recent p50 worker-job time. The solid 512-pig run culled 15,552 generated vertices; the slit run culled 120,290 generated vertices. The normal scene recorded no pre-model hides and kept the opt-in path close to Off.
## Captures
Every image includes the FPS performance overlay. The names are `<mode>-<scene>.png`:
- [Off — normal](screenshots/off-entity_normal.png), [wall](screenshots/off-entity_wall.png), [wall slit](screenshots/off-entity_wall_slit.png), [stress](screenshots/off-entity_stress.png), [stress slit](screenshots/off-entity_stress_slit.png)
- [Sync — normal](screenshots/sync-entity_normal.png), [wall](screenshots/sync-entity_wall.png), [wall slit](screenshots/sync-entity_wall_slit.png), [stress](screenshots/sync-entity_stress.png), [stress slit](screenshots/sync-entity_stress_slit.png)
- [Async — normal](screenshots/async-entity_normal.png), [wall](screenshots/async-entity_wall.png), [wall slit](screenshots/async-entity_wall_slit.png), [stress](screenshots/async-entity_stress.png), [stress slit](screenshots/async-entity_stress_slit.png)
## Reproduction
From the repository root, with prepared 26.3 resources available:
```sh
python3 tools/entity_occlusion_bench.py
```
The script uses fresh temporary game data, the release binary in `target/release`, OpenGL, the FPS overlay, and writes JSON plus captures under `/tmp/horizon-entity-occlusion-release-quick/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+1 -1
View File
@@ -93,7 +93,7 @@ pub fn kind_of(path: &Path) -> Option<Kind> {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"zip" => Some(horizon_mods::index::zip_kind(path)),
"hshader" => Some(Kind::Shader),
_ => None,
}
@@ -964,6 +964,10 @@ impl Scoreboard {
pub fn tracked(&self) -> Vec<String> {
self.holder_order.stream().map(str::to_owned).collect()
}
/// Every holder on a team (`teamsByPlayer.keySet()`).
pub fn team_members(&self) -> impl Iterator<Item = &str> {
self.team_of.keys().map(String::as_str)
}
// teams
pub fn team(&self, name: &str) -> Option<&Team> {
+377 -7
View File
@@ -314,6 +314,8 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.screenshot", None),
("key.toggleGui", None),
("key.smoothCamera", None),
// Horizon's emote wheel: no button is free by default; the stick's cursor picks.
("key.emote", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
@@ -815,6 +817,98 @@ pub fn stick_direction(stick: [f32; 2], threshold: f32) -> Option<Button> {
})
}
/// How far a stick already stepping must fall back to let go: between this and
/// the step threshold it holds its direction, so a stick resting on the
/// threshold doesn't step again.
pub const STICK_RELEASE: f32 = 0.35;
/// How much more a stick must lean along another axis to change direction, so
/// a stick resting on a diagonal doesn't flip between the two.
const STICK_TURN: f32 = 1.5;
/// [`stick_direction`] for a stick that may already be stepping `held`: it stays
/// that way until the stick falls back past [`STICK_RELEASE`] or clearly turns.
pub fn stick_direction_held(
stick: [f32; 2],
threshold: f32,
held: Option<Button>,
) -> Option<Button> {
let fresh = stick_direction(stick, threshold);
let Some((axis, positive)) = held.and_then(direction) else {
return fresh;
};
if length(stick) < STICK_RELEASE {
return None;
}
// `direction`'s y runs down the screen, a stick's up.
let toward = stick[axis] * if positive == (axis == 0) { 1.0 } else { -1.0 };
if toward > 0.0 && stick[1 - axis].abs() <= toward * STICK_TURN {
held
} else {
fresh
}
}
/// A list drawn with a scrollbar (`ui::scrollbar`), how far it is scrolled (a list
/// moving a whole row a notch draws the same rectangles, so they can't tell) and
/// the ways it can still scroll.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollArea {
pub rect: Rect,
pub amount: f32,
pub up: bool,
pub down: bool,
}
impl ScrollArea {
/// A row counts while any of it is in view: the stick can leave the focus on one nearly gone.
pub fn holds(&self, r: &Rect) -> bool {
let x = r[0] + r[2] / 2.0;
let [ax, ay, aw, ah] = self.rect;
(ax..ax + aw).contains(&x) && r[1] < ay + ah && r[1] + r[3] > ay
}
/// All of a row is in view.
fn shows(&self, r: &Rect) -> bool {
let [_, ay, _, ah] = self.rect;
self.holds(r) && r[1] + 0.5 >= ay && r[1] + r[3] <= ay + ah + 0.5
}
}
/// A vertical focus step, with lists: the strict step, unless it would land
/// outside the view of the list `from` is in, or only partly in it, while that
/// list can still scroll that way, when there is none (`None`) and the list
/// scrolls instead: its hidden rows come first and whole, as vanilla's arrow
/// keys walk a selection list's entries (`ensureVisible`).
pub fn list_step(
from: Rect,
button: Button,
targets: &[Rect],
areas: &[ScrollArea],
) -> Option<usize> {
let step = focus_step_strict(from, button, targets)?;
let can_scroll = |a: &ScrollArea| {
if button == Button::DPadUp {
a.up
} else {
a.down
}
};
let leaves = areas
.iter()
.find(|a| a.holds(&from))
.is_some_and(|a| can_scroll(a) && !a.shows(&targets[step]));
(!leaves).then_some(step)
}
/// The farthest centres of two neighbouring slots: 18 apart, 22 over the hotbar's gap.
const SLOT_NEIGHBOUR: f32 = 24.0;
/// A held direction's repeat stops at the edge of the slots it is walking: it
/// goes on only from a slot to a slot next to it (the hotbar's small gap
/// included). A fresh press may go anywhere.
pub fn repeat_crosses_slots(from: (f32, f32), to: (f32, f32), to_slot: bool) -> bool {
!to_slot || (to.0 - from.0).abs().max((to.1 - from.1).abs()) > SLOT_NEIGHBOUR
}
/// Arrow-key focus navigation as vanilla does it
/// (`ContainerEventHandler.nextFocusPathInDirection`), over every target of
/// the screen at once: from a strip one pixel past the focused edge, the
@@ -910,12 +1004,25 @@ pub fn focus_step_vague(from: Rect, button: Button, targets: &[Rect]) -> Option<
/// How far a list scrolled between two frames' targets: the vertical shift
/// most targets of the same position and size agree on (targets that didn't
/// move, outside the list, don't count). `None` when nothing moved.
pub fn scroll_shift(before: &[Rect], after: &[Rect]) -> Option<f32> {
/// move, outside the list, don't count), for a list scrolled towards its end
/// (`down`: the rows moved up) or its start. `None` when nothing moved that way.
/// It takes the direction because half a row's scroll votes the same both ways.
pub fn scroll_shift(before: &[Rect], after: &[Rect], down: bool) -> Option<f32> {
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
if before.len() == after.len() && before.iter().zip(after).all(|(a, b)| same(a, b)) {
return None;
}
// Most votes; among equals the smallest move (the next row, not a far one).
shift_votes(before, after)
.into_iter()
.filter(|v| (v.0 < 0.0) == down)
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))
.map(|v| v.0)
}
/// Every vertical shift that would carry a `before` target onto an `after`
/// target of the same position and size, with how many it would.
fn shift_votes(before: &[Rect], after: &[Rect]) -> Vec<(f32, usize)> {
let mut votes: Vec<(f32, usize)> = Vec::new();
for b in before {
for a in after {
@@ -932,11 +1039,94 @@ pub fn scroll_shift(before: &[Rect], after: &[Rect]) -> Option<f32> {
}
}
}
// Most votes; among equals the smallest move (the next row, not a far one).
votes
}
/// Where the focus goes when the right stick scrolled a list (`down`: towards
/// its end, so the rows moved up): the rectangle moved by the list's shift;
/// the same rectangle, for a widget outside the list; or, its row out of view,
/// the nearest target of its kind (`after`'s flag), the list's rows before
/// anything fixed. `None` when nothing moved that way, or none of its kind is
/// left. It takes the direction because half a row's scroll votes the same
/// both ways once a new row comes into view.
pub fn follow_scroll(
focus: Rect,
down: bool,
before: &[Rect],
after: &[(Rect, bool)],
) -> Option<usize> {
let rects: Vec<Rect> = after.iter().map(|t| t.0).collect();
// A column that stayed put isn't the list, even spaced like its shift.
let column = |of: &[Rect], r: &Rect| -> Vec<f32> {
let same = |o: &&Rect| o[0] == r[0] && o[2] == r[2] && o[3] == r[3];
of.iter().filter(same).map(|o| o[1]).collect()
};
let moving = |r: &&Rect| column(before, r) != column(&rects, r);
let was: Vec<Rect> = before.iter().filter(moving).copied().collect();
let now: Vec<Rect> = rects.iter().filter(moving).copied().collect();
let dy = shift_votes(&was, &now)
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))
.map(|v| v.0)
.filter(|v| (v.0 < 0.0) == down)
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))?
.0;
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
let moved = [focus[0], focus[1] + dy, focus[2], focus[3]];
let found = |at: &Rect| rects.iter().position(|r| same(r, at));
if !moving(&&focus) {
return found(&focus);
}
if let Some(i) = found(&moved).or_else(|| found(&focus)) {
return Some(i);
}
let in_list = |r: &Rect| {
let was = [r[0], r[1] - dy, r[2], r[3]];
before.iter().any(|b| same(b, &was))
};
let centre = (moved[0] + moved[2] / 2.0, moved[1] + moved[3] / 2.0);
let distance = |r: &Rect| (r[0] + r[2] / 2.0 - centre.0).hypot(r[1] + r[3] / 2.0 - centre.1);
after
.iter()
.enumerate()
.filter(|(_, t)| t.1)
.min_by(|(_, a), (_, b)| {
in_list(&b.0)
.cmp(&in_list(&a.0))
.then(distance(&a.0).total_cmp(&distance(&b.0)))
})
.map(|(i, _)| i)
}
/// The target a focus rectangle stands for once the screen's size changed:
/// the same widget, by its place in the draw order when the screen drew as
/// many; else the target nearest where its centre falls at the new size.
pub fn refind_resized(
focus: Rect,
before: &[Rect],
after: &[Rect],
old: (f32, f32),
new: (f32, f32),
) -> Option<usize> {
let same = |r: &Rect| r.iter().zip(&focus).all(|(a, b)| (a - b).abs() < 0.5);
if before.len() == after.len()
&& let Some(i) = before.iter().position(same)
{
return Some(i);
}
let at = rescaled(
(focus[0] + focus[2] / 2.0, focus[1] + focus[3] / 2.0),
old,
new,
);
let centres: Vec<(f32, f32)> = after
.iter()
.map(|r| (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0))
.collect();
nearest(at, &centres, f32::INFINITY)
}
/// A point at the same place, proportionally, on a screen of another size.
pub fn rescaled(at: (f32, f32), old: (f32, f32), new: (f32, f32)) -> (f32, f32) {
(at.0 * new.0 / old.0.max(1.0), at.1 * new.1 / old.1.max(1.0))
}
/// The target a remembered focus rectangle stands for in a new frame: the
@@ -1632,6 +1822,63 @@ mod tests {
assert_eq!(focus_step_rect(grid[0], Button::South, &grid), None);
}
#[test]
fn follow_scroll_keeps_rows_and_fixed_widgets() {
// Rows 36 tall scrolled down 18: a fifth row comes into view, so +18
// carries as many rows as the real -18. Back, below, stays.
let back = [296.0, 212.0, 71.0, 20.0];
let rows = |ys: &[f32]| -> Vec<(Rect, bool)> {
let rows = ys.iter().map(|y| ([78.0, *y, 270.0, 36.0], true));
rows.chain([(back, false)]).collect()
};
let rects = |t: &[(Rect, bool)]| -> Vec<Rect> { t.iter().map(|t| t.0).collect() };
let before = rects(&rows(&[33.0, 69.0, 105.0, 141.0]));
let after = rows(&[15.0, 51.0, 87.0, 123.0, 159.0]);
assert_eq!(follow_scroll(before[1], true, &before, &after), Some(1));
assert_eq!(follow_scroll(back, true, &before, &after), Some(5));
// And back up.
let up = rows(&[33.0, 69.0, 105.0, 141.0]);
let was = rects(&after);
assert_eq!(follow_scroll(was[2], false, &was, &up), Some(2));
// Nothing moved, as on a screen with no list.
let still = rows(&[33.0, 69.0, 105.0, 141.0]);
assert_eq!(follow_scroll(before[1], true, &before, &still), None);
// A sidebar's tabs spaced exactly like the list's shift stay put.
let tab = |y: f32| [8.0, y, 112.0, 16.0];
let with_tabs = |t: &[(Rect, bool)]| -> Vec<(Rect, bool)> {
t.iter()
.copied()
.chain([(tab(100.0), true), (tab(118.0), true)])
.collect()
};
let was = rects(&with_tabs(&rows(&[33.0, 69.0, 105.0, 141.0])));
let now = with_tabs(&after);
assert_eq!(follow_scroll(tab(118.0), true, &was, &now), Some(7));
}
#[test]
fn follow_scroll_lands_on_the_nearest_row_when_its_row_leaves() {
// The row at 33 left the top; the next one is at 51. Nothing fixed.
let row = |y: f32| [78.0, y, 270.0, 36.0];
let before = [row(33.0), row(69.0), row(105.0)];
let after = [(row(51.0), true), (row(87.0), true)];
assert_eq!(follow_scroll(row(33.0), true, &before, &after), Some(0));
// The last row left the bottom: the list's new last row, not a fixed
// widget of its kind (at 200) nearer where the row went.
let fixed = [78.0, 200.0, 270.0, 36.0];
let before = [row(15.0), row(51.0), row(87.0), row(159.0), fixed];
let after = [
(row(33.0), true),
(row(69.0), true),
(row(105.0), true),
(fixed, true),
];
assert_eq!(follow_scroll(row(159.0), false, &before, &after), Some(2));
// Nothing of its kind left.
let other = after.map(|t| (t.0, false));
assert_eq!(follow_scroll(row(159.0), false, &before, &other), None);
}
#[test]
fn refind_tabs_sliders_and_stick_steps() {
let rects: Vec<Rect> = vec![[0.0, 0.0, 10.0, 10.0], [0.0, 20.0, 10.0, 10.0]];
@@ -1648,8 +1895,10 @@ mod tests {
.map(|i| [10.0, 16.0 + 24.0 * i as f32, 100.0, 20.0])
.chain([[10.0, 200.0, 50.0, 20.0]])
.collect();
assert_eq!(scroll_shift(&before, &after), Some(-24.0));
assert_eq!(scroll_shift(&before, &before), None);
assert_eq!(scroll_shift(&before, &after, true), Some(-24.0));
// Rows this far apart line up the other way too; the direction is what tells them apart.
assert_eq!(scroll_shift(&before, &after, false), Some(24.0));
assert_eq!(scroll_shift(&before, &before, true), None);
let tabs = [
([60.0, 0.0, 26.0, 32.0], false),
([0.0, 0.0, 26.0, 32.0], true),
@@ -1675,6 +1924,127 @@ mod tests {
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
}
#[test]
fn a_list_step_scrolls_rather_than_leave_a_list_that_can() {
let rows = |n: usize| -> Vec<Rect> {
(0..n)
.map(|i| [100.0, 40.0 + i as f32 * 36.0, 270.0, 36.0])
.collect()
};
let back: Rect = [150.0, 220.0, 150.0, 20.0];
let search: Rect = [150.0, 10.0, 150.0, 20.0];
let mut targets = rows(4);
targets.extend([back, search]);
let area = |up, down| ScrollArea {
rect: [0.0, 40.0, 380.0, 144.0],
amount: 0.0,
up,
down,
};
let (down, up) = (Button::DPadDown, Button::DPadUp);
let step = |from, button, area: &[ScrollArea]| list_step(from, button, &targets, area);
// A row below in view comes first.
assert_eq!(step(targets[1], down, &[area(true, true)]), Some(2));
// The last row in view: more below scrolls; none leaves.
assert_eq!(step(targets[3], down, &[area(true, true)]), None);
assert_eq!(step(targets[3], down, &[area(true, false)]), Some(4));
// The first row in view, the same upwards.
assert_eq!(step(targets[0], up, &[area(true, false)]), None);
assert_eq!(step(targets[0], up, &[area(false, true)]), Some(5));
// From outside the list it enters it, and no list means a plain step.
assert_eq!(step(back, up, &[area(true, true)]), Some(3));
assert_eq!(step(targets[3], down, &[]), Some(4));
}
#[test]
fn a_list_step_shows_its_row_whole_and_starts_from_one_nearly_gone() {
let area = |up, down| ScrollArea {
rect: [0.0, 40.0, 380.0, 100.0],
amount: 50.0,
up,
down,
};
let (down, up) = (Button::DPadDown, Button::DPadUp);
// The next row is cut by the view's bottom: it scrolls first, unless that's the end.
let targets: Vec<Rect> = vec![[100.0, 100.0, 270.0, 25.0], [100.0, 125.0, 270.0, 25.0]];
assert_eq!(
list_step(targets[0], down, &targets, &[area(true, true)]),
None
);
assert_eq!(
list_step(targets[0], down, &targets, &[area(true, false)]),
Some(1)
);
// The same at its top.
let targets: Vec<Rect> = vec![[100.0, 30.0, 270.0, 25.0], [100.0, 55.0, 270.0, 25.0]];
assert_eq!(
list_step(targets[1], up, &targets, &[area(true, true)]),
None
);
// A focused row with its last pixels in view is still in the list: going up scrolls,
// even with something above the list to go to.
let row: Rect = [100.0, 18.0, 270.0, 25.0];
let field: Rect = [150.0, 0.0, 150.0, 15.0];
assert!(area(true, true).holds(&row));
assert_eq!(list_step(row, up, &[row, field], &[area(true, true)]), None);
// Above the view altogether, it isn't.
assert!(!area(true, true).holds(&[100.0, 10.0, 270.0, 25.0]));
}
#[test]
fn a_stick_holds_its_direction_until_it_lets_go_or_turns() {
let step = 0.55;
let (up, right) = (Some(Button::DPadUp), Some(Button::DPadRight));
// Only a push past the threshold starts a step.
assert_eq!(stick_direction_held([0.0, 0.5], step, None), None);
assert_eq!(stick_direction_held([0.0, 0.6], step, None), up);
// Wobbling on the threshold keeps it; falling back past the release lets go.
assert_eq!(stick_direction_held([0.0, 0.5], step, up), up);
assert_eq!(stick_direction_held([0.0, 0.3], step, up), None);
// A diagonal doesn't flip it, but a clear turn does.
assert_eq!(stick_direction_held([0.6, 0.55], step, up), up);
assert_eq!(stick_direction_held([0.55, 0.6], step, right), right);
assert_eq!(stick_direction_held([0.9, 0.3], step, up), right);
// Straight across to the other side.
assert_eq!(
stick_direction_held([0.0, -0.9], step, up),
Some(Button::DPadDown)
);
// Past the release on the far side, but not far enough for a step.
assert_eq!(stick_direction_held([0.0, -0.45], step, up), None);
}
#[test]
fn a_repeat_stops_where_the_slots_end() {
let at = (100.0, 100.0);
assert!(!repeat_crosses_slots(at, (118.0, 100.0), true));
// The hotbar, over its small gap, is the same run of slots.
assert!(!repeat_crosses_slots(at, (100.0, 122.0), true));
// Another block of slots, or a button.
assert!(repeat_crosses_slots(at, (100.0, 132.0), true));
assert!(repeat_crosses_slots(at, (118.0, 100.0), false));
}
#[test]
fn a_resize_keeps_the_same_widget() {
let before: Vec<Rect> = vec![[10.0, 10.0, 20.0, 20.0], [100.0, 10.0, 20.0, 20.0]];
let after: Vec<Rect> = vec![[300.0, 90.0, 20.0, 20.0], [400.0, 90.0, 20.0, 20.0]];
let (old, new) = ((200.0, 100.0), (800.0, 400.0));
// The same count: the same place in the draw order, however far it moved.
assert_eq!(
refind_resized(before[0], &before, &after, old, new),
Some(0)
);
// A list showing more rows: the nearest to where it would be.
let more: Vec<Rect> = vec![
[30.0, 30.0, 20.0, 20.0],
[395.0, 35.0, 20.0, 20.0],
[700.0, 300.0, 20.0, 20.0],
];
assert_eq!(refind_resized(before[1], &before, &more, old, new), Some(1));
assert_eq!(refind_resized(before[0], &before, &[], old, new), None);
}
#[test]
fn labels_and_conflicts() {
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
@@ -23,6 +23,8 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
("key.pickItem", "key.mouse.middle", "gameplay"),
("key.command", "key.keyboard.slash", "multiplayer"),
("key.friends", "key.keyboard.o", "multiplayer"),
// Horizon's emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
("key.emote", "key.keyboard.b", "multiplayer"),
("key.socialInteractions", "key.keyboard.p", "multiplayer"),
("key.toggleGui", "key.keyboard.f1", "misc"),
(
+50
View File
@@ -91,6 +91,36 @@ pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> O
}
}
/// Where a cursor left on a target goes once the targets moved: with most of
/// them if they moved together (a recipe book opened), else to the nearest
/// one (a creative tab with other slots), so it never rests between targets.
/// `None` when it wasn't on a target, or one is still there.
pub fn follow(at: Point, before: &[Point], after: &[Point]) -> Option<usize> {
const SAME: f32 = 0.5;
let on = before.iter().find(|t| distance(at, **t) <= SAME)?;
if after.iter().any(|t| distance(at, *t) <= SAME) {
return None;
}
let carried = |by: Point| {
before
.iter()
.filter(|b| {
let moved = (b.0 + by.0, b.1 + by.1);
after.iter().any(|a| distance(moved, *a) <= SAME)
})
.count()
};
let together = after
.iter()
.enumerate()
.map(|(i, a)| (i, carried((a.0 - on.0, a.1 - on.1))))
.max_by_key(|&(_, count)| count)
.filter(|&(_, count)| count * 2 > before.len());
together
.map(|(i, _)| i)
.or_else(|| nearest(at, after, f32::INFINITY))
}
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
/// slows as it lands, and exactly `to` once the glide is over.
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
@@ -109,6 +139,26 @@ mod tests {
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
#[test]
fn follow_goes_with_targets_that_moved_together() {
let moved = SLOTS.map(|s| (s.0 + 77.0, s.1));
assert_eq!(follow(SLOTS[1], &SLOTS, &moved), Some(1));
}
#[test]
fn follow_takes_the_nearest_of_other_targets() {
let other = [(30.0, 50.0), (200.0, 200.0)];
assert_eq!(follow(SLOTS[1], &SLOTS, &other), Some(0));
}
#[test]
fn follow_leaves_a_free_cursor_and_a_target_still_there() {
let other = [(30.0, 50.0)];
assert_eq!(follow((100.0, 100.0), &SLOTS, &other), None);
assert_eq!(follow(SLOTS[0], &SLOTS, &SLOTS), None);
assert_eq!(follow(SLOTS[0], &SLOTS, &[]), None);
}
#[test]
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
+4
View File
@@ -18,6 +18,8 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
@@ -30,6 +32,8 @@ horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The control endpoint's token (control.rs), as horizon-server's and the relay's.
getrandom = "0.3"
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
@@ -648,6 +648,7 @@ fn remesh(
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
shader_pack: false,
assets,
render,
tables: &tables,
@@ -407,6 +407,16 @@ impl BlockEntities {
self.add(pos);
}
}
/// [`Self::retain`] for unloading chunks: removes the block entities at
/// the positions `gone` names and changes none it keeps, so those that
/// sleep stay asleep (a moving player unloads chunks most ticks).
pub fn unload(&mut self, mut gone: impl FnMut(&Pos) -> bool) {
let asleep = std::mem::take(&mut self.asleep);
self.retain(|p, _| !gone(p));
self.asleep = asleep;
let map = &self.map;
self.asleep.retain(|p| map.contains_key(p));
}
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
@@ -558,6 +568,24 @@ mod tests {
.collect()
}
/// Unloading a chunk removes its block entities and leaves the others'
/// sleep alone; `retain`, which may change what it keeps, wakes them all.
#[test]
fn unloading_a_chunk_keeps_the_others_asleep() {
let mut b = level();
let (kept, gone) = ((1, 64, 1), (17, 64, 1));
b.insert(kept, hopper());
b.insert(gone, hopper());
b.sleep(kept);
b.sleep(gone);
b.unload(|p| p.0 >> 4 == 1);
assert!(b.get(&gone).is_none());
assert!(!b.is_asleep(&gone));
assert!(b.is_asleep(&kept));
b.retain(|_, _| true);
assert!(!b.is_asleep(&kept));
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
@@ -7,7 +7,7 @@
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{
Result,
bidi::visual_str,
bidi::visual,
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
@@ -271,10 +271,10 @@ pub const DYES: [(&str, u32); 16] = [
("black", 0x1D1D21),
];
fn dye_prefix(name: &str) -> Option<(usize, &str)> {
// Longest names first so "light_gray" wins over "gray".
let mut order: Vec<usize> = (0..16).collect();
order.sort_by_key(|&i| std::cmp::Reverse(DYES[i].0.len()));
order.into_iter().find_map(|i| {
// Longest names first; this is the same stable order as sorting DYES by
// descending prefix length, without a Vec allocation or comparison sort.
const ORDER: [usize; 16] = [3, 8, 2, 1, 4, 10, 0, 12, 13, 15, 5, 6, 7, 9, 11, 14];
ORDER.into_iter().find_map(|i| {
name.strip_prefix(DYES[i].0)
.and_then(|rest| rest.strip_prefix('_'))
.map(|rest| (i, rest))
@@ -639,7 +639,9 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
}
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour.
/// full-bright with an 8x outline in the dark colour. The font colours each
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
/// else the sign's. A line is one call per run of one colour.
fn sign_text_calls(
scene: &Scene,
data: &SignData,
@@ -662,25 +664,53 @@ fn sign_text_calls(
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).advance;
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
text.lines
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = visual_str(&shown).into_owned();
TextCall {
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
for i in 0..text.lines.len() {
// The line that fits, in the order it is drawn (getVisualOrder).
let line = text.line_text(i);
let line = line.resolved();
let flat: String = line.chars().map(|(c, _)| c).collect();
let fits = first_line(&flat, data.max_line_width(), &advance);
let shown = line.slice(0, fits.chars().count());
let shown = visual(&shown);
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
let x = -((width as i32) / 2) as f32;
let y = i as f32 * line_height - midpoint;
let first = calls.len();
let mut pen = 0.0;
for span in shown.spans.iter().filter(|s| !s.text.is_empty()) {
let run = span
.style
.color
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
match calls[first..].last_mut() {
Some(last) if last.color == run => last.text.push_str(&span.text),
_ => calls.push(TextCall {
matrix: m,
x: x + pen,
y,
text: span.text.clone(),
color: run,
outline,
light,
}),
}
pen += span.text.chars().map(advance).sum::<f32>();
}
if calls.len() == first {
// A line with no text is still a call, of no glyphs.
calls.push(TextCall {
matrix: m,
x: -((width(&shown) as i32) / 2) as f32,
y: i as f32 * line_height - midpoint,
text: shown,
x,
y,
text: String::new(),
color,
outline,
light,
}
})
.collect()
});
}
}
calls
}
/// How far the text of an outlined call sits in front of its outline, in
@@ -841,6 +871,9 @@ pub struct Scene<'a> {
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
/// Per-block visibility for dynamic, animated, or uncached models. Bounds
/// are world-space and conservatively include model animation extents.
pub dynamic_visible: Option<&'a dyn Fn([i32; 3], u32, DVec3, DVec3) -> bool>,
}
/// Empty by default (a dedicated server): no state has a block entity model.
@@ -1410,6 +1443,167 @@ impl BlockEntityRenderer {
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
fn dynamic_bounds(
&self,
scene: &Scene,
pos: [i32; 3],
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> (DVec3, DVec3) {
let [x, y, z] = pos.map(f64::from);
let mut lo = DVec3::new(x, y, z);
let mut hi = DVec3::new(x + 1.0, y + 1.0, z + 1.0);
match kind {
Kind::Chest { double_with, .. } => {
if let Some(offset) = double_with {
lo.x += f64::from(offset[0].min(0));
lo.z += f64::from(offset[2].min(0));
hi.x += f64::from(offset[0].max(0));
hi.z += f64::from(offset[2].max(0));
}
let is_open = chest_openness.map_or_else(
|| {
(scene.lid)(pos) != 0.0
|| double_with.is_some_and(|offset| {
let other = [
pos[0].saturating_add(offset[0]),
pos[1].saturating_add(offset[1]),
pos[2].saturating_add(offset[2]),
];
(scene.lid)(other) != 0.0
})
},
|(open, _)| open != 0.0,
);
if is_open {
// The lid pivots up from the rear edge. Its longest corner
// rises by less than one block in the fully open pose.
hi.y += 0.8;
}
}
Kind::Shulker { facing, .. } => {
let offset = step(facing);
lo.x += f64::from(offset[0].min(0)) * 0.52;
lo.y += f64::from(offset[1].min(0)) * 0.52;
lo.z += f64::from(offset[2].min(0)) * 0.52;
hi.x += f64::from(offset[0].max(0)) * 0.52;
hi.y += f64::from(offset[1].max(0)) * 0.52;
hi.z += f64::from(offset[2].max(0)) * 0.52;
}
Kind::Banner { wall: false, .. } => {
hi.y += 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Banner { wall: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Sign { hanging: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.15;
lo.z -= 0.15;
hi.x += 0.15;
hi.z += 0.15;
}
Kind::Sign { hanging: false, .. } => {
hi.y += 0.3;
lo.x -= 0.02;
lo.z -= 0.02;
hi.x += 0.02;
hi.z += 0.02;
}
Kind::EnchantingTable | Kind::Lectern { .. } => {
// The book and statue models rise above the block's collision
// cube; include their full rendered height and rotated corners.
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::CopperGolem { .. } => {
// The statue pose rotates around the root, so some variants
// extend below the block as well as above it.
lo.y -= 1.0;
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::Conduit => {
// Its animated cage is centered above the conduit block and
// the wind model spans multiple cells. The caller bypasses
// voxel occlusion for envelopes this tall, but retains frustum
// culling with this conservative volume.
lo.x -= 1.0;
lo.y -= 1.0;
lo.z -= 1.0;
hi.x += 1.0;
hi.y += 1.0;
hi.z += 1.0;
}
Kind::Bell => {
// The bell's pivoted swing moves its corners slightly outside
// the block even though the model starts centered in it.
lo.x -= 0.1;
lo.y -= 0.1;
lo.z -= 0.1;
hi.x += 0.1;
hi.y += 0.1;
hi.z += 0.1;
}
Kind::Skull { .. } => {
// Wall-mounted and dragon heads can protrude from their block.
lo.x -= 0.25;
lo.y -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.y += 0.25;
hi.z += 0.25;
}
Kind::EndGateway { .. } => {
let anim = (scene.anim)(pos);
let beam = gateway_beam(anim.gateway, scene.partial, scene.max_y, scene.game_time);
let height = f64::from(beam.height.max(0));
lo.y -= height;
hi.y += height;
}
_ => {}
}
(lo, hi)
}
fn is_dynamic_visible(
&self,
scene: &Scene,
shapes: &Arc<BlockEntityShapes>,
pos: [i32; 3],
state: u32,
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
let Some(visible) = scene.dynamic_visible else {
return true;
};
if matches!(kind, Kind::EndGateway { .. }) {
return true;
}
// A pack model may extend outside the vanilla envelope. Keep that
// geometry on the uncullable path unless its model bounds are known.
if !Arc::ptr_eq(shapes, &self.vanilla) {
return true;
}
let (lo, hi) = self.dynamic_bounds(scene, pos, kind, chest_openness);
visible(pos, state, lo, hi)
}
fn cached_entry_is_current(
&self,
scene: &Scene,
@@ -1419,6 +1613,7 @@ impl BlockEntityRenderer {
options: CacheOptions,
entry: &StaticEntry,
section_revision: StaticSectionRevision,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
if entry.key.state != state
|| entry.key.nether != scene.nether
@@ -1430,15 +1625,24 @@ impl BlockEntityRenderer {
}
match kind {
Kind::Chest { .. } => {
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
// Double chests depend on the neighboring half's light too;
// let those uncommon models take the full key path.
open == 0.0 && other.is_none()
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if open != 0.0 {
return false;
}
let mut light = scene.world.light(pos[0], pos[1], pos[2]);
if let Some(other) = other {
let other_light = scene.world.light(other[0], other[1], other[2]);
light = (light >> 4).max(other_light >> 4) << 4
| (light & 15).max(other_light & 15);
}
entry.key.light == light
}
Kind::Shulker { .. } => {
let (progress, old) = (scene.anim)(pos).shulker;
@@ -1701,6 +1905,17 @@ impl BlockEntityRenderer {
}
let local = static_local_index(pos, section_min_y);
let current_section_revision = active_section_revision.unwrap_or_default();
let chest_openness = (cache.is_some()
&& options.chests
&& matches!(kind, Kind::Chest { .. }))
.then(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if cache_only && section_revision.is_some() && active_section_revision.is_none() {
break 'draw;
}
@@ -1716,6 +1931,7 @@ impl BlockEntityRenderer {
options,
&active_entries[index].1,
current_section_revision,
chest_openness,
)
{
let entry = &mut active_entries[index].1;
@@ -1735,7 +1951,9 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. }) {
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
let packed = scene.world.light(pos[0], pos[1], pos[2]);
let origin =
(DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
@@ -1756,11 +1974,25 @@ impl BlockEntityRenderer {
}
break 'draw;
}
if cache_only && chest_openness.is_some_and(|(open, _)| open != 0.0) {
if let Some(cache) = &mut cache
&& let Ok(index) =
active_entries.binary_search_by_key(&local, |(index, _)| *index)
{
active_entries.remove(index);
cache.len -= 1;
cache.mark_section_dirty(section);
}
break 'draw;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let mut packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, None) {
break 'draw;
}
if options.signs {
self.sign_text(scene, pos, state, origin, packed, out);
} else {
@@ -1779,8 +2011,15 @@ impl BlockEntityRenderer {
break 'draw;
}
if let Some(cache) = &mut cache
&& let Some(key) =
self.static_key(scene, pos, state, kind, options, &mut packed)
&& let Some(key) = self.static_key(
scene,
pos,
state,
kind,
options,
&mut packed,
chest_openness,
)
{
let slot = active_entries.binary_search_by_key(&local, |(index, _)| *index);
let entry = match slot {
@@ -1865,7 +2104,9 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. }) {
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
self.render_banner(
&shapes,
scene,
@@ -1892,6 +2133,9 @@ impl BlockEntityRenderer {
if cache_only {
break 'draw;
}
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, chest_openness) {
break 'draw;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, pos, kind, base, packed, lights, false, true, out);
}
@@ -1958,6 +2202,7 @@ impl BlockEntityRenderer {
kind: &Kind,
options: CacheOptions,
packed: &mut u8,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> Option<StaticKey> {
let mut sprite_rects = [None; 5];
let mut sprite_count = 0;
@@ -1975,12 +2220,14 @@ impl BlockEntityRenderer {
part,
..
} if options.chests => {
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if open != 0.0 {
return None;
}
@@ -3250,6 +3497,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
mod tests {
use super::*;
use crate::assets::Assets;
use horizon_world::nbt::Tag;
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn entity_block_particles_and_breaking_use_exported_models() -> Result<()> {
@@ -3362,6 +3610,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -3656,6 +3905,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
@@ -3728,6 +3978,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
@@ -3756,6 +4007,129 @@ mod tests {
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
/// The front text calls of a sign whose every glyph advances 6.
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
advance: 6.0,
..Glyph::default()
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
.into_iter()
.map(|c| (c.text, c.x, c.y, c.color, c.outline))
.collect()
}
fn sign_with(lines: [Tag; 4]) -> SignData {
let mut data = SignData::default();
data.decode(&Tag::compound([(
"front_text",
Tag::compound([("messages", Tag::List(lines.to_vec()))]),
)]));
data
}
fn colored(text: &str, color: &str) -> Tag {
Tag::compound([
("text", Tag::String(text.into())),
("color", Tag::String(color.into())),
])
}
/// `Font.StringRenderOutput`: a glyph takes its style's colour and the
/// sign's dark text colour only when the style has none (#9783). A line
/// of two colours is two calls, the second where the first ends, the
/// whole line centred.
#[test]
fn sign_text_takes_each_lines_own_colors() {
let mut two = colored("ab", "red");
let Tag::Compound(parts) = &mut two else {
unreachable!()
};
parts.insert("extra".into(), Tag::List(vec![colored("cd", "blue")]));
let data = sign_with([
Tag::String("plain".into()),
colored("Online", "green"),
two,
Tag::String("".into()),
]);
// Black dye: the dark text colour is black. Sign lines are 10 apart
// from 20 above the middle.
assert_eq!(
front_calls(&data, false),
[
("plain".to_owned(), -15.0, -20.0, 0xFF00_0000, 0),
("Online".to_owned(), -18.0, -10.0, 0xFF55_FF55, 0),
("ab".to_owned(), -12.0, 0.0, 0xFFFF_5555, 0),
("cd".to_owned(), 0.0, 0.0, 0xFF55_55FF, 0),
(String::new(), 0.0, 10.0, 0xFF00_0000, 0),
]
);
}
/// A glowing sign's own colour still wins over its dye, and the outline
/// stays the dye's dark colour. `§` codes in a line colour it too, and
/// `§r` goes back to the sign's colour.
#[test]
fn glowing_sign_text_keeps_its_outline_under_a_lines_own_colors() {
let mut data = sign_with([
Tag::String("plain".into()),
colored("Online", "green"),
Tag::String("§cRed§rX".into()),
Tag::String("".into()),
]);
data.front.color = 14;
data.front.glowing = true;
let (red, outline) = (0xFFFF_0000, 0xFF66_0000);
assert_eq!(
front_calls(&data, true),
[
("plain".to_owned(), -15.0, -20.0, red, outline),
("Online".to_owned(), -18.0, -10.0, 0xFF55_FF55, outline),
("Red".to_owned(), -12.0, 0.0, 0xFFFF_5555, outline),
("X".to_owned(), 6.0, 0.0, red, outline),
(String::new(), 0.0, 10.0, red, outline),
]
);
}
/// The text that fits the sign is drawn, in the colour it had.
#[test]
fn a_long_colored_line_is_cut_where_it_stops_fitting() {
// 90 wide: fifteen glyphs of 6.
let data = sign_with([
colored("abcdefghijklmnopqrst", "gold"),
Tag::String("".into()),
Tag::String("".into()),
Tag::String("".into()),
]);
assert_eq!(
front_calls(&data, false)[0],
("abcdefghijklmno".to_owned(), -45.0, -20.0, 0xFFFF_AA00, 0)
);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
@@ -3843,6 +4217,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
@@ -3983,6 +4358,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
@@ -4068,6 +4444,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// Reverse local-position order to check that cache indexing does not
// change the order of emitted entities.
@@ -446,6 +447,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -500,6 +502,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([100, 70, -32], 0)];
let other_block = [([116, 70, -32], 0)];
@@ -686,6 +689,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let enabled = CacheOptions {
@@ -739,6 +743,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -840,6 +845,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -894,6 +900,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -955,6 +962,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1050,6 +1058,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1138,6 +1147,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
let options = CacheOptions {
@@ -1213,6 +1223,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1291,6 +1302,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1357,6 +1369,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1404,6 +1417,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
@@ -1509,6 +1523,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
+82 -3
View File
@@ -90,9 +90,20 @@ pub fn effect_lines<'a>(
);
}
let instant = effect(e).is_some_and(|d| d.instant);
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
if !instant {
if duration == -1 {
// MobEffectInstance.isInfiniteDuration: formatDuration says
// the infinite sign, not a time.
line = format!(
"{line} ({})",
translate("effect.duration.infinite").unwrap_or_else(|| "∞".to_owned())
);
} else {
let scaled = (duration as f32 * scale) as i32;
if scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
}
}
(line, effect(e).map_or(Category::Neutral, |d| d.category))
})
@@ -487,6 +498,63 @@ mod tests {
assert!(!stand.idle());
}
/// The stand sleeps while `idle`, so its tick must change nothing then: run the real
/// tick on idle stands and compare, and check a stand with fuel to take wakes.
#[test]
fn an_idle_brewing_stand_tick_changes_nothing() {
let items = Items::for_tests(std::collections::HashMap::new());
let brewing = Brewing {
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
inputs: HashSet::new(),
recipes: vec![BrewingRecipe {
id: "awkward".into(),
input: PotionIngredient {
items: ["minecraft:potion".to_owned()].into_iter().collect(),
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
},
reagent: PotionIngredient {
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
potions: None,
},
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
}],
};
let providers = Providers::empty();
let water = || Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
let stand = |bottle: bool, fuel: i32, powder: bool| {
let mut s = BrewingStand::new();
s.fuel = fuel;
if bottle {
s.items[1] = water();
}
if powder {
s.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
}
s.last_bottles = Some([false, bottle, false]);
s
};
for before in [
stand(false, 0, false),
stand(true, 0, false),
stand(true, 5, false),
stand(true, 5, true),
] {
assert!(before.idle(), "{before:?}");
let mut after = before.clone();
assert_eq!(
after.tick(&brewing, &items, &providers),
BrewTick::default()
);
assert_eq!(format!("{after:?}"), format!("{before:?}"));
}
// A brewable ingredient starts the brew: not idle, and the tick changes it.
let mut brewing_now = stand(true, 5, false);
brewing_now.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
assert!(!brewing_now.idle());
brewing_now.tick(&brewing, &items, &providers);
assert_eq!(brewing_now.brew_time, 400);
}
/// #3312: `PotionContents.getPotionDescription` names the effects with
/// translatable components, so a Russian game reads them from ru_ru.json
/// (the prepared one; skipped without it), as the tooltip's other lines.
@@ -505,4 +573,15 @@ mod tests {
assert!(lines[0].0.starts_with("Замедление IV ("), "{lines:?}");
assert!(lines[1].0.starts_with("Сопротивление III ("), "{lines:?}");
}
#[test]
fn infinite_effects_say_the_infinite_sign() {
// MobEffectInstance.isInfiniteDuration: a -1 duration says the
// infinite sign, not a time; a short one still says nothing.
let translate = |key: &str| (key == "effect.duration.infinite").then(|| "∞".to_owned());
let lines = effect_lines([("minecraft:resistance", -1, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance (∞)");
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
assert_eq!(lines[0].0, "resistance");
}
}
+199
View File
@@ -1,3 +1,27 @@
// `joml::Mat4::rotation`, `rotate_affine`, `transform_affine` and `invert_orthonormal`, and
// `joml::Quat::rotation_x`/`_y`/`_z`, in this file are adapted from JOML 1.10.9's `Matrix4f`,
// `Quaternionf` and `Vector4f` (https://github.com/JOML-CI/JOML, MIT; see THIRD_PARTY_NOTICES.md).
//
// Copyright (c) 2015-2026 Richard Greenlees
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
@@ -119,6 +143,40 @@ pub mod joml {
w: w * cz + z * sz,
}
}
/// `Quaternionf.rotationX(angle)`.
pub fn rotation_x(angle: f32) -> Self {
let (x, w) = Self::half_turn(angle);
Self {
x,
y: 0.0,
z: 0.0,
w,
}
}
/// `Quaternionf.rotationY(angle)`.
pub fn rotation_y(angle: f32) -> Self {
let (y, w) = Self::half_turn(angle);
Self {
x: 0.0,
y,
z: 0.0,
w,
}
}
/// `Quaternionf.rotationZ(angle)`.
pub fn rotation_z(angle: f32) -> Self {
let (z, w) = Self::half_turn(angle);
Self {
x: 0.0,
y: 0.0,
z,
w,
}
}
fn half_turn(angle: f32) -> (f32, f32) {
let sin = sin(angle * 0.5);
(sin, cos_from_sin(sin, angle * 0.5))
}
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
let [x, y, z] = v;
@@ -142,8 +200,10 @@ pub mod joml {
}
const PERSPECTIVE: u8 = 1;
const AFFINE: u8 = 2;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
const ORTHONORMAL: u8 = 16;
/// A JOML `Matrix4f` with its property flags, which select the code
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
@@ -284,10 +344,149 @@ pub mod joml {
// already taken that branch.
self.rotate_axis(2, ang);
}
/// `Matrix4f.rotation(Quaternionfc)`.
pub fn rotation(&mut self, q: Quat) {
let (w2, x2, y2, z2) = (q.w * q.w, q.x * q.x, q.y * q.y, q.z * q.z);
let zw = q.z * q.w;
let dzw = zw + zw;
let xy = q.x * q.y;
let dxy = xy + xy;
let xz = q.x * q.z;
let dxz = xz + xz;
let yw = q.y * q.w;
let dyw = yw + yw;
let yz = q.y * q.z;
let dyz = yz + yz;
let xw = q.x * q.w;
let dxw = xw + xw;
self.reset_unless_identity();
self.set(0, 0, w2 + x2 - z2 - y2);
self.set(0, 1, dxy + dzw);
self.set(0, 2, dxz - dyw);
self.set(1, 0, -dzw + dxy);
self.set(1, 1, y2 - z2 + w2 - x2);
self.set(1, 2, dyz + dxw);
self.set(2, 0, dyw + dxz);
self.set(2, 1, dyz - dxw);
self.set(2, 2, z2 - y2 - x2 + w2);
self.properties = AFFINE | ORTHONORMAL;
}
/// An affine matrix of no more specific kind, as JOML's property flags have it.
pub fn affine(m: glam::Mat4) -> Self {
Self {
m: m.to_cols_array(),
properties: AFFINE,
}
}
/// `Matrix4f.rotate(Quaternionfc)` of an affine matrix that is neither the identity nor
/// a pure translation (`rotateAffine`).
pub fn rotate_affine(&mut self, q: Quat) {
debug_assert!(
self.properties & AFFINE != 0 && self.properties & (IDENTITY | TRANSLATION) == 0
);
let (w2, x2, y2, z2) = (q.w * q.w, q.x * q.x, q.y * q.y, q.z * q.z);
let zw = q.z * q.w;
let dzw = zw + zw;
let xy = q.x * q.y;
let dxy = xy + xy;
let xz = q.x * q.z;
let dxz = xz + xz;
let yw = q.y * q.w;
let dyw = yw + yw;
let yz = q.y * q.z;
let dyz = yz + yz;
let xw = q.x * q.w;
let dxw = xw + xw;
let r = [
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
];
let s = *self;
for (column, r) in r.iter().enumerate() {
for row in 0..3 {
let v = s.get(0, row) * r[0] + s.get(1, row) * r[1] + s.get(2, row) * r[2];
self.set(column, row, v);
}
self.set(column, 3, 0.0);
}
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
}
/// `Vector4f.mul(Matrix4fc)` of an affine matrix (`mulAffine`, JOML's `Math.fma`
/// unfused): the matrix times `v`, `w` kept.
pub fn transform_affine(&self, v: [f32; 4]) -> [f32; 3] {
debug_assert!(self.properties & AFFINE != 0);
let [x, y, z, w] = v;
std::array::from_fn(|row| {
self.get(0, row) * x
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row) * w))
})
}
/// `Matrix4f.invert()` of a rotation and translation (`invertOrthonormal`; with the
/// identity and pure-translation shortcuts). `None` for any other matrix, which JOML
/// inverts by other code paths.
pub fn invert_orthonormal(&self) -> Option<Self> {
if self.properties & IDENTITY != 0 {
return Some(Self::IDENTITY);
}
let mut out = *self;
if self.properties & TRANSLATION != 0 {
for row in 0..3 {
out.set(3, row, -self.get(3, row));
}
out.properties = AFFINE | TRANSLATION | ORTHONORMAL;
return Some(out);
}
if self.properties & ORTHONORMAL == 0 {
return None;
}
let g = |c, r| self.get(c, r);
let t = |c| -(g(c, 0) * g(3, 0) + g(c, 1) * g(3, 1) + g(c, 2) * g(3, 2));
out.m = [
g(0, 0),
g(1, 0),
g(2, 0),
0.0,
g(0, 1),
g(1, 1),
g(2, 1),
0.0,
g(0, 2),
g(1, 2),
g(2, 2),
0.0,
t(0),
t(1),
t(2),
1.0,
];
out.properties = AFFINE | ORTHONORMAL;
Some(out)
}
pub fn to_glam(self) -> glam::Mat4 {
glam::Mat4::from_cols_array(&self.m)
}
}
}
use joml::{Mat4, Quat};
/// `Camera.getViewRotationMatrix` to the last bit: `Matrix4f.rotation` of the conjugate of
/// `setRotation`'s quaternion, and its JOML `invert()`. `view_matrix` turns the same quaternion
/// into a matrix by glam's formula, which differs in the last bits (1.0 for 0.99999994, 6e-8 for
/// 0).
pub fn view_rotation_matrix(yaw: f32, pitch: f32) -> (glam::Mat4, glam::Mat4) {
let q = Quat::rotation_yxz(PI - yaw * DEG_TO_RAD, -pitch * DEG_TO_RAD, 0.0);
let mut m = Mat4::IDENTITY;
m.rotation(Quat {
x: -q.x,
y: -q.y,
z: -q.z,
w: q.w,
});
let inverse = m.invert_orthonormal().expect("a rotation is orthonormal");
(m.to_glam(), inverse.to_glam())
}
/// Camera-relative world view used by terrain, sky and the first-person hand.
pub fn view_matrix(yaw: f32, pitch: f32) -> glam::Mat4 {
// Vanilla uses the conjugate of Camera.rotation, not a world-up look-at.
@@ -431,6 +431,16 @@ pub struct SignText {
pub glowing: bool,
/// The `click_event` in each line's component style, as saved.
pub click: [Option<Tag>; 4],
/// Each line's saved component when it is more than plain text (a colour,
/// siblings, a list), so its style survives loading, saving and the
/// network. See `SignText::message` for a line edited since.
pub saved: [Option<SavedLine>; 4],
}
/// A sign line's saved component and the text it showed when it was read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SavedLine {
pub text: String,
pub component: Tag,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -749,6 +749,43 @@ mod tests {
assert!(c.idle(false));
}
/// The campfire sleeps while `idle`, so the tick its block runs (`cook_tick` lit,
/// `cooldown_tick` unlit) must change nothing then, and must change an awake one.
#[test]
fn an_idle_campfire_tick_changes_nothing() {
let tick = |c: &mut Campfire, lit: bool| {
if lit {
!c.cook_tick().is_empty()
} else {
c.cooldown_tick()
}
};
let empty = Campfire::default();
let mut food = Campfire::default();
assert!(food.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
let mut warm = food.clone();
warm.cook_tick();
let mut cooled = warm.clone();
while cooled.cooldown_tick() {}
for (before, lit) in [
(&empty, true),
(&empty, false),
(&food, false),
(&cooled, false),
] {
assert!(before.idle(lit), "{before:?} lit {lit}");
let mut after = before.clone();
assert!(!tick(&mut after, lit));
assert_eq!(&after, before, "lit {lit}");
}
for (before, lit) in [(&food, true), (&warm, false)] {
assert!(!before.idle(lit), "{before:?} lit {lit}");
let mut after = before.clone();
tick(&mut after, lit);
assert_ne!(&after, before, "lit {lit}");
}
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
File diff suppressed because it is too large. Load diff
+79 -9
View File
@@ -66,6 +66,8 @@ pub struct Extras {
/// Modrinth. Off by default: it is Horizon's own and the screen is
/// otherwise vanilla's (tools/test_pack_screen_parity.py).
pub pack_browser: bool,
/// Make /pregen available in single-player and hosted LAN worlds.
pub pregen: bool,
pub fullbright: bool,
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
pub dynamic_lights: bool,
@@ -166,9 +168,17 @@ pub struct Extras {
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_auras: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -180,6 +190,7 @@ impl Default for Extras {
fn default() -> Self {
Self {
pack_browser: false,
pregen: false,
fullbright: false,
dynamic_lights: false,
zoom: false,
@@ -225,7 +236,10 @@ impl Default for Extras {
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_auras: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -259,6 +273,7 @@ impl Extras {
};
match key.trim() {
"packBrowser" => e.pack_browser = flag(e.pack_browser),
"pregen" => e.pregen = flag(e.pregen),
"fullbright" => e.fullbright = flag(e.fullbright),
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
"zoom" => e.zoom = flag(e.zoom),
@@ -335,6 +350,9 @@ impl Extras {
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -345,8 +363,9 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -399,6 +418,11 @@ impl Extras {
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_auras),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -570,24 +594,25 @@ pub enum Item {
WheelCyclesSettings,
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
Pregen,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
let mut rows = vec![
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("horizon.extras.header.particles"),
Ok(HideBlockParticles),
Err("horizon.extras.header.inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -596,6 +621,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("horizon.extras.header.chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("horizon.extras.header.world"),
Ok(Pregen),
Err("horizon.extras.header.multiplayer"),
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
@@ -609,8 +636,11 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsAuras),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
@@ -639,9 +669,13 @@ impl Item {
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -668,9 +702,13 @@ impl Item {
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::Pregen => "horizon.extras.pregen.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -706,9 +744,13 @@ impl Item {
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -724,7 +766,8 @@ impl Item {
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsAuras => {
| CosmeticsOthers | CosmeticsAuras | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
return None;
}
})
@@ -732,6 +775,9 @@ impl Item {
/// The English description: the fallback of [`Item::description_text`].
pub fn description(self) -> &'static str {
match self {
Item::Pregen => {
"Enables /pregen in your single-player and LAN worlds. With cheats enabled, use /pregen start to generate an area before exploring it. Does not enable the command on servers you join."
}
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
@@ -789,6 +835,15 @@ impl Item {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -815,7 +870,12 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine | Item::CosmeticsOthers | Item::CosmeticsAuras => Some(Cost::Low),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsAuras
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -841,7 +901,11 @@ impl Item {
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsAuras => e.cosmetics_auras,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -896,6 +960,7 @@ impl Item {
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::Pregen => e.pregen = !e.pregen,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
// From what the button shows; the result is the player's choice from now on.
@@ -903,6 +968,9 @@ impl Item {
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
@@ -993,7 +1061,7 @@ mod tests {
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
@@ -1018,6 +1086,8 @@ mod tests {
e.minimap_rotate = true;
e.minimap_corner = Corner::BottomLeft;
e.auto_reconnect = true;
Item::Pregen.cycle(&mut e, false);
assert_eq!(Item::Pregen.switch(&e), Some(true));
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
+4
View File
@@ -936,6 +936,10 @@ impl Font {
offset: 0.5,
})
}
/// Starts the random `random_glyph` draws on over from `seed`.
pub fn reseed(&self, seed: i64) {
self.cache.borrow_mut().random = horizon_core::random::LegacyRandom::new(seed);
}
/// `FontSet.getRandomGlyph`: what an obfuscated character draws, a random
/// glyph of the font as wide as `advance` (rounded up), else the missing
/// glyph.
File diff suppressed because it is too large. Load diff
+28 -3
View File
@@ -55,6 +55,13 @@ const RETEST: u32 = 8;
/// more than they save. Else one frame in `PROBE` tests every section.
const MIN_SHARE: f32 = 0.1;
const PROBE: u32 = 64;
/// The gate, for frame `stamp`, with `share` the newest result's hidden share:
/// whether the frame culls, and whether it probes (tests every section). GPU
/// culling's frames take it too.
pub(super) fn gate(share: f32, stamp: u32) -> (bool, bool) {
let probe = share < MIN_SHARE;
(!probe || stamp.is_multiple_of(PROBE), probe)
}
/// On Metal, sections in view from which a frame culls. Apple's GPUs store
/// and reload the whole frame at each of the passes the test adds, a fixed
/// cost that the sections it hides repay only at high render distances: on
@@ -232,6 +239,11 @@ impl Cull {
))
.then_some(depth_test)
}
/// Its test runs in phase 1's pass, the depth test forced early (Vulkan):
/// GPU culling's frames can run it too.
pub fn early(&self) -> bool {
self.depth_test == DepthTest::Early
}
/// `color` is the world pass's colour format.
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat, depth_test: DepthTest) -> Self {
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
@@ -275,7 +287,7 @@ impl Cull {
0,
fragment,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
@@ -422,8 +434,12 @@ impl Cull {
self.test_slots.clear();
self.test_indices.clear();
self.total = 0;
self.probe = !self.always && self.hidden.1 < MIN_SHARE;
!self.probe || self.stamp.is_multiple_of(PROBE)
let (culls, probe) = match self.always {
true => (true, false),
false => gate(self.hidden.1, self.stamp),
};
self.probe = probe;
culls
}
/// Whether phase 1 draws the section in resident `slot` (or the far tile
@@ -718,6 +734,15 @@ fn source(depth_test: DepthTest) -> String {
mod tests {
use super::*;
/// Culling while the newest result hid enough; else one frame in `PROBE`
/// tests every section. GPU culling's frames take the same gate.
#[test]
fn the_gate_culls_while_enough_is_hidden_and_probes_else() {
assert_eq!(gate(MIN_SHARE, 3), (true, false));
assert_eq!(gate(MIN_SHARE / 2.0, 3), (false, true));
assert_eq!(gate(MIN_SHARE / 2.0, PROBE), (true, true));
}
#[test]
fn shaders_validate() {
for (depth_test, capabilities) in [
@@ -114,8 +114,10 @@ fn see(t: u32) {
}
}
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs).
@group(1) @binding(0) var depth: texture_depth_2d;
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs). A depth
// view bound as an unfilterable float: naga's GLSL backend cannot `textureLoad`
// a depth texture, and the texel is the same value.
@group(1) @binding(0) var depth: texture_2d<f32>;
// The world pass's reversed depth test, greater or equal passing, made here:
// where it cannot be forced before a shader that writes storage, a fragment
@@ -123,7 +125,7 @@ fn see(t: u32) {
// runs only for the fragments that pass it.
@fragment
fn fs_box(in: Box) {
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0).r {
see(in.test);
}
}
@@ -14,7 +14,8 @@ use super::Vertex;
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
const MATERIALS: usize = 10;
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
const MATERIALS: usize = 12;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
@@ -51,6 +52,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
/// drawn like an ordinary entity, pulled toward the camera.
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// A native world canvas, drawn after particles with normal text depth/culling.
pub const WORLD_TEXT_OVERLAY: u32 = 39;
/// The front of its eight-offset outlined text, using POLYGON_OFFSET.
pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
@@ -59,6 +64,8 @@ fn material(layer: u32, oit: bool) -> usize {
return 9;
}
match layer & 0xffff {
WORLD_TEXT_OVERLAY => 10,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -118,7 +125,7 @@ impl EntityBatch {
self.combined.clear();
self.combined
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
for runs in &self.runs {
for runs in &self.runs[..10] {
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
@@ -141,6 +148,27 @@ impl EntityBatch {
}
}
}
// RenderType.text sorts alpha-blended quads from far to near. Keep
// both world-canvas layers after existing materials (including eyes
// moved out of additive); front text uses polygon offset.
for runs in &self.runs[10..] {
let start = self.combined.len();
for &(first, count) in runs {
self.combined
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
self.combined[start..]
.as_chunks_mut::<4>()
.0
.sort_by(|a, b| {
let distance = |q: &[Vertex; 4]| {
(0..3)
.map(|i| ((q[0].pos[i] + q[2].pos[i]) * 0.5).powi(2))
.sum::<f32>()
};
distance(b).total_cmp(&distance(a))
});
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
@@ -160,6 +188,41 @@ impl EntityBatch {
mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
let quad = |layer, x| {
[Vertex {
layer,
pos: [x, 0.0, 0.0],
..geometry(1, false)[0]
}; 4]
};
let entities: Vec<_> = [
(0, 1.0),
(35, 2.0),
(OIT_ENTITY, 3.0),
(WORLD_TEXT_OVERLAY, 4.0),
(WORLD_TEXT_OVERLAY, 5.0),
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let additive: Vec<_> = [(OIT_ENTITY | 2, 7.0), (2, 8.0)]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
assert_eq!(
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
);
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
positions,
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
);
}
// Retain the original implementation as the equivalence/benchmark oracle.
fn baseline(
@@ -257,12 +320,12 @@ mod tests {
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
@@ -52,7 +52,9 @@ struct Params {
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var linear_clamp: sampler;
@group(0) @binding(2) var input_depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(2) var input_depth: texture_2d<f32>;
@group(0) @binding(3) var current_color: texture_2d<f32>;
@group(0) @binding(4) var previous_color: texture_2d<f32>;
@group(0) @binding(5) var dilated_depth_out: texture_storage_2d<r32float, write>;
@@ -333,7 +335,7 @@ fn load_input_depth(p: vec2<i32>) -> f32 {
if !is_on_screen(p, params.render_size) {
return 0.0;
}
return textureLoad(input_depth, p, 0);
return textureLoad(input_depth, p, 0).r;
}
@compute @workgroup_size(8, 8)
@@ -12,7 +12,9 @@ struct Params {
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(1) var depth: texture_2d<f32>;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
@@ -38,7 +40,7 @@ struct Out {
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0).r;
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
@@ -31,6 +31,12 @@
//! walk's would and a thin shell more, alike on every backend; a frame whose
//! sums could leave i32 is drawn by the CPU (`Prepared::Cpu`).
//!
//! Its statistics, the sections the pass kept and their draws (`counts[0]`,
//! `counts[1]`), are copied after the pass into one of `READBACKS` small
//! buffers with the CPU's part of the same frame (`copy_stats`), mapped once the
//! frame is submitted (`submitted`, from the frame's after-submit hook) and
//! taken in a frame or a few later (`collect`); nothing waits for them.
//!
//! A record also holds where its section's buckets of solid quads by the side
//! they face end, and what `mesher::Sides::visible_from` compares the eye with
//! for each, its margin in (`Sides::thresholds`); the tables hold the eye less
@@ -70,9 +76,17 @@ use super::{
submit::Queue,
upload::Uploads,
};
use crate::mesher::Sides;
use glam::{DVec3, DVec4, Vec3};
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
use crate::{graphics::GpuCullingOffer, mesher::Sides};
use glam::{DVec3, DVec4, Mat4, Vec3};
use std::{
cmp::Reverse,
collections::BinaryHeap,
sync::{
Arc,
atomic::{AtomicU8, Ordering},
},
time::Instant,
};
/// One section (`gpu_culling.wgsl`'s `Section`).
#[repr(C)]
@@ -176,9 +190,9 @@ fn threshold_key(t: f32, positive: bool) -> u32 {
/// The most draws a section has: the runs its buckets that can face the eye
/// lie in. Two runs are apart by a bucket left out, so 7 buckets hold at most 4.
const RUNS: u32 = 4;
/// Words per id in `runs` (the shader's `RUN_WORDS`): its page and draws, then
/// each draw's first quad and quads.
const RUN_WORDS: u64 = 1 + 2 * RUNS as u64;
/// Words per id in `runs` (the shader's `RUN_WORDS`): its page, flags and draws,
/// then each draw's first quad and quads, then its two occlusion stamps.
const RUN_WORDS: u64 = 1 + 2 * RUNS as u64 + 2;
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
/// frustum plane rejects it, so it is drawn wherever its column is within the
@@ -256,15 +270,36 @@ struct Params {
/// Its height in sections, and the level's (the sky band's top section).
reach_height: i32,
reach_sections: i32,
/// `REACH`, `SKY`, `EYE`.
/// `REACH`, `SKY`, `EYE`, `OCCLUDE`, `PROBE`.
flags: u32,
/// With `EYE`, where the eye's keys start in `tables`, in words.
eyes: u32,
/// With `OCCLUDE`: the frame's stamp (never 0), where the boxes to test
/// start in `counts`, in words, 0, 0.
occl: [u32; 4],
/// The world pass's view projection, which the test draws its boxes with.
view_proj: [[f32; 4]; 4],
}
/// Counts before the pages' (the shader's `STATS`): the sections `cull` kept,
/// and the draws, fewer only where a region was short.
const STATS: u64 = 2;
/// the draws (fewer only where a region was short), then occlusion's: the
/// quads of the sections with runs and of the tested boxes not seen (the
/// gate's share), the box draw (its instances: the boxes tested), and the
/// boxes not seen.
const STATS: u64 = 12;
const QUADS: usize = 2;
const HIDDEN_QUADS: usize = 3;
const BOX: u64 = 4;
const HIDDEN: usize = 9;
/// Statistics on their way back at once, a frame or a few late so the CPU never
/// waits (as `cull`'s results).
const READBACKS: usize = 3;
const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// Its mapping failed or the device lost its buffer: never copied into or
/// unmapped again, so the statistics stop (as `timing::GpuTimer`'s).
const FAILED: u8 = 3;
/// In `Params::flags` (the shader's `REACH`): cave culling's graph is in
/// `reach`.
const REACH: u32 = 1;
@@ -274,6 +309,10 @@ const SKY: u32 = 2;
/// In `Params::flags` (the shader's `EYE`): solid quads facing away from the
/// eye are left out, its keys in `tables` from `Params::eyes`.
const EYE: u32 = 4;
/// In `Params::flags` (the shader's `OCCLUDE` and `PROBE`): the frame occludes,
/// and it tests every box (cull.rs's probe frame).
const OCCLUDE: u32 = 8;
const PROBE: u32 = 16;
/// Ids per block (the shader's `BLOCK`), and the workgroup of the passes over ids.
const WORKGROUP: u32 = 64;
/// Workgroups along x when there are more (the shader's `ROW`).
@@ -314,6 +353,11 @@ pub(super) struct FrameInputs<'a> {
/// from it are left out (`sides_on`); `None`: every solid quad is drawn. A
/// GPU frame has no water mirror, whose eye below would see more of them.
pub eye: Option<Vec3>,
/// The world pass's view projection (gfx.rs `globals.view_proj`), and
/// whether occlusion culling is on: frames that can run its test then
/// do, by its gate (`GpuCulling::occluding`).
pub view_proj: Mat4,
pub occlude: bool,
}
/// What `GpuCulling::prepare` made of a frame.
@@ -475,10 +519,31 @@ pub(super) fn supported(
// The pass binds 8, the last cave culling's graph.
&& limits.max_storage_buffers_per_shader_stage >= 8
&& !super::flat::is_broken_intel_vulkan(info)
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
// pulled solid pipelines (those of a device with flat page groups) read the page.
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
&& !loses_base_vertex(info, flat_min_sections.is_some())
}
/// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
/// pulled solid pipelines (those of a device with flat page groups) read the page.
/// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
pub(super) fn loses_base_vertex(info: &wgpu::AdapterInfo, flat_page_groups: bool) -> bool {
info.backend == wgpu::Backend::Dx12 && flat_page_groups
}
/// What the settings offer (`graphics::GpuCullingOffer`): where `supported`
/// holds, but not on a software adapter (WARP, lavapipe), whose pass would run
/// on the CPU beside the frame, as occlusion culling's default has it; where it
/// doesn't, DX12 with flat page groups says why (`loses_base_vertex`).
pub(super) fn offer(
info: &wgpu::AdapterInfo,
supported: bool,
flat_page_groups: bool,
) -> GpuCullingOffer {
match (supported, super::is_software(info.device_type)) {
(false, _) if loses_base_vertex(info, flat_page_groups) => GpuCullingOffer::Dx12,
(false, _) => GpuCullingOffer::Unsupported,
(true, true) => GpuCullingOffer::Software,
(true, false) => GpuCullingOffer::Offered,
}
}
/// A buffer that is recreated larger when it must hold more.
@@ -523,6 +588,20 @@ impl Grown {
self.bytes = size;
Some(true)
}
/// `reserve`, keeping what the buffer held (`runs`' stamps).
fn reserve_keeping(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
bytes: u64,
) -> Option<bool> {
let (old, old_bytes) = (self.buffer.clone(), self.bytes);
let made = self.reserve(device, bytes)?;
if made {
encoder.copy_buffer_to_buffer(&old, 0, &self.buffer, 0, old_bytes);
}
Some(made)
}
}
/// The section table as the CPU keeps it, by id, and what changed since
@@ -653,6 +732,7 @@ impl Records {
self.fading = 0;
}
/// The sections with a record.
#[cfg(any(test, debug_assertions))]
pub fn live(&self) -> usize {
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
self.records.len() - free as usize
@@ -730,6 +810,159 @@ impl Fades {
}
}
/// One GPU frame's statistics on their way back: `counts[..STATS]`, and what
/// the CPU drew of the same frame.
struct Readback {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
/// Its frame (gfx.rs `frame_serial`), and the CPU's part of its statistics:
/// the sections it drew that the pass didn't count, its section draws, and
/// every draw call.
frame: u64,
cpu: [usize; 3],
/// Its frame occluded: its counts carry the test's.
occluded: bool,
}
/// The occlusion test (`gpu_occlusion.wgsl`): its boxes' pipeline and bind
/// group, and phase 2's passes (count2, scan2, scatter2).
struct BoxTest {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
group: Option<wgpu::BindGroup>,
phase2: [wgpu::ComputePipeline; 3],
}
impl BoxTest {
/// `color`: the world pass's colour format. `None` when the device fails to
/// make the pipelines: GPU culling then runs without the test.
fn new(
device: &wgpu::Device,
color: wgpu::TextureFormat,
compute: &wgpu::BindGroupLayout,
) -> Option<Self> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let storage = |read_only| wgpu::BufferBindingType::Storage { read_only };
use wgpu::ShaderStages as S;
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("GPU culling occlusion test"),
entries: &[
entry(0, S::VERTEX_FRAGMENT, wgpu::BufferBindingType::Uniform),
entry(9, S::VERTEX, storage(true)),
entry(10, S::VERTEX, storage(true)),
entry(11, S::FRAGMENT, storage(false)),
entry(12, S::VERTEX, storage(true)),
],
});
let source = [
include_str!("gpu_culling.wgsl"),
include_str!("gpu_occlusion.wgsl"),
]
.concat();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("GPU culling occlusion test"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let layouts = [Some(&layout)];
let box_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("GPU culling occlusion test"),
bind_group_layouts: &layouts,
..Default::default()
});
// cull.rs's `Early` pipeline: front faces against the world pass's
// reversed depth, without writes.
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("GPU culling occlusion test"),
layout: Some(&box_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_box"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_box"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
multiview_mask: None,
cache: None,
});
let compute_layouts = [Some(compute)];
let compute_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("GPU culling phase 2"),
bind_group_layouts: &compute_layouts,
..Default::default()
});
let phase2 = ["count2", "scan2", "scatter2"].map(|entry| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("GPU culling phase 2"),
layout: Some(&compute_layout),
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: GPU culling's occlusion test unavailable ({e})");
return None;
}
Some(Self {
pipeline,
layout,
group: None,
phase2,
})
}
/// Its bind group: the parameters, then the test list (`counts`), the
/// origins, the stamps (`runs`) and the records.
fn bind(&self, device: &wgpu::Device, buffers: [&wgpu::Buffer; 5]) -> wgpu::BindGroup {
let entries = [0, 9, 10, 11, 12]
.into_iter()
.zip(buffers)
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("GPU culling occlusion test"),
layout: &self.layout,
entries: &entries.collect::<Vec<_>>(),
})
}
}
/// The pass, its buffers and the records it draws from: made when GPU culling
/// is switched on, dropped when it is switched off.
pub(super) struct GpuCulling {
@@ -769,12 +1002,28 @@ pub(super) struct GpuCulling {
/// This frame's updates and table words, kept for their room.
scratch: Vec<Update>,
words: Vec<u32>,
/// The statistics on their way back (`Readback`), the next one to copy
/// into, and this frame's copy.
readbacks: Vec<Readback>,
next: usize,
current: Option<usize>,
/// The occlusion test, where the device can run it (Vulkan, with cull.rs's
/// test forced early: `Cull::early`).
test: Option<BoxTest>,
/// Advanced by every frame that may occlude (never 0); the newest share of
/// quads the test hid, with its frame; whether this frame occludes; and the
/// newest result's boxes not seen and tested.
stamp: u32,
share: (u64, f32),
occluding: bool,
hidden: Option<[usize; 2]>,
}
impl GpuCulling {
/// `None` when the device fails to make the pipelines or the memory for the
/// buffers is missing (caught, not raised: raised errors are fatal).
pub fn new(device: &wgpu::Device) -> Option<Self> {
/// buffers is missing (caught, not raised: raised errors are fatal). `test`:
/// the world pass's colour format, where frames can run the occlusion test.
pub fn new(device: &wgpu::Device, test: Option<wgpu::TextureFormat>) -> Option<Self> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
@@ -827,7 +1076,24 @@ impl GpuCulling {
log::warn!("Horizon renderer: GPU culling unavailable ({e})");
return None;
}
let test = test.and_then(|color| BoxTest::new(device, color, &layout));
use wgpu::BufferUsages as U;
let readbacks = (0..READBACKS)
.map(|_| {
Some(Readback {
buffer: try_create(
device,
"GPU culling statistics",
STATS * 4,
U::MAP_READ | U::COPY_DST,
)?,
state: Arc::new(AtomicU8::new(FREE)),
frame: 0,
cpu: [0; 3],
occluded: false,
})
})
.collect::<Option<Vec<_>>>()?;
Some(Self {
pipelines,
layout,
@@ -859,7 +1125,8 @@ impl GpuCulling {
device,
"GPU culling counts",
(STATS + 64) * 4,
U::STORAGE | U::INDIRECT | U::COPY_DST | TEST_READ,
// The counts are copied out for the statistics (`copy_stats`).
U::STORAGE | U::INDIRECT | U::COPY_DST | U::COPY_SRC,
)?,
draws: Grown::new(
device,
@@ -873,7 +1140,13 @@ impl GpuCulling {
1024 * 16,
U::VERTEX | U::STORAGE | TEST_READ,
)?,
runs: Grown::new(device, "GPU culling runs", 1024 * RUN_WORDS * 4, U::STORAGE)?,
// Kept when it grows (`reserve_keeping`): it holds the stamps.
runs: Grown::new(
device,
"GPU culling runs",
1024 * RUN_WORDS * 4,
U::STORAGE | U::COPY_SRC | U::COPY_DST,
)?,
// Room for render distance 64's graph, 57.5 KB.
reach: Grown::new(
device,
@@ -891,6 +1164,15 @@ impl GpuCulling {
pass_pages: 1,
scratch: Vec::new(),
words: Vec::new(),
readbacks,
next: 0,
current: None,
test,
stamp: 0,
// cull.rs's start: culling until a result says otherwise.
share: (0, 1.0),
occluding: false,
hidden: None,
})
}
@@ -912,6 +1194,7 @@ impl GpuCulling {
self.pages.clear();
self.ids = 0;
self.n_updates = 0;
self.occluding = false;
self.prepare_buffers(device, queue, uploads, encoder, input)
.unwrap_or(Prepared::OutOfMemory)
}
@@ -955,12 +1238,18 @@ impl GpuCulling {
let pass_pages = self.records.page_sections.len().max(1) as u32;
// A region has room for every draw of every record on its page.
let total = RUNS * self.records.page_sections.iter().sum::<u32>();
// The statistics, each page's count, then each block's per page.
let count_words = STATS + u64::from(pass_pages) * (1 + u64::from(ids.div_ceil(WORKGROUP)));
// The statistics, each page's count in both phases, each block's per
// page in both phases, then where the test exists the boxes to test.
let blocks = u64::from(ids.div_ceil(WORKGROUP));
let phase1_end = STATS + u64::from(pass_pages) * (2 + blocks);
let tests_at = phase1_end + u64::from(pass_pages) * blocks;
let count_words = match self.test {
Some(_) => tests_at + u64::from(ids),
None => phase1_end,
};
for (buffer, bytes) in [
(&mut self.updates, self.scratch.len() as u64 * Update::BYTES),
(&mut self.origins, u64::from(ids) * 16),
(&mut self.runs, u64::from(ids) * RUN_WORDS * 4),
(&mut self.tables, u64::from(regions + 2 * pass_pages) * 4),
(&mut self.counts, count_words * 4),
(&mut self.draws, u64::from(total.max(1)) * 20),
@@ -969,6 +1258,12 @@ impl GpuCulling {
self.group = None;
}
}
if self
.runs
.reserve_keeping(device, encoder, u64::from(ids) * RUN_WORDS * 4)?
{
self.group = None;
}
// A copy of the graph made anew holds none.
if let Some(r) = &input.reach
&& self.reach.reserve(device, r.bits.len() as u64 * 4)?
@@ -982,6 +1277,16 @@ impl GpuCulling {
let Some(params) = planes else {
return Some(Prepared::Cpu);
};
// Occlusion, by cull.rs's gate over the share the newest test hid.
let mut occl = 0;
if self.test.is_some() && input.occlude {
self.stamp = self.stamp.wrapping_add(1).max(1);
let (occludes, probe) = super::cull::gate(self.share.1, self.stamp);
if occludes && ids > 0 {
self.occluding = true;
occl = OCCLUDE | if probe { PROBE } else { 0 };
}
}
if self.records.all_dirty {
// The whole table, after it grew or more records changed than
// updates take (GPU culling switched on in a loaded world, a
@@ -1025,8 +1330,11 @@ impl GpuCulling {
updates: self.scratch.len() as u32,
pages: pass_pages,
regions,
occl: [self.stamp, tests_at as u32, 0, 0],
view_proj: input.view_proj.to_cols_array_2d(),
..params
};
params.flags |= occl;
if let Some(r) = &input.reach {
if self.reach_generation != Some(r.generation) {
// Through the belt: a search lands every few frames while the
@@ -1040,9 +1348,24 @@ impl GpuCulling {
params.flags |= REACH | if r.sky_reached { SKY } else { 0 };
}
uploads.buffer(encoder, &self.params, 0, bytemuck::bytes_of(&params));
encoder.clear_buffer(&self.counts.buffer, 0, Some(count_words * 4));
// A frame that occludes reads phase 2's blocks too; the boxes to test
// are written before they are read.
let cleared = if self.occluding { tests_at } else { phase1_end };
encoder.clear_buffer(&self.counts.buffer, 0, Some(cleared * 4));
if self.group.is_none() {
self.group = Some(self.bind(device));
if let Some(test) = &mut self.test {
test.group = Some(test.bind(
device,
[
&self.params,
&self.counts.buffer,
&self.origins.buffer,
&self.runs.buffer,
&self.sections.buffer,
],
));
}
}
self.ids = ids;
self.n_updates = params.updates;
@@ -1102,11 +1425,13 @@ impl GpuCulling {
/// The kept sections' solid draws, a count draw per page, with the quad
/// indices bound. Leaves the origins at vertex slot 1. `group`: the page's group 1 for
/// pipelines that read the page themselves (`Flat::page_group`), else none.
/// `phase`: 0, or 1 after `compact` in a frame that occludes.
pub fn draw<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
arena: &Arena,
group: impl Fn(u32) -> Option<&'a wgpu::BindGroup>,
phase: u64,
) {
if self.pages.is_empty() {
return;
@@ -1121,7 +1446,7 @@ impl GpuCulling {
&self.draws.buffer,
u64::from(first) * 20,
&self.counts.buffer,
(STATS + u64::from(page)) * 4,
(STATS + phase * u64::from(self.pass_pages) + u64::from(page)) * 4,
size,
);
}
@@ -1131,6 +1456,139 @@ impl GpuCulling {
pub fn draw_calls(&self) -> usize {
self.pages.len()
}
/// Whether the frame occludes (`prepare`): phase 1's `draw`, then `test` in
/// its pass, `compact` after it, and phase 2's `draw` in the next pass.
pub fn occluding(&self) -> bool {
self.occluding
}
/// The boxes the newest test read back did not see and those it tested,
/// and the share of quads it hid (cull.rs `Cull::hidden`).
pub fn hidden(&self) -> Option<([usize; 2], f32)> {
self.hidden.map(|h| (h, self.share.1))
}
/// In the world pass after phase 1's draws: the listed boxes against its
/// depth. Leaves the pass's pipeline and group 0 changed.
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some(BoxTest {
pipeline,
group: Some(group),
..
}) = &self.test
&& self.occluding
{
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw_indirect(&self.counts.buffer, BOX * 4);
}
}
/// Between the world passes: phase 2's draws, of the sections the test saw,
/// into the regions phase 1 drew from.
pub fn compact(&self, encoder: &mut wgpu::CommandEncoder, timing: Option<&mut PassTiming>) {
let (Some(test), Some(group)) = (&self.test, &self.group) else {
return;
};
if !self.occluding {
return;
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("GPU culling phase 2"),
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling phase 2")),
});
pass.set_bind_group(0, group, &[]);
let [count2, scan2, scatter2] = &test.phase2;
let blocks = self.ids.div_ceil(WORKGROUP);
pass.set_pipeline(count2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
pass.set_pipeline(scan2);
pass.dispatch_workgroups(self.pass_pages, 1, 1);
pass.set_pipeline(scatter2);
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
}
/// After the frame's world passes (phase 2's counts too), in the same
/// encoder: the pass's counts copied out with
/// the CPU's part of frame `frame` (`Readback::cpu`), into the next readback
/// if it is free. While three frames' are on their way, this one's
/// statistics are never shown.
pub fn copy_stats(&mut self, encoder: &mut wgpu::CommandEncoder, frame: u64, cpu: [usize; 3]) {
let r = &mut self.readbacks[self.next];
if r.state.load(Ordering::Acquire) != FREE {
return;
}
encoder.copy_buffer_to_buffer(&self.counts.buffer, 0, &r.buffer, 0, STATS * 4);
(r.frame, r.cpu, r.occluded) = (frame, cpu, self.occluding);
self.current = Some(self.next);
}
/// The frame's statistics to read back, started by calling it right after
/// the frame's submission (gfx's after-submit hook, on the submit thread
/// when the frame goes there), as `cull::Cull::submitted`.
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let i = self.current.take()?;
let r = &self.readbacks[i];
let (buffer, state) = (r.buffer.clone(), r.state.clone());
state.store(PENDING, Ordering::Release);
self.next = (i + 1) % READBACKS;
Some(move || {
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FAILED },
Ordering::Release,
);
});
})
}
/// The statistics that came back, their readbacks freed: the newest
/// frame's, as (frame, [sections drawn, section draws, draw calls]), the
/// pass's counts added to the CPU's part. Called as a frame starts, so a
/// copy no submission took (a frame that failed on the way) is dropped.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<(u64, [usize; 3])> {
self.current = None;
if device.poll(wgpu::PollType::Poll).is_err() {
return None;
}
let mut newest: Option<(u64, [usize; 3])> = None;
for r in &self.readbacks {
if r.state.load(Ordering::Acquire) != MAPPED {
continue;
}
{
let Ok(data) = r.buffer.slice(..).get_mapped_range() else {
// Device loss can destroy a buffer after its mapping
// succeeded; unmapping it would panic (`timing::GpuTimer`).
r.state.store(FAILED, Ordering::Release);
continue;
};
// The mapping need not be 4-byte aligned: read the words unaligned.
let words: [u32; STATS as usize] =
bytemuck::pod_read_unaligned(&data[..STATS as usize * 4]);
let [kept, draws] = [words[0], words[1]];
// The newest test's share, for the gate (`prepare`).
if r.occluded && r.frame > self.share.0 {
let quads = words[QUADS].max(1);
self.share = (r.frame, words[HIDDEN_QUADS] as f32 / quads as f32);
self.hidden = Some([words[HIDDEN] as usize, words[BOX as usize + 1] as usize]);
}
let [sections, cpu_draws, calls] = r.cpu;
if newest.is_none_or(|(frame, _)| r.frame > frame) {
newest = Some((
r.frame,
[sections + kept as usize, cpu_draws + draws as usize, calls],
));
}
}
r.buffer.unmap();
r.state.store(FREE, Ordering::Release);
}
newest
}
}
#[cfg(test)]
@@ -1231,6 +1689,8 @@ mod tests {
frustum,
reach: None,
eye: None,
view_proj: Mat4::IDENTITY,
occlude: false,
}
}
@@ -1393,6 +1853,21 @@ mod tests {
runs
}
/// The pass's module validates without the early depth test, and the
/// occlusion test's (the pass's with `gpu_occlusion.wgsl`) with it.
#[test]
fn the_shaders_validate() {
use naga::valid::Capabilities as C;
let pass = include_str!("gpu_culling.wgsl");
let test = [pass, include_str!("gpu_occlusion.wgsl")].concat();
for (source, capabilities) in [(pass, C::all() - C::EARLY_DEPTH_TEST), (&test, C::all())] {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), capabilities)
.validate(&module)
.unwrap();
}
}
/// Ids, page counts, flags and changes against a plain list of what each id
/// holds: the lowest free id comes back, flagged only if inserted so, free
/// ids at the end leave, counts follow moves (to pages past the last too), a
@@ -1631,6 +2106,8 @@ mod tests {
("reach_sections", offset_of!(Params, reach_sections)),
("flags", offset_of!(Params, flags)),
("eyes", offset_of!(Params, eyes)),
("occl", offset_of!(Params, occl)),
("view_proj", offset_of!(Params, view_proj)),
] {
assert!(members.contains(&(name.into(), offset)), "Params.{name}");
}
@@ -1651,7 +2128,7 @@ mod tests {
];
assert_eq!(layout("Update"), (size_of::<Update>(), named(&id)));
assert_eq!((size_of::<Record>(), size_of::<Update>()), (80, 96));
assert_eq!(size_of::<Params>(), 128);
assert_eq!(size_of::<Params>(), 208);
let constant = |name: &str| {
let (_, c) = module
.constants
@@ -2339,7 +2816,7 @@ mod tests {
// So have the sections' buckets and whether a frame has an eye.
let mut shapes = Rng(0x3C6E_F372_FE94_F82B);
let (min_y, height) = (-64, 24);
let mut gpu = GpuCulling::new(device).expect("the pass");
let mut gpu = GpuCulling::new(device, None).expect("the pass");
let mut uploads = Uploads::new(device);
let mut held: BTreeMap<u32, (Placed, Sides)> = BTreeMap::new();
// Fading sections the frustum and the graph keep, left to the CPU.
@@ -2512,6 +2989,8 @@ mod tests {
frustum: &frustum,
reach,
eye,
view_proj: Mat4::IDENTITY,
occlude: false,
};
if case == 7 {
// Planes past the integers: the CPU draws that frame, nothing is
@@ -2581,8 +3060,12 @@ mod tests {
}
}
gpu.dispatch(&mut encoder, None);
// Its statistics come back with a CPU part of the case's own.
let cpu = [case * 7, case * 3, case];
gpu.copy_stats(&mut encoder, case as u64, cpu);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
gpu.submitted().expect("a readback free")();
let pages = u64::from(gpu.pass_pages);
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
let draws = read(device, queue, &gpu.draws.buffer, gpu.draws.bytes);
@@ -2619,6 +3102,13 @@ mod tests {
[kept_sections, draw_count],
"case {case}: sections kept, draws"
);
// The reads above waited for the GPU.
let (kept, drawn) = (kept_sections as usize, draw_count as usize);
assert_eq!(
gpu.collect(device),
Some((case as u64, [cpu[0] + kept, cpu[1] + drawn, cpu[2]])),
"case {case}: statistics"
);
compared += kept_sections as usize;
}
// Not a comparison of empty lists, nor of a test that keeps everything.
@@ -2645,6 +3135,73 @@ mod tests {
);
}
/// The statistics come back with their frame, in place and from the submit
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
/// with its `after` handed back): three frames on their way fill the ring, a
/// fourth's are skipped, `collect` takes in the newest; and a readback whose
/// buffer the device lost after its mapping goes `FAILED`, never copied into
/// or unmapped again (as `timing::GpuTimer`'s), so the statistics stop.
#[test]
fn the_statistics_come_back_with_their_frame() {
use super::super::submit::{AfterSubmit, Job};
on_each_device(|device, queue| {
for threaded in [false, true] {
let queue = Queue::new((**queue).clone(), threaded);
let mut gpu = GpuCulling::new(device, None).expect("the pass");
// As if the pass kept 5 sections and drew 7 times.
queue.write_buffer(&gpu.counts.buffer, 0, bytemuck::cast_slice(&[5_u32, 7]));
// Whether the frame's statistics went out.
let frame = |gpu: &mut GpuCulling, f: u64| {
let mut encoder = device.create_command_encoder(&Default::default());
gpu.copy_stats(&mut encoder, f, [f as usize, 10, 3]);
let after = gpu.submitted().map(|g| Box::new(g) as AfterSubmit);
let sent = after.is_some();
let job = Job {
encoder,
surface: None,
after,
};
if let Err(back) = queue.submit_later(job) {
queue.submit([back.encoder.finish()]);
if let Some(read) = back.after {
read();
}
}
sent
};
let landed = |gpu: &mut GpuCulling| {
queue.landed();
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
gpu.collect(device)
};
for f in 1..=3 {
assert!(frame(&mut gpu, f), "frame {f}, threaded {threaded}");
}
assert!(!frame(&mut gpu, 4), "a full ring, threaded {threaded}");
assert_eq!(landed(&mut gpu), Some((3, [3 + 5, 10 + 7, 3])));
assert_eq!(gpu.collect(device), None);
assert!(frame(&mut gpu, 5));
assert_eq!(landed(&mut gpu), Some((5, [5 + 5, 10 + 7, 3])));
// Frame 6's buffer destroyed after its mapping (#6320).
let lost = gpu.next;
assert!(frame(&mut gpu, 6));
queue.landed();
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
gpu.readbacks[lost].buffer.destroy();
assert_eq!(gpu.collect(device), None);
for f in 7..=12 {
let sent = frame(&mut gpu, f);
let back = landed(&mut gpu);
let state = gpu.readbacks[lost].state.load(Ordering::Acquire);
assert_eq!(state, FAILED, "frame {f}, threaded {threaded}");
// The two other readbacks, then none.
assert_eq!(sent, f <= 8, "frame {f}, threaded {threaded}");
assert_eq!(back.is_some(), f <= 8, "frame {f}, threaded {threaded}");
}
}
});
}
/// `draw`'s count draws draw what the pass kept, page by page: each kept
/// section's quads from its own range, all of them without an eye and those
/// of its runs that can face one with it, with its own origin, which the
@@ -2687,7 +3244,7 @@ struct Quad {
for _ in 0..4 {
assert!(arena.open_page(device, u64::from(SECTIONS / 3) * grain));
}
let mut gpu = GpuCulling::new(device).expect("the pass");
let mut gpu = GpuCulling::new(device, None).expect("the pass");
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
// The sections' buckets by facing side have their own sequence.
let mut shapes = Rng(0xA54F_F53A_5F1D_36F1);
@@ -2816,6 +3373,8 @@ struct Quad {
frustum,
reach: None,
eye,
view_proj: Mat4::IDENTITY,
occlude: false,
};
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
@@ -2860,7 +3419,7 @@ struct Quad {
});
pass.set_pipeline(&pipeline);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
gpu.draw(&mut pass, &arena, |_| None);
gpu.draw(&mut pass, &arena, |_| None, 0);
drop(pass);
encoder.copy_texture_to_buffer(
target.as_image_copy(),
@@ -13,6 +13,13 @@
// integers the CPU computed, and facing compares keys that order as the floats
// the CPU compares. `cull` leaves sections fading in or out to the CPU
// (`FADING`).
// Frames that occlude (`OCCLUDE`, gpu_occlusion.wgsl) draw in two phases, each
// in id order: `cull` leaves to phase 2 the sections whose box the last test
// did not see and lists the boxes to test; after the test, `count2`, `scan2`
// and `scatter2` write the draws of those it saw into the same regions. Which
// boxes count as seen untested (`near_or_big`) is float arithmetic of the
// shader's own: it only sorts sections between the phases, and doubt counts as
// seen.
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -34,13 +41,19 @@ struct Params {
// Each frustum plane's w term, its margin in, then 0, 0.
w: array<vec4<i32>, 2>,
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
// section y; its height in sections and the level's; REACH, SKY and EYE;
// where the eye's keys start in `tables`.
// section y; its height in sections and the level's; REACH, SKY, EYE,
// OCCLUDE and PROBE; where the eye's keys start in `tables`.
reach: vec4<i32>,
reach_height: i32,
reach_sections: i32,
flags: u32,
eyes: u32,
// Occlusion (OCCLUDE): this frame's stamp (never 0), where the boxes to
// test start in `counts`, 0, 0.
occl: vec4<u32>,
// The world pass's view projection, TAA's jitter included (gfx.rs
// `globals.view_proj`).
view_proj: mat4x4<f32>,
}
// gpu_culling.rs `Record`.
@@ -75,15 +88,18 @@ struct Update {
// and its size).
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
// draws (`cull`), turned into where they start (`scan`).
// The statistics (`STATS`), each page's draws in phase 1 and in phase 2, then
// per block of ids and page its draws in each phase (`cull`, `count2`), turned
// into where they start (`scan`, `scan2`), then the boxes to test (`cull`).
@group(0) @binding(4) var<storage, read_write> counts: array<atomic<u32>>;
// DrawIndexedIndirectArgs, five words each.
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
// frame, else 0; then each draw's first quad and quads.
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | TESTED | PHASE2 | its draws when
// it is drawn this frame, else 0; then each draw's first quad and quads; then
// the stamps of the frames whose test last saw its box and last tested it,
// kept from frame to frame.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
// a bit each (32 a word, lowest first).
@@ -95,8 +111,16 @@ const STEP_BITS: u32 = 0x38000000u;
const ROW: u32 = 65535u;
// Ids per block.
const BLOCK: u32 = 64u;
// Counts before the pages'.
const STATS: u32 = 2u;
// Counts before the pages': sections kept and draws, then occlusion's: the
// quads of the sections with runs and those of the tested boxes not seen, the
// box draw (four words and a spare), those boxes, phase 2's draws, a spare.
const STATS: u32 = 12u;
const DRAWS: u32 = 1u;
const QUADS: u32 = 2u;
const HIDDEN_QUADS: u32 = 3u;
const BOX: u32 = 4u;
const HIDDEN: u32 = 9u;
const DRAWN2: u32 = 10u;
// gpu_culling.rs `rounded_box` for a box past what its codes hold: no plane
// rejects it.
const UNBOUNDED: u32 = 0x80000000u;
@@ -109,14 +133,56 @@ const FADING: u32 = 1u;
// In `params.flags`: solid quads facing away from the eye are left out, its
// keys in `tables` from `params.eyes`.
const EYE: u32 = 4u;
// Words per id in `runs`, and where its page starts in the first, its draws
// (at most 4: gpu_culling.rs `RUNS`) below.
const RUN_WORDS: u32 = 9u;
const PAGE_SHIFT: u32 = 3u;
// Words per id in `runs`, and where its page starts in the first, its flags and
// draws (at most 4: gpu_culling.rs `RUNS`) below.
const RUN_WORDS: u32 = 11u;
const PAGE_SHIFT: u32 = 5u;
// In its first word: its box is tested this frame; it waits for phase 2.
const TESTED: u32 = 16u;
const PHASE2: u32 = 8u;
// Its stamps: the frames whose test last saw its box, and last tested it.
const SEEN_WORD: u32 = 9u;
const TESTED_WORD: u32 = 10u;
// In `params.flags`: the frame occludes, and tests every box (cull.rs's probe).
const OCCLUDE: u32 = 8u;
const PROBE: u32 = 16u;
// cull.rs `RETEST` and `BIG`.
const RETEST: u32 = 8u;
const BIG: f32 = 0.25;
// gpu_culling.rs `CODE_OFFSET`: the rounded box's codes hold -8..=23.
const CODE_OFFSET: i32 = 8i;
// Past the box, for rounding: far past what f32 moves a camera-relative corner.
const EPS: f32 = 0.015625;
// Clip z / w at about twice the near plane's distance: reversed depth is 1 at
// the near plane and grows toward the eye; behind it, z > 0 >= w.
const NEAR_DEPTH: f32 = 0.5;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
return STATS + params.pages + block * params.pages + page;
// `page`'s count draw in `phase` (0: the first world pass; 1: after the test).
fn page_count(page: u32, phase: u32) -> u32 {
return STATS + phase * params.pages + page;
}
// The count of `block`'s draws in `page` and `phase` (`cull`, `count2`), then
// where they start (`scan`): phase 1's slots, then phase 2's.
fn block_count(block: u32, page: u32, phase: u32) -> u32 {
let blocks = (params.ids + BLOCK - 1u) / BLOCK;
return STATS + 2u * params.pages + (phase * blocks + block) * params.pages + page;
}
// The id's first `runs` word, its flags cleared, if it draws in `phase` (in
// phase 2, only once this frame's test saw its box); else 0.
fn drawn_in(id: u32, phase: u32) -> u32 {
if id >= params.ids {
return 0u;
}
let w = runs[id * RUN_WORDS];
if (w & 7u) == 0u || ((w & PHASE2) != 0u) != (phase == 1u) {
return 0u;
}
if (w & PHASE2) != 0u && runs[id * RUN_WORDS + SEEN_WORD] != params.occl.x {
return 0u;
}
return w & ~(PHASE2 | TESTED);
}
// `page`'s region of `draws`: its first draw and its size.
@@ -218,6 +284,59 @@ fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
}
}
// Corner `c` (bit 0: x, bit 1: y, bit 2: z) of the box tested for the section
// at `o` whose rounded box is `b` (never UNBOUNDED here): cull.wgsl's box, the
// cell grown by a block, grown further to the section's own bounds where they
// reach past it, then by EPS.
fn box_corner(o: vec3<f32>, b: u32, c: u32) -> vec3<f32> {
let low = vec3<i32>(vec3<u32>(b, b >> 5u, b >> 10u) & vec3(31u)) - vec3(CODE_OFFSET);
let high = vec3<i32>(vec3<u32>(b >> 15u, b >> 20u, b >> 25u) & vec3(31u)) - vec3(CODE_OFFSET);
let lo = min(vec3<f32>(low), vec3(-1.0)) - vec3(EPS);
let hi = max(vec3<f32>(high), vec3(17.0)) + vec3(EPS);
return o + select(lo, hi, (vec3(c) & vec3(1u, 2u, 4u)) != vec3(0u));
}
// Boxes that count as seen untested: one with a corner within about twice the
// near plane's distance, behind the eye, or NaN, whose front faces near
// clipping could cut away; or, as in cull.wgsl, one over BIG of the screen.
fn near_or_big(o: vec3<f32>, b: u32) -> bool {
var lo = vec2(1e30);
var hi = vec2(-1e30);
for (var c = 0u; c < 8u; c++) {
let p = params.view_proj * vec4(box_corner(o, b, c), 1.0);
if !(p.z < NEAR_DEPTH * p.w) {
return true;
}
let q = p.xy / p.w;
lo = min(lo, q);
hi = max(hi, q);
}
let size = max(min(hi, vec2(1.0)) - max(lo, vec2(-1.0)), vec2(0.0));
return size.x * size.y > BIG * 4.0;
}
// In frames that occlude, a kept section with runs: its flags (0: phase 1,
// untested), its box listed when it is tested. Its last test saw it: phase 1,
// retested every RETEST frames (every frame with PROBE). A box that counts as
// seen: phase 1. Else phase 2, drawn if this frame's test sees its box.
fn occlusion(id: u32, at: u32, o: vec3<f32>, b: u32) -> u32 {
let stamp = params.occl.x;
let seen = runs[at + SEEN_WORD];
let passed = seen != 0u && seen == runs[at + TESTED_WORD];
if passed && (params.flags & PROBE) == 0u && (id + stamp) % RETEST != 0u {
return 0u;
}
if (b & UNBOUNDED) != 0u || near_or_big(o, b) {
runs[at + SEEN_WORD] = stamp;
runs[at + TESTED_WORD] = stamp;
return 0u;
}
runs[at + TESTED_WORD] = stamp;
let t = atomicAdd(&counts[BOX + 1u], 1u);
atomicStore(&counts[params.occl.y + t], id);
return TESTED | select(PHASE2, 0u, passed);
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -230,6 +349,10 @@ fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
@compute @workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.y * (ROW * BLOCK) + gid.x;
if id == 0u && (params.flags & OCCLUDE) != 0u {
// The box draw's vertices: the cube's corners (gpu_occlusion.wgsl).
atomicStore(&counts[BOX], 36u);
}
// Past the table's end the records are stale: the CPU never writes them free.
if id >= params.ids {
return;
@@ -252,7 +375,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
atomicAdd(&counts[0], 1u);
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
let origin = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
origins[id] = origin;
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
// empty bucket breaking none, a draw each; without an eye, its solid quads.
var n = 0u;
@@ -292,24 +416,29 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
if n == 0u {
return;
}
runs[at] = (page << PAGE_SHIFT) | n;
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
var flags = 0u;
if (params.flags & OCCLUDE) != 0u {
flags = occlusion(id, at, bitcast<vec3<f32>>(origin.xyz), s.a.z);
}
runs[at] = (page << PAGE_SHIFT) | flags | n;
if (flags & PHASE2) == 0u {
atomicAdd(&counts[block_count(id / BLOCK, page, 0u)], n);
}
}
var<workgroup> partial: array<u32, 256>;
// One workgroup per page: each lane sums a stretch of blocks, the lanes'
// sums are scanned, and each block's sum becomes where its draws start.
@compute @workgroup_size(256)
fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let page = group.x;
// sums are scanned, and each block's sum becomes where its draws start, in
// `phase` (`scan`, gpu_occlusion.wgsl `scan2`).
fn scan_page(page: u32, lane: u32, phase: u32) {
let blocks = (params.ids + BLOCK - 1u) / BLOCK;
let stretch = (blocks + 255u) / 256u;
let first = min(lane * stretch, blocks);
let last = min(first + stretch, blocks);
var sum = 0u;
for (var b = first; b < last; b++) {
sum += atomicLoad(&counts[block_count(b, page)]);
sum += atomicLoad(&counts[block_count(b, page, phase)]);
}
partial[lane] = sum;
workgroupBarrier();
@@ -327,31 +456,35 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
start = partial[lane - 1u];
}
for (var b = first; b < last; b++) {
let count = atomicLoad(&counts[block_count(b, page)]);
atomicStore(&counts[block_count(b, page)], start);
let count = atomicLoad(&counts[block_count(b, page, phase)]);
atomicStore(&counts[block_count(b, page, phase)], start);
start += count;
}
if lane == 255u {
// A region has room for every draw of its page's records; were one
// short, its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[1], total);
atomicStore(&counts[page_count(page, phase)], total);
atomicAdd(&counts[DRAWS], total);
if phase == 1u {
atomicAdd(&counts[DRAWN2], total);
}
}
}
@compute @workgroup_size(256)
fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scan_page(group.x, lane, 0u);
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draws go after those of the
// block's earlier sections in its page, in its runs' order.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
// block's earlier sections in its page, in its runs' order, in `phase`
// (`scatter`, gpu_occlusion.wgsl `scatter2`).
fn scatter_block(block: u32, lane: u32, phase: u32) {
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id * RUN_WORDS];
}
let mine = drawn_in(id, phase);
held[lane] = mine;
workgroupBarrier();
// Its draws below PAGE_SHIFT, its page above.
@@ -360,7 +493,7 @@ fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_in
return;
}
let page = mine >> PAGE_SHIFT;
var slot = atomicLoad(&counts[block_count(block, page)]);
var slot = atomicLoad(&counts[block_count(block, page, phase)]);
for (var other = 0u; other < lane; other++) {
// A section not drawn holds 0: no draws.
if (held[other] >> PAGE_SHIFT) == page {
@@ -383,3 +516,8 @@ fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_in
draws[d + 4u] = id;
}
}
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scatter_block(group.y * ROW + group.x, lane, 0u);
}
@@ -0,0 +1,95 @@
// GPU culling's occlusion test (gpu_culling.rs `BoxTest`), after
// gpu_culling.wgsl in one module, made only where the depth test can be forced
// before the fragment shader (Vulkan, cull.rs `DepthTest::Early`): one instance
// per box `cull` listed, its section's box drawn front faces only against phase
// 1's depth; then `count2`, `scan2` and `scatter2` write phase 2's draws.
@group(0) @binding(9) var<storage, read> box_list: array<u32>;
@group(0) @binding(10) var<storage, read> box_origins: array<vec4<u32>>;
@group(0) @binding(11) var<storage, read_write> box_runs: array<u32>;
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside (cull.wgsl's).
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
struct BoxOut {
@builtin(position) position: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
}
@vertex
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> BoxOut {
let id = box_list[params.occl.y + t];
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
var out: BoxOut;
out.id = id;
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
return out;
}
// Seen at this fragment, which runs only where the depth test passes (forced
// before it: a fragment that failed would still write). Every writer stores
// the same stamp.
@fragment
@early_depth_test(force)
fn fs_box(in: BoxOut) -> @location(0) vec4<f32> {
let at = in.id * RUN_WORDS + SEEN_WORD;
if box_runs[at] != params.occl.x {
box_runs[at] = params.occl.x;
}
return vec4(0.0);
}
var<workgroup> tally: array<atomic<u32>, 3>;
// After the test, one workgroup per block of ids: the quads of the sections
// with runs, the tested boxes not seen and their quads, and per page each
// block's draws that phase 2 makes (`drawn_in`'s condition).
@compute @workgroup_size(64)
fn count2(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
if lane < 3u {
atomicStore(&tally[lane], 0u);
}
workgroupBarrier();
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
if id < params.ids {
let at = id * RUN_WORDS;
let w = runs[at];
let n = w & 7u;
if n > 0u {
var quads = 0u;
for (var k = 0u; k < n; k++) {
quads += runs[at + 2u + 2u * k];
}
atomicAdd(&tally[0], quads);
let saw = runs[at + SEEN_WORD] == params.occl.x;
if (w & TESTED) != 0u && !saw {
atomicAdd(&tally[1], 1u);
atomicAdd(&tally[2], quads);
}
if (w & PHASE2) != 0u && saw {
atomicAdd(&counts[block_count(block, w >> PAGE_SHIFT, 1u)], n);
}
}
}
workgroupBarrier();
if lane == 0u {
atomicAdd(&counts[QUADS], atomicLoad(&tally[0]));
atomicAdd(&counts[HIDDEN], atomicLoad(&tally[1]));
atomicAdd(&counts[HIDDEN_QUADS], atomicLoad(&tally[2]));
}
}
@compute @workgroup_size(256)
fn scan2(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scan_page(group.x, lane, 1u);
}
@compute @workgroup_size(64)
fn scatter2(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
scatter_block(group.y * ROW + group.x, lane, 1u);
}
@@ -101,6 +101,11 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
vec![
("entity", vec!["entity.wgsl"], entity.clone()),
(
"GPU culling occlusion test",
vec!["gpu_culling.wgsl", "gpu_occlusion.wgsl"],
include_str!("gpu_culling.wgsl").to_owned() + include_str!("gpu_occlusion.wgsl"),
),
(
"static entity",
vec!["entity.wgsl", "static_entity.wgsl"],
@@ -367,6 +367,7 @@ impl NativeFrame {
distance: None,
unculled: None,
every_face: false,
view_casters: false,
})
}
@@ -510,15 +511,24 @@ impl NativeFrame {
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
// Under `HORIZON_STILL` the world's ticks are the clock (`PackInputs::still_ticks`), and
// eased values sit at their targets.
let still = frame.pack.still_ticks;
let seconds = still.map_or_else(
|| self.start.elapsed().as_secs_f32() % 3600.0,
|ticks| (ticks as f32 / 20.0) % 3600.0,
);
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
let since = match still {
Some(_) => f32::INFINITY,
None => self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()),
};
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(
frame,
size,
seconds,
self.frames,
still.map_or(self.frames, |ticks| ticks as u32),
self.shadow_camera.as_ref(),
);
inputs.wetness = self.wetness.0;
@@ -551,7 +561,13 @@ impl NativeFrame {
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
];
let dt = inputs.delta_time;
let dt = match still {
Some(_) => {
inputs.delta_time = 0.05;
f32::INFINITY
}
None => inputs.delta_time,
};
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
*value = eased(*value, target, half_life, dt);
}
@@ -1156,6 +1172,14 @@ 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.
hdr: 0,
headroom: 1.0,
paper_white: 80.0,
peak: 80.0,
black_level: 0.0,
padding: [0; 3],
}
}
@@ -1598,7 +1622,7 @@ file = \"b.wgsl\"
mapped_at_creation: false,
});
// What the material stage stores, as the bits of floats: the normal +x (octahedral
// (1, 0), two snorm16), smoothness 0.5, data 0.25 and the fragment's depth 0.5.
// (1, 0), two snorm16), smoothness 0.75, data 0.25 and the fragment's depth 0.5.
let writer = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
@@ -1607,7 +1631,7 @@ file = \"b.wgsl\"
}
@fragment fn fs() -> @location(0) vec4<u32> {
return bitcast<vec4<u32>>(vec4<f32>(
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.5, 0.25, 0.5));
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.75, 0.25, 0.5));
}"
.into(),
),
@@ -1707,8 +1731,9 @@ file = \"b.wgsl\"
buffer.unmap();
pixel
};
// Present where the world's depth is the stored one: +x, 0.5, 0.25 as 8-bit.
assert_eq!(read(0.5), [255, 128, 64, 255]);
// Present where the world's depth is the stored one: +x, 0.75, 0.25 as 8-bit. No value
// here sits halfway between two 8-bit levels, where drivers round either way.
assert_eq!(read(0.5), [255, 191, 64, 255]);
// Hidden by something nearer drawn since: the stored values no longer show.
assert_eq!(read(0.25), [0, 0, 0, 0]);
}
@@ -159,9 +159,9 @@ const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
/// shadow, and ends past the build height and the slant across the map below the camera.
/// the next cell. Its depth starts 356 blocks toward the light (256 further than `shaders/`
/// packs' default), so the ground over a deep cave still casts its shadow, and ends past the
/// build height and the slant across the map below the camera.
pub fn camera(settings: &Settings) -> Shadow {
Shadow {
resolution: settings.resolution,
@@ -169,8 +169,8 @@ pub fn camera(settings: &Settings) -> Shadow {
render_mul: 1.0,
interval: SNAP,
euclidean_snap: true,
near: 256.0,
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
near: Some(-356.0),
far: Some(2.0 * settings.distance + 384.0),
..Shadow::default()
}
}
@@ -692,6 +692,25 @@ impl Occlusion {
pub fn contains(&self, key: SectionKey) -> bool {
self.graph.as_ref().is_none_or(|g| g.contains(key))
}
/// Whether the current graph knows that `key` is visible from
/// `camera_section`. Unknown is returned when the graph is absent, was
/// searched from another section, or does not cover the queried section.
pub fn visible_for_camera(&self, key: SectionKey, camera_section: SectionKey) -> Option<bool> {
let graph = self.graph.as_ref()?;
if graph.from != camera_section {
return None;
}
let dx = i64::from(key.0) - i64::from(graph.from.0);
let dz = i64::from(key.2) - i64::from(graph.from.2);
if dx.abs() > i64::from(graph.radius)
|| dz.abs() > i64::from(graph.radius)
|| key.1 < graph.y0
|| key.1 > graph.sections
{
return None;
}
Some(graph.contains(key))
}
/// Changes whenever `contains` may answer differently: a search landed, a
/// partial update reached more sections, or the graph went.
#[cfg(any(test, debug_assertions))]
@@ -904,6 +923,77 @@ mod tests {
assert!(o.contains((2, 1, 0)));
}
/// `visible_for_camera` answers only for the box of the graph it holds, and
/// only for the camera that search started from. Everywhere else it says
/// unknown, so the caller keeps the geometry.
#[test]
fn visible_for_camera_is_unknown_outside_its_box_and_its_camera() {
// No graph yet: nothing to answer with.
assert_eq!(
Occlusion::new(0, 4).visible_for_camera((0, 1, 0), (0, 1, 0)),
None
);
let mut o = Occlusion::new(0, 4);
o.settle(at((0, 1, 0)), 2);
let from = o.columns.section_of(at((0, 1, 0)));
assert_eq!(from, (0, 1, 0), "the camera section searched from");
// Inside the searched box the answer is `contains`, its edges too.
for x in -2..=2 {
for z in -2..=2 {
for y in -1..=4 {
assert_eq!(
o.visible_for_camera((x, y, z), from),
Some(o.contains((x, y, z))),
"{x} {y} {z}"
);
}
}
}
assert_eq!(
o.visible_for_camera((2, 1, 0), from),
Some(true),
"the box's edge"
);
// Off the box's sides, below it, or above the level: unknown.
for key in [
(3, 1, 0),
(-3, 1, 0),
(0, 1, 3),
(0, 1, -3),
(0, -2, 0),
(0, 5, 0),
] {
assert_eq!(o.visible_for_camera(key, from), None, "{key:?}");
}
// Another camera section: this graph is not about it.
assert_eq!(o.visible_for_camera((0, 1, 0), (1, 1, 0)), None);
}
/// The answer follows the graph: a closed wall hides the section behind it,
/// and opening the wall shows it again.
#[test]
fn visible_for_camera_reports_what_the_graph_hides() {
let mut o = Occlusion::new(0, 4);
for (dx, dy, dz) in STEPS {
o.set((dx, 1 + dy, dz), 0);
}
o.settle(at((0, 1, 0)), 3);
let from = (0, 1, 0);
assert_eq!(
o.visible_for_camera((1, 1, 0), from),
Some(true),
"the wall itself"
);
assert_eq!(
o.visible_for_camera((2, 1, 0), from),
Some(false),
"hidden behind it"
);
o.set((1, 1, 0), ALL_VISIBLE);
o.settle(at((0, 1, 0)), 3);
assert_eq!(o.visible_for_camera((2, 1, 0), from), Some(true));
}
/// Faces `a` and `b` of a section see each other, nothing else does.
fn connects(a: usize, b: usize) -> u64 {
1 << (a * 6 + b) | 1 << (b * 6 + a)
@@ -519,6 +519,36 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
}\n \
}\n";
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
///
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
/// holds each vertex's own ambient occlusion.
pub fn terrain_parts(
terrain: &horizon_shaders::GbuffersInputs,
lava: &[u16],
) -> [horizon_shaders::GbuffersInputs; 2] {
let opaque = crate::mesher::OPAQUE_LAYER;
let lava: String = lava
.iter()
.map(|l| format!(" || hz_v_layer == {l}u"))
.collect();
let part = |solid: bool| {
let mut inputs = terrain.clone();
inputs.after.push_str(&format!(
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
));
inputs
};
[part(true), part(false)]
}
impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
@@ -577,12 +607,16 @@ impl PackAtlas {
} else {
"1.0"
};
// A vertex's place in its section goes down to the 32768th of a block below it: read back
// with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` has that for every position
// we read (water tops at 8/9 - 0.001, plants' offsets), on all three axes (#8464). A fit,
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
{cell}\
hz_scale = vec2({:?}, {:?}) * {side};\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_Vertex = vec4(floor(hz_v_pos * 32768.0) / 32768.0 + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
@@ -644,6 +678,7 @@ impl PackAtlas {
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: false,
}
}
}
@@ -171,13 +171,98 @@ fn flat_draws_map_uvs_onto_a_stitched_sprite() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// Read back with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` (camera on the block
/// grid) puts a water top, 8/9 - 0.001 of a block up, at 29094/32768, and plants offset by 13/15
/// and 0.1 of a block at 28398/32768 and 3276/32768 (#8464): each 32768th below the position.
/// Whole blocks and sixteenths stay where they are.
#[test]
fn terrain_vertices_go_down_to_the_32768th_below_them() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
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: 16,
height: 16,
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, 16).unwrap();
queue.submit([encoder.finish()]);
let water = 8.0f32 / 9.0 - 0.001;
let cases = [
([0.1, water, 13.0 / 15.0], [3276.0, 29094.0, 28398.0]),
([13.0 / 15.0, 0.1, water], [28398.0, 3276.0, 29094.0]),
([1.0, 0.5, 15.9375], [32768.0, 16384.0, 522240.0]),
];
let vertices: Vec<_> = cases
.iter()
.map(|&(pos, _)| TerrainVertex::new(pos, [0.0, 0.0], 0x8000, u32::MAX, 0))
.collect();
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let mut failures = Vec::new();
for flat in [true, false] {
for shadow in [false, true] {
let read = sample_probe(
&device,
&queue,
&atlas,
&vertices,
origin,
[flat, shadow],
"gl_Vertex",
);
for ((pos, expected), got) in cases.iter().zip(&read) {
let got = [got[0], got[1], got[2]].map(|v| v * 32768.0);
if got != *expected {
failures.push(format!(
"{pos:?}, flat={flat} shadow={shadow}: {got:?} != {expected:?}"
));
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
flags: [bool; 2],
) -> Vec<[f32; 4]> {
sample_probe(
device,
queue,
atlas,
vertices,
scale,
flags,
"vec4(mc_midTexCoord, gl_MultiTexCoord0.xy)",
)
}
/// What the pack's vertex stage sets `probe` to (`expression`), for each of `vertices`.
fn sample_probe(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
[flat, shadow]: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let pp = |code| horizon_shaders::Preprocessed {
code,
@@ -185,7 +270,7 @@ fn sample(
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
probe = vec4(mc_midTexCoord, gl_MultiTexCoord0.xy);\n\
probe = {expression};\n\
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
));
@@ -16,14 +16,16 @@ const MAPPED: u8 = 3;
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
// A depth view bound as unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same value.
@group(0) @binding(0) var depth: texture_2d<f32>;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
return vec4<f32>(textureLoad(depth, centre, 0).r, 0.0, 0.0, 1.0);
}
";
@@ -64,7 +66,7 @@ impl CenterDepth {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
@@ -102,6 +102,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
reference: 0.1,
}),
item_id_declared: false,
far_depth: false,
}
}
@@ -164,6 +165,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: false,
}
}
@@ -51,6 +51,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
after,
alpha_test: None,
item_id_declared: false,
far_depth: false,
}
}
+351 -76
View File
@@ -37,12 +37,24 @@ mod world_target_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod shadow_faces_tests;
#[cfg(test)]
mod forced_flip_tests;
#[cfg(test)]
mod weather_depth_tests;
#[cfg(test)]
mod custom_type_tests;
#[cfg(test)]
mod sky_depth_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -99,6 +111,8 @@ enum SamplerKind {
Nearest,
Repeat,
NearestRepeat,
/// The block atlas: nearest texels, blended between the two nearest levels.
Atlas,
Comparison,
}
@@ -108,15 +122,15 @@ impl SamplerKind {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Self::Nearest
}
// shadowtex0/1 through a plain sampler: linear, as Iris 1.11.7 reads them, when the
// device filters their copies (`ShadowTargets::linear`).
Source::ShadowDepth(_, false) if filtering => Self::Linear,
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
// Blocks look as the game draws them: nearest texels. Between levels, Iris 1.11.7
// blends `gtexture`'s two nearest (our probe packs, #9307: a read at level 1.25 is
// levels 1 and 2 mixed 3:1).
Source::Atlas if filtering => Self::Atlas,
Source::Atlas | Source::Normals | Source::Specular => Self::NearestRepeat,
Source::Image(_) if filtering => Self::Linear,
Source::Image(_) => Self::Nearest,
@@ -134,7 +148,7 @@ impl SamplerKind {
fn binding_type(self) -> wgpu::SamplerBindingType {
match self {
Self::Linear | Self::Repeat => wgpu::SamplerBindingType::Filtering,
Self::Linear | Self::Repeat | Self::Atlas => wgpu::SamplerBindingType::Filtering,
Self::Nearest | Self::NearestRepeat => wgpu::SamplerBindingType::NonFiltering,
Self::Comparison => wgpu::SamplerBindingType::Comparison,
}
@@ -357,6 +371,8 @@ struct Pass {
writes: Vec<(u32, usize)>,
/// Buffers flipped after the pass.
flip: u16,
/// `hz_depth_linear`: which of the depth samplers a plain sampler reads filter.
depth_linear: [f32; 4],
/// Buffers sampled, for the bind group cache key.
reads: u16,
/// shadowcolor buffers sampled by a program of the other buffers.
@@ -417,6 +433,14 @@ struct Mips {
levels: Vec<(wgpu::TextureView, wgpu::BindGroup)>,
}
/// The picture a pack's `final` draws at the world's size while the window is another: it is
/// copied onto the window once, after the pack's passes, as the game's own post pass does
/// without a pack.
struct FinalTarget {
size: [u32; 2],
view: wgpu::TextureView,
}
struct Targets {
size: [u32; 2],
/// A and B of each buffer used.
@@ -516,8 +540,14 @@ pub struct PackPost {
passes: Vec<Pass>,
/// Shadow cache for temporal reprojection.
shadow_cache: ShadowCache,
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`;
/// when the pack draws terrain in two parts, solid terrain only (`gbuffers_terrain_solid` or
/// its fallback).
terrain: Option<Pass>,
/// What draws cutout terrain (`gbuffers_terrain_cutout` or its fallback), after `terrain`,
/// when the pack draws terrain in two parts. A pack with no program for one part has only
/// the other.
terrain_cutout: Option<Pass>,
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
water: Option<Pass>,
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
@@ -593,6 +623,8 @@ pub struct PackPost {
rgb: u16,
rgb_unset: bool,
used: u16,
/// Buffers some pass draws into the "alt" side of or flips; the others' B is their A.
doubled: u16,
/// `size.buffer.*`.
sizes: [Option<horizon_shaders::BufferSize>; BUFFERS],
/// Buffers bound as images, and those also copied for a pass that samples them.
@@ -627,6 +659,7 @@ pub struct PackPost {
nearest: wgpu::Sampler,
repeat: wgpu::Sampler,
nearest_repeat: wgpu::Sampler,
atlas_sampler: wgpu::Sampler,
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
/// nor buffer to clear.
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
@@ -639,6 +672,12 @@ pub struct PackPost {
blit_group: Option<([wgpu::TextureView; 2], wgpu::BindGroup)>,
/// Shows colortex0 when the pack has no `final` (OQ-5).
copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// Copies the picture `final` drew at the world's size onto the window: no flip, it is
/// already in the screen's rows.
upscale: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// The picture `final` draws while the world's size is not the window's.
final_target: Option<FinalTarget>,
screen: wgpu::TextureFormat,
uniforms: FrameUniforms,
}
@@ -757,6 +796,14 @@ fn fits(ty: &str, sample_type: wgpu::TextureSampleType) -> bool {
}
}
/// `hz_depth_linear` for a program: 1 for each of shadowtex0, shadowtex1, `shadow` and
/// `watershadow` whose depth filters (`linear`, bit n: shadowtex n). `shadow` is shadowtex1 in a
/// program that also declares `watershadow` (6.3).
fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4] {
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
[0, 1, usize::from(watershadow), 0].map(|n| f32::from(linear >> n & 1))
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_depth = match name {
@@ -1079,6 +1126,11 @@ pub(super) struct ShadowView {
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
}
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
@@ -1165,7 +1217,7 @@ struct Resources<'a> {
/// The formats and dimensions of the images no `image.*` gives, for their stand-ins.
stand_ins: &'a std::cell::RefCell<Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>>,
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
/// unset, and the device filters `R32Float`).
/// unset): the translator's helpers blend a gather of it (`hz_depth_linear`).
shadow_linear: u8,
}
@@ -1283,6 +1335,36 @@ fn bindings(
}
None => {
let mut src = source(&s.name, group.shadow(), shadow.is_some(), watershadow);
// A sampler the game gives no texture reads texture unit 0, as do the atlas's
// names: the atlas in gbuffers programs, `colortex0` in deferred, composite and
// final (OptiFine's shaders.txt). Iris 1.11.7 read the atlas through a name of
// ours in gbuffers_terrain, and the scene in `colortex0` through such a name and
// through `gtexture`, `tex` and `texture` in those three, with or without
// `colortex0` declared (probe packs of ours, #9307). Translucent programs and
// the other groups weren't measured and keep the stand-in.
let unit_0 = match group {
Group::Gbuffers => Some(Source::Atlas),
Group::Deferred | Group::Composite | Group::Final => Some(Source::Color(0)),
_ => None,
};
// A name of the game's that has no texture here (a shadow sampler with no
// shadow pass) stays blank; so does a sampler that isn't 2D, and one under a
// `texture.<stage>.<name>` left unbound (#9064's probe read a 16-texel-wide
// texture there in final, undiagnosed).
let unknown = src == Source::Dummy
&& source(&s.name, false, true, false) == Source::Dummy
&& s.ty == "sampler2D"
&& !textures.iter().any(|t| t.name == s.name);
// colortex0 to colortex3 (gcolor, gdepth, gnormal, composite) are no gbuffers
// samplers in shaders.txt's table: Iris 1.11.7 read the atlas through each of
// colortex0-3 and gnormal in gbuffers_terrain (our probe, #9307).
let not_gbuffers =
group == Group::Gbuffers && matches!(src, Source::Color(n) if n < 4);
if let Some(unit_0) = unit_0
&& (src == Source::Atlas || unknown || not_gbuffers)
{
src = unit_0;
}
let blank = match src {
// The shadow pass writes the shadow map.
Source::ShadowDepth(..) => group == Group::Shadow,
@@ -1300,7 +1382,6 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::ShadowDepth(n, false) if pack.shadow_linear & 1 << n != 0 => float,
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
@@ -1503,6 +1584,22 @@ fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
})
}
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
/// declared as another of these types gets none, and reads 0: Iris 1.11.7 logs "Wrong uniform
/// type ... Disabling that uniform" and our probes read 0 there (float as int, int as float,
/// vec3 as vec2, vec2 as vec3; the vec4 cases were not probed). Other pairs (a `bool` on either
/// side) keep the conversion `Value::write` makes: our probe read a float of 2.5 declared
/// `bool` as true in both games.
fn custom_index(custom: &[(String, String)], name: &str, ty: &str) -> Option<usize> {
const STRICT: [&str; 5] = ["float", "int", "vec2", "vec3", "vec4"];
let k = custom.iter().position(|(c, _)| c == name)?;
let declared = custom[k].1.as_str();
let disabled = declared != ty && STRICT.contains(&declared) && STRICT.contains(&ty);
(!disabled).then_some(pack_uniforms::NAMES.len() + k)
}
/// The pass of program `p`: a quad, or `geometry` drawn by the game. `None` (with a note) when
/// wgpu refuses it or it writes nothing. `formats` are its buffers' (for a program of the shadow
/// buffers, buffer n is shadowcolor n); `shadow` the shadowcolor formats when the shadow pass
@@ -1516,17 +1613,14 @@ fn build_pass(
screen: wgpu::TextureFormat,
notes: &mut Vec<String>,
geometry: Option<&Geometry<'_>>,
custom: &[String],
custom: &[(String, String)],
draws: &wgpu::BindGroupLayout,
pack: &Resources<'_>,
) -> Option<Pass> {
let features = device.features();
// The frame's uniforms, then the pack's custom ones.
let index = |name: &str| {
pack_uniforms::index_in(name, geometry.is_some()).or_else(|| {
let k = custom.iter().position(|c| c == name)?;
Some(pack_uniforms::NAMES.len() + k)
})
let index = |name: &str, ty: &str| {
pack_uniforms::index_in(name, geometry.is_some()).or_else(|| custom_index(custom, name, ty))
};
let mask = |flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
let mut p = p;
@@ -1718,7 +1812,7 @@ fn build_pass(
let missing: Vec<&str> = interface
.uniforms
.iter()
.filter(|u| !u.name.starts_with("hz_") && index(&u.name).is_none())
.filter(|u| !u.name.starts_with("hz_") && index(&u.name, &u.ty).is_none())
.map(|u| u.name.as_str())
.collect();
if !missing.is_empty() {
@@ -1740,7 +1834,7 @@ fn build_pass(
uniforms: interface
.uniforms
.iter()
.map(|u| (index(&u.name), u.ty.clone(), u.offset))
.map(|u| (index(&u.name, &u.ty), u.ty.clone(), u.offset))
.collect(),
name: p.name,
group: p.group,
@@ -1749,6 +1843,7 @@ fn build_pass(
samplers,
writes,
flip,
depth_linear: depth_linear(interface, pack.shadow_linear),
shadow_reads,
written,
mipmaps: p.mipmaps,
@@ -1793,14 +1888,11 @@ fn build_compute(
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
notes: &mut Vec<String>,
custom: &[String],
custom: &[(String, String)],
pack: &Resources<'_>,
) -> Option<Pass> {
let index = |name: &str| {
pack_uniforms::index(name).or_else(|| {
let k = custom.iter().position(|c| c == name)?;
Some(pack_uniforms::NAMES.len() + k)
})
let index = |name: &str, ty: &str| {
pack_uniforms::index(name).or_else(|| custom_index(custom, name, ty))
};
let interface = &c.interface;
let (entries, samplers, slots, reads, shadow_reads, images) = bindings(
@@ -1869,7 +1961,7 @@ fn build_compute(
uniforms: interface
.uniforms
.iter()
.map(|u| (index(&u.name), u.ty.clone(), u.offset))
.map(|u| (index(&u.name, &u.ty), u.ty.clone(), u.offset))
.collect(),
name,
group,
@@ -1883,6 +1975,7 @@ fn build_compute(
samplers,
writes: Vec::new(),
flip: 0,
depth_linear: depth_linear(interface, pack.shadow_linear),
copies: written & reads | before_colors,
reads: reads | written,
shadow_reads,
@@ -1965,7 +2058,9 @@ impl PackPost {
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
let shadow_colors = (1u16 << post.shadow_colors) - 1;
let mut custom = post.custom;
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
let custom_names: Vec<(String, String)> = (custom.uniforms())
.map(|(name, ty)| (name.to_owned(), ty.to_owned()))
.collect();
// A first run finds the names neither the frame nor the pack defines.
custom.eval(0.0, &pack_uniforms::zero);
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
@@ -1977,6 +2072,9 @@ impl PackPost {
let mask =
|flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
let mut used = 1u16;
// Buffers some pass draws into the "alt" side of, or flips: only these get a B texture.
let mut doubled = mask(&post.deferred_pre) | mask(&post.composite_pre);
let mut shadow_doubled = 0u16;
let draws = DrawUniforms::new(device);
// The shadow buffers' formats when the shadow pass runs; their programs see them as
// buffers 0 to 7.
@@ -2060,13 +2158,9 @@ impl PackPost {
packed,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
(0..2)
.filter(|&n| !shadow_config.nearest[n])
.fold(0, |m, n| m | 1 << n)
} else {
0
},
shadow_linear: (0..2)
.filter(|&n| !shadow_config.nearest[n])
.fold(0, |m, n| m | 1 << n),
};
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
let in_shadow = p.group.shadow();
@@ -2135,11 +2229,29 @@ impl PackPost {
));
return None;
}
// Composite-style programs draw into the "alt" side of what they write; gbuffers,
// shadow and `final` programs write in place, compute programs through images.
let alt = if geometry.is_none()
&& group != Group::Final
&& matches!(pass.pipeline, Pipeline::Render(_))
{
pass.written
} else {
0
};
if in_shadow {
shadow_doubled |= alt;
} else {
doubled |= alt;
}
for p in std::iter::once(&pass).chain(&pass.compute) {
let (stage, flipped) = pack.released.get();
pack.released.set((stage, flipped | p.flip));
if !in_shadow {
if in_shadow {
shadow_doubled |= p.flip;
} else {
used |= p.reads | p.written | p.flip;
doubled |= p.flip;
}
storage |= image_buffers(&p.images);
image_copied |= p.image_copies;
@@ -2163,10 +2275,15 @@ impl PackPost {
ids: block_ids.as_ref().map(|i| &i.0),
cull: g.cull,
});
let terrain = post
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
let mut terrain = post
.terrain
.zip(terrain_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let mut terrain_cutout = post
.terrain_cutout
.zip(terrain_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let water = post
.water
.zip(terrain_geometry.as_ref())
@@ -2295,7 +2412,7 @@ impl PackPost {
let weather_geometry = world.weather.then(|| Geometry {
buffers: &entity_buffers,
table: None,
depth_write: true,
depth_write: post.rain_depth,
atlas: None,
material_maps: false,
ids: None,
@@ -2371,8 +2488,31 @@ impl PackPost {
.filter_map(|p| build(p, None))
.collect();
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
if let Some(t) = &terrain {
notes.push(format!("terrain draws with {}", t.name));
// Terrain in two parts draws both or neither: one alone would leave the other part's
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
// program of a pack that doesn't split can't draw.
if split
&& terrain.is_some() != terrain_cutout.is_some()
&& let Some(p) = terrain.take().or(terrain_cutout.take())
{
notes.push(format!(
"{}: the other part of terrain can't draw; the game draws all terrain",
p.name
));
}
match (&terrain, &mut terrain_cutout) {
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
(t, Some(c)) => {
if let Some(t) = t {
notes.push(format!("solid terrain draws with {}", t.name));
}
notes.push(format!("cutout terrain draws with {}", c.name));
// Timed (and labelled) apart from the solid part when it runs the same program.
if t.as_ref().is_some_and(|t| t.name == c.name) {
c.name.push_str(" (cutout)");
}
}
(None, None) => {}
}
for (part, pass) in [
("terrain", &shadow_terrain),
@@ -2456,6 +2596,7 @@ impl PackPost {
.iter()
.chain(&shadow_comp)
.chain(&terrain)
.chain(&terrain_cutout)
.chain(&water))
.chain(shadow_terrain.iter().chain(&shadow_water))
.chain(sky.iter().flat_map(|s| s.basic.iter().chain(&s.textured)))
@@ -2478,30 +2619,28 @@ impl PackPost {
let depth1 = every_pass()
.flat_map(|p| &p.samplers)
.any(|s| matches!(s.source, Source::ShadowDepth(1, _)));
let linear = every_pass()
.flat_map(|p| &p.samplers)
.filter(|s| s.filtering)
.fold(0u8, |m, s| match s.source {
Source::ShadowDepth(n, false) => m | 1 << n,
_ => m,
});
let t = super::pack_shadow::ShadowTargets::new(
device,
&shadow_config,
f,
colors,
shadow_doubled & colors,
depth1,
linear,
);
notes.push(format!(
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b}{1}",
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b} (two textures: {4:#b}){1}",
t.size,
if depth1 { ", shadowtex1" } else { "" },
shadow_config.distance,
shadow_config.sun_path_rotation,
shadow_doubled & colors,
));
t
});
log::info!(
"shader pack: colortex buffers {used:#b}, with two textures {:#b}",
doubled & used
);
// Any program can leave pixels untouched. Composite-style passes also write the
// alternate image, so their first write does not replace the main image's clear.
let mut cleared = Vec::new();
@@ -2513,6 +2652,7 @@ impl PackPost {
.iter()
.flat_map(|s| s.basic.iter().chain(&s.textured))
.chain(&terrain)
.chain(&terrain_cutout)
.chain(entities.iter().flat_map(|e| e.passes.iter().flatten()))
.chain(&clouds)
.chain(&lines)
@@ -2528,6 +2668,7 @@ impl PackPost {
}
// Terrain tests against the world's depth (entities...), so the blit copies it then.
let blit_depth = terrain.is_some()
|| terrain_cutout.is_some()
|| water.is_some()
|| sky.is_some()
|| entities.as_ref().is_some_and(Entities::drawn)
@@ -2597,7 +2738,7 @@ impl PackPost {
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: false }),
],
&blit_targets,
// GL's window depth, written for depthtex0 and the terrain's depth test.
@@ -2624,6 +2765,20 @@ impl PackPost {
&[target(screen)],
None,
);
let upscale = helper(
device,
"vs_screen",
"fs_copy",
&[
texture_entry(0, float),
entry(
2,
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
),
],
&[target(screen)],
None,
);
let filterable = |b: usize| {
formats[b].sample_type(None, Some(device.features()))
== Some(wgpu::TextureSampleType::Float { filterable: true })
@@ -2665,6 +2820,7 @@ impl PackPost {
setup_pending: true,
passes,
terrain,
terrain_cutout,
water,
sky,
entities,
@@ -2708,6 +2864,7 @@ impl PackPost {
rgb,
rgb_unset: true,
used,
doubled: doubled & used,
storage,
copied,
sizes,
@@ -2814,8 +2971,18 @@ impl PackPost {
),
repeat: sampler(device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat),
nearest_repeat: sampler(device, wgpu::FilterMode::Nearest, wgpu::AddressMode::Repeat),
atlas_sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack atlas"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
..Default::default()
}),
blit,
copy,
upscale,
final_target: None,
screen,
uniforms: {
let mut u = FrameUniforms::default();
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
@@ -2858,7 +3025,8 @@ impl PackPost {
}
}
let fog = self.uniforms.fog();
// Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`), then the pack's.
// Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`, `hz_depth_linear`),
// then the pack's.
let ours = [
[
out[0] as f32,
@@ -2868,12 +3036,21 @@ impl PackPost {
],
fog[0].to_array(),
fog[1].to_array(),
pass.depth_linear,
];
let head: &[u8] = bytemuck::cast_slice(&ours);
let n = head.len().min(pass.block.len());
pass.block[..n].copy_from_slice(&head[..n]);
for (index, ty, offset) in &pass.uniforms {
if let Some(v) = index.and_then(|i| self.uniforms.values.get(i)) {
let Some((i, v)) = index.and_then(|i| Some((i, self.uniforms.values.get(i)?))) else {
continue;
};
// A built-in of another type keeps its initial value (0); custom uniforms were
// checked when the pass was built.
if pack_uniforms::NAMES
.get(i)
.is_none_or(|name| v.read_as(name, ty))
{
v.write(ty, &mut pass.block, *offset as usize);
}
}
@@ -2911,12 +3088,6 @@ impl PackPost {
shadow_color(i, usize::from(key & 1 << (32 + i) != 0))
.unwrap_or(&self.dummy)
}
Source::ShadowDepth(i, false) if s.filtering => {
match shadow.and_then(|t| t.linear[i].as_ref()) {
Some(v) => v,
None => &self.dummy,
}
}
Source::ShadowDepth(i, _) => match shadow {
Some(t) => match (i, &t.depth1) {
(1, Some(d)) => &d.1,
@@ -3017,6 +3188,7 @@ impl PackPost {
SamplerKind::Nearest => &self.nearest,
SamplerKind::Repeat => &self.repeat,
SamplerKind::NearestRepeat => &self.nearest_repeat,
SamplerKind::Atlas => &self.atlas_sampler,
SamplerKind::Comparison => shadow.map_or(&self.shadow.1, |t| &t.comparison),
};
entries.push(wgpu::BindGroupEntry {
@@ -3153,7 +3325,8 @@ impl PackPost {
}
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
/// the game's draws.
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
/// same draws.
fn draw_terrain(
&mut self,
device: &wgpu::Device,
@@ -3163,13 +3336,35 @@ impl PackPost {
list: TerrainList,
draw: &mut TerrainDraw<'_>,
) {
let slot = match list {
self.draw_terrain_part(device, queue, encoder, size, list, false, draw);
if list == TerrainList::Solid {
self.draw_terrain_part(device, queue, encoder, size, list, true, draw);
}
}
/// The pass that draws `list`, or the cutout part of the solid list.
fn terrain_slot(&mut self, list: TerrainList, cutout: bool) -> &mut Option<Pass> {
match list {
TerrainList::Solid if cutout => &mut self.terrain_cutout,
TerrainList::Solid => &mut self.terrain,
TerrainList::Translucent => &mut self.water,
TerrainList::ShadowSolid => &mut self.shadow_terrain,
TerrainList::ShadowTranslucent => &mut self.shadow_water,
};
let Some(mut pass) = slot.take() else {
}
}
#[allow(clippy::too_many_arguments)]
fn draw_terrain_part(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
size: [u32; 2],
list: TerrainList,
cutout: bool,
draw: &mut TerrainDraw<'_>,
) {
let Some(mut pass) = self.terrain_slot(list, cutout).take() else {
return;
};
let translucent = list.translucent();
@@ -3207,12 +3402,7 @@ impl PackPost {
if !pass.group.shadow() {
self.mips_valid &= !pass.written;
}
*match list {
TerrainList::Solid => &mut self.terrain,
TerrainList::Translucent => &mut self.water,
TerrainList::ShadowSolid => &mut self.shadow_terrain,
TerrainList::ShadowTranslucent => &mut self.shadow_water,
} = Some(pass);
*self.terrain_slot(list, cutout) = Some(pass);
}
/// The sky's parts in vanilla's order, each with the program that draws it; one render pass
@@ -3846,12 +4036,13 @@ impl PackPost {
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
},
every_face: true,
view_casters: true,
})
}
/// Whether the pack draws terrain itself.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some()
self.terrain.is_some() || self.terrain_cutout.is_some()
}
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
@@ -3902,6 +4093,7 @@ impl PackPost {
.passes
.iter_mut()
.chain(&mut self.terrain)
.chain(&mut self.terrain_cutout)
.chain(&mut self.water)
.chain(sky)
.chain(entities)
@@ -3983,8 +4175,16 @@ impl PackPost {
if i != 0 && self.clears[i].is_none() {
attachment |= wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
}
let sides = [0, 1]
.map(|_| texture_sized(&label, self.formats[i], attachment, levels, sizes[i]));
let side = || texture_sized(&label, self.formats[i], attachment, levels, sizes[i]);
let a = side();
let sides = [
a.clone(),
if self.doubled & 1 << i != 0 {
side()
} else {
a
},
];
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
mips[i] = Some(sides.each_ref().map(|t| {
Mips {
@@ -4147,9 +4347,6 @@ impl PackPost {
TerrainList::ShadowTranslucent,
terrain,
);
if let Some(t) = &self.shadow_targets {
t.copy_linear(encoder);
}
let mut comp = std::mem::take(&mut self.shadow_comp);
for pass in &mut comp {
let mut compute = std::mem::take(&mut pass.compute);
@@ -4229,8 +4426,8 @@ impl PackPost {
shadow.interval.to_bits().hash(&mut hasher);
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
shadow.near.to_bits().hash(&mut hasher);
shadow.far.to_bits().hash(&mut hasher);
shadow.near.map(f32::to_bits).hash(&mut hasher);
shadow.far.map(f32::to_bits).hash(&mut hasher);
for hf in &shadow.hardware_filtering {
hf.hash(&mut hasher);
}
@@ -4318,6 +4515,35 @@ impl PackPost {
terrain: &mut TerrainDraw<'_>,
) {
self.resize(device, size);
// 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) {
if self.final_target.as_ref().is_none_or(|t| t.size != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack final"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.screen,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.final_target = Some(FinalTarget {
size,
view: texture.create_view(&Default::default()),
});
}
self.final_target.as_ref().map(|t| t.view.clone())
} else {
self.final_target = None;
None
};
if let Some((layout, ids, rects)) = &self.block_ids {
// A page that grew has a new buffer.
self.page_groups
@@ -4420,7 +4646,10 @@ impl PackPost {
store: wgpu::StoreOp::Store,
};
for b in (0..BUFFERS).filter(|&b| self.rgb & 1 << b != 0) {
for alt in [false, true] {
for alt in [false, true]
.into_iter()
.take(1 + usize::from(self.doubled & 1 << b != 0))
{
if let Some(v) = view(self.main, b, alt) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack three-channel start"),
@@ -4453,7 +4682,8 @@ impl PackPost {
let max_attachments = (limits.max_color_attachments as usize).min(8);
// The buffers the blit doesn't clear, and the other texture of every cleared buffer.
let alt_clears = (0..BUFFERS)
.filter(|&b| self.clears[b].is_some() && view(self.main, b, true).is_some())
.filter(|&b| self.clears[b].is_some() && self.doubled & 1 << b != 0)
.filter(|&b| view(self.main, b, true).is_some())
.map(|b| (b, true));
let mut by_size: Vec<([u32; 2], Vec<(usize, bool)>)> = Vec::new();
for (b, alt) in self
@@ -4561,6 +4791,7 @@ impl PackPost {
let mut shown = false;
// A water-only pack still needs the opaque phase to populate requested snapshots.
let mut terrain_drawn = self.terrain.is_none()
&& self.terrain_cutout.is_none()
&& self.sky.is_none()
&& !self.entities.as_ref().is_some_and(Entities::drawn)
&& self.clouds.is_none()
@@ -4617,8 +4848,8 @@ impl PackPost {
self.mips_valid |= stale;
let final_pass = pass.group == Group::Final;
// A pass writing buffers of a size of their own draws at that size (they share it).
// `final` draws the picture at the world's size; a copy takes it to the window.
let out = match pass.writes.first() {
_ if final_pass => window,
Some(&(_, b)) => self.targets.as_ref().map_or(size, |t| t.sizes[b]),
None => size,
};
@@ -4641,7 +4872,7 @@ impl PackPost {
overwrite
};
if final_pass {
attachments.push(attach(screen, overwrite));
attachments.push(attach(final_view.as_ref().unwrap_or(screen), overwrite));
shown = true;
} else if pass.sink {
attachments.push(attach(&targets.sink, ops));
@@ -4724,6 +4955,31 @@ impl PackPost {
rp.set_pipeline(&self.copy.0);
rp.set_bind_group(0, &group, &[]);
rp.draw(0..3, 0..1);
} else if let Some(picture) = &final_view {
// The picture at the world's size onto the window: one upscale, smoothly.
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack final"),
layout: &self.upscale.1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(picture),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
],
});
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack final"),
color_attachments: &[attach(screen, overwrite)],
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes("final copy")),
..Default::default()
});
rp.set_pipeline(&self.upscale.0);
rp.set_bind_group(0, &group, &[]);
rp.draw(0..3, 0..1);
}
}
}
@@ -4735,6 +4991,10 @@ mod snorm_tests;
#[path = "pack_post/depth_test.rs"]
mod depth_test;
#[cfg(test)]
#[path = "pack_post/terrain_parts_tests.rs"]
mod terrain_parts_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -5404,3 +5664,18 @@ mod const_offset_tests;
#[cfg(test)]
mod integer_colortex0_tests;
#[cfg(test)]
mod atlas_mip_tests;
#[cfg(test)]
mod wrong_type_uniform_tests;
#[cfg(test)]
mod atlas_name_post_tests;
#[cfg(test)]
mod unknown_sampler_terrain_tests;
#[cfg(test)]
mod mipmap_box_tests;
@@ -9,7 +9,9 @@ fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var world_depth: texture_depth_2d;
// A depth view read as an unfilterable float: naga's GLSL backend cannot
// `textureLoad` a depth texture, and the texel is the same either way.
@group(0) @binding(1) var world_depth: texture_2d<f32>;
struct World {
@location(0) color: vec4<f32>,
@@ -24,7 +26,7 @@ fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
var out: World;
out.color = textureLoad(source, at, 0);
out.depth = 1.0 - textureLoad(world_depth, at, 0);
out.depth = 1.0 - textureLoad(world_depth, at, 0).r;
return out;
}
@@ -49,15 +51,51 @@ fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
}
// The screen's own rows: a picture a shader pack's `final` drew (not a GL-rows buffer).
@vertex
fn vs_screen(@builtin(vertex_index) i: u32) -> Full {
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), uv);
}
@fragment
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
return textureSample(source, linear, v.uv);
}
// One level of a buffer's mip chain from the level above (6.1.7): the average of the 2×2
// texels under each destination texel, by one bilinear sample at their shared corner.
// One level of a buffer's mip chain from the level above (6.1.7): a box filter, the average
// of the texels under each destination texel (OpenGL 4.6, 8.14.4, recommends one). Along an
// even side a texel covers two of the level above; along an odd side of 2d + 1 texels, texel i
// covers 2 + 1 / d of them: 2i, 2i + 1 and 2i + 2, weighted d - i, d and i + 1 over 2d + 1.
// Every texel of the level above then counts as much, so a small bright spot keeps its share
// down to the last level, which is the buffer's mean.
fn down_weights(i: u32, side: u32) -> vec3<f32> {
if side == 1u {
return vec3<f32>(1.0, 0.0, 0.0);
}
if side % 2u == 0u {
return vec3<f32>(0.5, 0.5, 0.0);
}
let d = f32(side / 2u);
return vec3<f32>(d - f32(i), d, f32(i) + 1.0) / f32(side);
}
@fragment
fn fs_down(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let size = max(textureDimensions(source) / 2u, vec2<u32>(1u));
return textureSampleLevel(source, linear, p.xy / vec2<f32>(size), 0.0);
let above = textureDimensions(source);
let at = vec2<u32>(p.xy);
let wx = down_weights(at.x, above.x);
let wy = down_weights(at.y, above.y);
let first = vec2<i32>(at * 2u);
let last = vec2<i32>(above) - 1;
var sum = vec4<f32>(0.0);
for (var y = 0; y < 3; y++) {
for (var x = 0; x < 3; x++) {
let w = wx[x] * wy[y];
if w > 0.0 {
sum += w * textureLoad(source, min(first + vec2<i32>(x, y), last), 0);
}
}
}
return sum;
}
@@ -0,0 +1,108 @@
//! The block atlas is read with nearest texels, blended between the two nearest levels: our
//! probe packs (#9307) read `gtexture` in gbuffers_terrain, and the atlas as a custom texture in
//! composite, at levels 0.5 and 1.25 in Iris 1.11.7, and got the two nearest levels mixed.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv = gl_MultiTexCoord0.xy; gl_Position = ftransform(); }\n";
/// The atlas as composite's `noisetex`, read at levels 0.5 (red) and 0.25 (green).
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders/shaders.properties",
"texture.composite.noisetex=minecraft:textures/atlas/blocks.png\n",
);
add("shaders/composite.vsh", VERTEX);
add(
"shaders/composite.fsh",
"#version 130\nuniform sampler2D noisetex;\nvoid main() {\n/* DRAWBUFFERS:0 */\n\
\x20 gl_FragData[0] = vec4(textureLod(noisetex, vec2(0.5), 0.5).r, textureLod(noisetex, vec2(0.5), 0.25).r, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-atlas-levels",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
/// A 4x4 atlas whose first level is black and second white.
fn atlas(gpu: &Fixture) -> wgpu::TextureView {
let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
label: Some("Original two-level atlas"),
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 2,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
for (level, side, value) in [(0u32, 4u32, 0u8), (1, 2, 255)] {
gpu.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: level,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&[value, value, value, 255].repeat((side * side) as usize),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(side * 4),
rows_per_image: None,
},
wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: 1,
},
);
}
texture.create_view(&Default::default())
}
#[test]
fn the_atlas_blends_its_two_nearest_levels() {
let Some(gpu) = Fixture::new() else { return };
// The pack reads no lightmap: any texture serves.
let lightmap = atlas(&gpu);
let mut post = PackPost::new(
&gpu.device,
&gpu.queue,
&gpu.adapter,
pack(),
wgpu::TextureFormat::Rgba8Unorm,
World {
atlas: Some((atlas(&gpu), [4, 4])),
material_maps: None,
lightmap,
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
)
.expect("the pack runs");
for p in gpu.render(&mut post, [4, 4]) {
// Half of white, and a quarter (within the blend's 8-bit weight).
assert!(p[0].abs_diff(128) <= 2 && p[1].abs_diff(64) <= 2, "{p:?}");
}
}
@@ -0,0 +1,74 @@
//! In deferred, composite and final programs the atlas's sampler names (`gtexture`, `tex`,
//! `texture`) and names the game gives no texture read texture unit 0, which OptiFine's
//! shaders.txt gives to `colortex0` there. Our probe packs (#9307) read the scene in `colortex0`
//! through each in Iris 1.11.7, also in programs that don't declare `colortex0`.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv = gl_MultiTexCoord0.xy; gl_Position = ftransform(); }\n";
/// deferred writes a colour into colortex0; composite reads it through `gtexture` into
/// colortex1, composite1 through `tex` into colortex2, composite2 through a name of its own into
/// colortex3; final shows them in that order, left to right, then what its own `texture` reads.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
for program in ["deferred", "composite", "composite1", "composite2", "final"] {
add(&format!("{program}.vsh"), VERTEX);
}
add(
"deferred.fsh",
"#version 120\nvoid main() {\n/* DRAWBUFFERS:0 */\n gl_FragData[0] = vec4(0.0, 200.0 / 255.0, 40.0 / 255.0, 1.0);\n}\n",
);
add(
"composite.fsh",
"#version 130\nuniform sampler2D gtexture;\nvarying vec2 uv;\nvoid main() {\n/* DRAWBUFFERS:1 */\n gl_FragData[0] = texture(gtexture, uv);\n}\n",
);
add(
"composite1.fsh",
"#version 130\nuniform sampler2D tex;\nvarying vec2 uv;\nvoid main() {\n/* DRAWBUFFERS:2 */\n gl_FragData[0] = texture(tex, uv);\n}\n",
);
add(
"composite2.fsh",
"#version 130\nuniform sampler2D probeOwnName;\nvarying vec2 uv;\nvoid main() {\n/* DRAWBUFFERS:3 */\n gl_FragData[0] = texture(probeOwnName, uv);\n}\n",
);
add(
"final.fsh",
"#version 120\nuniform sampler2D texture;\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
uniform sampler2D colortex3;\nvarying vec2 uv;\nvoid main() {\n\
\x20 gl_FragColor = uv.x < 0.25 ? texture2D(colortex1, uv) : uv.x < 0.5 ? texture2D(colortex2, uv)\n\
\x20 : uv.x < 0.75 ? texture2D(colortex3, uv) : texture2D(texture, uv);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-atlas-names-in-post",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn atlas_names_read_colortex0_in_deferred_composite_and_final() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
for (i, p) in pixels.iter().enumerate() {
let name = match i % 8 {
0..2 => "gtexture in composite",
2..4 => "tex in composite1",
4..6 => "a name of the pack's in composite2",
_ => "texture in final",
};
assert_eq!(*p, [0, 200, 40, 255], "{name}, pixel {i}");
}
}
@@ -0,0 +1,76 @@
//! A custom uniform of `shaders.properties` that the GLSL declares with another type: an original
//! probe (#9307). In Iris 1.11.7 our probe packs read 0 for a float custom uniform declared `int`,
//! an int declared `float`, a vec3 declared `vec2` and a vec2 declared `vec3`, and the uniform's
//! own value where the types agree. A float of 2.5 declared `bool` read true (Iris logged it as
//! disabled, but it read true in both games).
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders.properties",
"uniform.float.floatAsInt = 7.0\n\
uniform.int.intAsFloat = 7\n\
uniform.vec3.vec3AsVec2 = vec3(1.0, 2.0, 3.0)\n\
uniform.vec2.vec2AsVec3 = vec2(4.0, 5.0)\n\
uniform.float.floatAsFloat = 0.5\n\
uniform.int.intAsInt = 3\n\
uniform.float.floatAsBool = 2.5\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows case x % 7 in red.
add(
"final.fsh",
"#version 330 compatibility\n\
uniform int floatAsInt;\nuniform float intAsFloat;\nuniform vec2 vec3AsVec2;\n\
uniform vec3 vec2AsVec3;\nuniform float floatAsFloat;\nuniform int intAsInt;\n\
uniform bool floatAsBool;\n\
void main() {\n int x = int(gl_FragCoord.x) % 7;\n float o = 0.0;\n\
if (x == 0) o = float(floatAsInt) / 10.0;\n\
else if (x == 1) o = intAsFloat / 10.0;\n\
else if (x == 2) o = (vec3AsVec2.x + vec3AsVec2.y) / 10.0;\n\
else if (x == 3) o = (vec2AsVec3.x + vec2AsVec3.y) / 10.0;\n\
else if (x == 4) o = floatAsFloat;\n\
else if (x == 5) o = float(intAsInt) / 10.0;\n\
else o = floatAsBool ? 1.0 : 0.0;\n\
gl_FragColor = vec4(o, 0.0, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-custom-types",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 1, "{:?}", post.notes);
post
}
#[test]
fn a_custom_uniform_declared_with_another_type_reads_zero() {
let Some(gpu) = Fixture::new() else { return };
let Ok(mut post) = gpu.try_build(pack()) else {
panic!("the pack runs")
};
let pixels = gpu.render(&mut post, [7, 1]);
let red: Vec<u8> = pixels[..7].iter().map(|p| p[0]).collect();
// 0 for the four mismatches, 0.5 and 0.3 where the types agree, then true.
let want = [0.0, 0.0, 0.0, 0.0, 0.5, 0.3, 1.0].map(|v: f32| (v * 255.0).round() as u8);
assert!(
red.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
"{red:?}, expected {want:?}"
);
}
@@ -3,15 +3,15 @@ use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use wgpu::{TextureFormat as T, util::DeviceExt};
const SIZE: [u32; 2] = [16, 16];
pub(super) const SIZE: [u32; 2] = [16, 16];
struct Gpu {
pub(super) struct Gpu {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
pub(super) device: wgpu::Device,
pub(super) queue: wgpu::Queue,
}
impl Gpu {
fn new() -> Option<Self> {
pub(super) fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
@@ -27,7 +27,7 @@ impl Gpu {
queue,
})
}
fn texture(&self, format: T) -> wgpu::TextureView {
pub(super) fn texture(&self, format: T) -> wgpu::TextureView {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original depth fixture input"),
@@ -47,7 +47,7 @@ impl Gpu {
})
.create_view(&Default::default())
}
fn load(
pub(super) fn load(
&self,
files: Vec<(String, String)>,
world: World<'_>,
@@ -92,7 +92,12 @@ impl Gpu {
);
built
}
fn clear(&self, color: &wgpu::TextureView, depth: &wgpu::TextureView, reverse_depth: f32) {
pub(super) fn clear(
&self,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
reverse_depth: f32,
) {
let mut encoder = self.device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Independent original fixture clear"),
@@ -122,7 +127,7 @@ impl Gpu {
});
self.queue.submit([encoder.finish()]);
}
fn render(
pub(super) fn render(
&self,
post: &mut PackPost,
color: &wgpu::TextureView,
@@ -213,7 +218,7 @@ enum Scene {
WaterOnly,
}
const VERTEX: &str =
pub(super) const VERTEX: &str =
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
@@ -316,7 +321,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 }),
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 }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
@@ -561,7 +566,7 @@ fn attached_compute_preserves_distinct_hand_snapshots() {
}
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
@@ -648,15 +653,23 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
/// moved toward the next texel, times four (0 nearest, 255 linear). `nearest` is a line for
/// both stage files.
fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
shadow_depth_reads(
nearest,
"float moved(sampler2D s, ivec2 t) {\n\
float a = texelFetch(s, t, 0).r;\n\
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
}\n\
void main() {\n\
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
}\n",
)
}
/// `shadow_depth_filtering`'s shadow map read by `reads`: `final`'s code after its declarations
/// of shadowtex0 and shadowtex1.
fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
let gpu = Gpu::new()?;
if !gpu
.device
.features()
.contains(wgpu::Features::FLOAT32_FILTERABLE)
{
eprintln!("DEPTH_FIXTURE_NO_FLOAT32_FILTERABLE");
return None;
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
@@ -677,6 +690,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: false,
}),
..Default::default()
};
@@ -687,15 +701,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
"final.fsh".into(),
format!(
"#version 330 compatibility\n{settings}\
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n\
float moved(sampler2D s, ivec2 t) {{\n\
float a = texelFetch(s, t, 0).r;\n\
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
}}\n\
void main() {{\n\
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
}}\n"
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n{reads}"
),
),
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
@@ -769,6 +775,24 @@ fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
}
}
#[test]
fn shadow_depth_reads_filter_directly_and_with_a_texel_offset() {
// texture2D on the sampler itself, and textureOffset one texel to the right: both a quarter
// texel past a centre, so each moves a quarter of the way to the next texel.
let reads = "void main() {\n\
ivec2 t = ivec2(int(gl_FragCoord.x) % 14, 8);\n\
vec2 p = (vec2(t) + vec2(0.75, 0.5)) / 16.0;\n\
float a = texelFetch(shadowtex0, t, 0).r;\n\
float b = texelFetch(shadowtex1, t + ivec2(1, 0), 0).r;\n\
float r = (texture2D(shadowtex0, p).r - a) * 64.0;\n\
float g = (textureOffset(shadowtex1, p, ivec2(1, 0)).r - b) * 64.0;\n\
gl_FragColor = vec4(r, g, 0.0, 1.0);\n\
}\n";
if let Some(pixels) = shadow_depth_reads("", reads) {
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex direct and offset");
}
}
#[test]
fn shadowtex_nearest_settings_read_the_nearest_texel() {
for (line, expected) in [
@@ -783,3 +807,68 @@ fn shadowtex_nearest_settings_read_the_nearest_texel() {
}
}
}
/// The sky program covers the horizon and downward viewing directions too.
/// This original probe reports sky colour in RGB and window depth in alpha.
#[test]
#[ignore = "requires a host GPU"]
fn sky_covers_the_horizon_without_writing_depth_or_overpainting_terrain() {
let gpu = Gpu::new().expect("GPU required for the sky coverage regression");
assert_ne!(gpu.adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let files = vec![
("gbuffers_skybasic.vsh".into(), "#version 120\nvoid main(){gl_Position=ftransform();}".into()),
("gbuffers_skybasic.fsh".into(), "#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(1.0,0.0,0.0,1.0);}".into()),
("final.vsh".into(), VERTEX.into()),
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0;uniform sampler2D depthtex0;varying vec2 uv;\nconst vec4 colortex0ClearColor=vec4(0.0,1.0,0.0,1.0);\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,texture2D(depthtex0,uv).r);}".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 {
sky: Some(crate::gfx::pack_sky::inputs()),
..Default::default()
},
);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
frame.distance = 512.0;
frame.detail_distance = 512.0;
frame.depth_far = 2048.0;
for yaw in [0.0, 45.0, 90.0, 180.0, 270.0] {
for pitch in [-60.0, 0.0, 21.1, 60.0] {
frame.yaw = yaw;
frame.pitch = pitch;
gpu.clear(&color, &depth, 0.0);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
all_pixels(
&pixels,
[255, 0, 0, 255],
&format!("sky yaw={yaw} pitch={pitch}"),
);
}
}
// A pre-existing foreground depth must survive the sky, as must its colour.
gpu.clear(&color, &depth, 0.5);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
all_pixels(
&pixels,
[16, 16, 16, 128],
"foreground colour and depth survive sky",
);
}
@@ -0,0 +1,65 @@
//! A buffer's mip chain is a box filter of the level above, odd sides included, so a small
//! bright spot keeps its share of every level down to the last, the buffer's mean: OpenGL 4.6
//! (8.14.4) recommends a box filter for generated mipmaps, and Iris 1.11.7's colortex mipmaps
//! kept a 26x22-pixel spot of a 1280x720 buffer in its last level (probe packs of ours).
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// composite writes 1.0 into one texel of colortex1, its top right corner, and 0 elsewhere;
/// final shows level 2 (the 1x1 level of a 5x3 buffer) in red and texel (1, 0) of level 1 (2x1)
/// in green, both scaled so that the box filter's values read 0.5.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 330 compatibility\n/*\nconst int colortex1Format = RGBA16F;\n*/\n\
/* RENDERTARGETS: 1 */\nlayout(location = 0) out vec4 o;\n\
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n\
o = vec4(p == ivec2(4, 2) ? 1.0 : 0.0, 0.0, 0.0, 1.0);\n}\n",
);
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 330 compatibility\n/*\nconst bool colortex1MipmapEnabled = true;\n*/\n\
uniform sampler2D colortex1;\n\
void main() {\n float mean = textureLod(colortex1, vec2(0.5), 2.0).r;\n\
float right = texelFetch(colortex1, ivec2(1, 0), 1).r;\n\
gl_FragColor = vec4(mean * 7.5, right * 3.75, 0.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-mipmap-box",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn a_bright_texel_keeps_its_share_of_odd_sized_levels() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [5, 3]);
// The 1x1 level is the mean of the 15 texels, 1/15; texel (1, 0) of the 2x1 level covers
// columns 2, 3 and 4 (weights 1, 2, 2 of 5) of all three rows (1 of 3 each): 2/15.
let [r, g, ..] = pixels[0];
assert!(
r.abs_diff(128) <= 2 && g.abs_diff(128) <= 2,
"mean and level-1 texel scaled to 0.5: got red {r}, green {g}, expected 128 and 128"
);
}
@@ -45,6 +45,7 @@ fn pack() -> Post {
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: 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);\
@@ -0,0 +1,93 @@
//! A buffer gets its B texture only when a composite-style program draws into it or flips it:
//! the others' B is a second view of A, and they read the same as before. The programs here
//! are original and cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes colortex1 from colortex2, which no program writes; colortex4 is only
/// flipped by a directive. `shadowcomp` writes shadowcolor1; shadowcolor0 is only read.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("shadowcomp.vsh", VERTEX);
add(
"shadowcomp.fsh",
"#version 120\n/* RENDERTARGETS: 1 */\nvoid main() { gl_FragData[0]=vec4(0,0.5,0,1); }\n",
);
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS: 1 */\n\
void main() { gl_FragData[0]=vec4(texture2D(colortex2,uv).rgb*0.5,1); }\n",
);
add("final.vsh", VERTEX);
// Pixel column x shows colortex1, colortex4, shadowcolor0 and shadowcolor1 in turn.
add(
"final.fsh",
"#version 330 compatibility\nconst int shadowMapResolution=16;\n\
const vec4 colortex2ClearColor=vec4(1,1,1,1);\n\
const vec4 colortex4ClearColor=vec4(0,0,0.75,1);\n\
const vec4 shadowcolor0ClearColor=vec4(0.25,0,0,1);\n\
uniform sampler2D colortex1;\nuniform sampler2D colortex4;\n\
uniform sampler2D shadowcolor0;\nuniform sampler2D shadowcolor1;\nin vec2 uv;\n\
void main() {\n vec4 c[4] = vec4[4](texture(colortex1,uv), texture(colortex4,uv),\n\
texture(shadowcolor0,uv), texture(shadowcolor1,uv));\n\
gl_FragColor = c[int(gl_FragCoord.x) % 4];\n}\n",
);
add(
"shaders.properties",
"shadow.enabled=true\nflip.composite.colortex4=true\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-single-side",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn only_buffers_drawn_into_or_flipped_get_a_second_texture() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// Two frames: the second starts from the first one's buffers.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
let expected = [
[128, 128, 128, 255],
[0, 0, 191, 255],
[64, 0, 0, 255],
[0, 128, 0, 255],
];
for (x, want) in expected.iter().enumerate() {
let got = pixels[x];
assert!(
got.iter().zip(want).all(|(g, w)| g.abs_diff(*w) <= 1),
"frame {frame}, column {x}: {got:?}, expected {want:?}"
);
}
}
let shared = |sides: &[wgpu::TextureView; 2]| sides[0].texture() == sides[1].texture();
let targets = post.targets.as_ref().expect("rendered");
for (b, single) in [(1, false), (2, true), (4, false)] {
let sides = targets.color[b].as_ref().expect("used buffer");
assert_eq!(shared(sides), single, "colortex{b}");
}
let shadow = post.shadow_targets.as_ref().expect("shadow pass");
for (n, single) in [(0, true), (1, false)] {
let sides = shadow.color[n].as_ref().expect("used buffer");
assert_eq!(shared(sides), single, "shadowcolor{n}");
}
}
@@ -0,0 +1,166 @@
//! The sky's programs read their fragments' own depth in `gl_FragCoord.z`, as our probe packs
//! read it in Iris 1.11.7: the sky disc at its distance; and
//! the sky still shows only where the world left the far plane. Original fixture, no pack files.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::TextureFormat as T;
/// `program` writes its fragment's `gl_FragCoord.z` into colortex1 (32-bit floats) and red into
/// colortex0; final shows colortex1's value as a 24-bit integer on the top half, and the top
/// half of colortex0 on the bottom half.
fn files(program: &str) -> Vec<(String, String)> {
vec![
(
"shaders.properties".into(),
format!("blend.{program}=off\n"),
),
(
format!("{program}.vsh"),
"#version 120\nvoid main() { gl_Position = ftransform(); }\n".into(),
),
(
format!("{program}.fsh"),
"#version 120\n/* DRAWBUFFERS:01 */\nvoid main() {\n\
\x20 gl_FragData[0] = vec4(1.0, 0.0, 0.0, 1.0);\n\
\x20 gl_FragData[1] = vec4(gl_FragCoord.z);\n}\n"
.into(),
),
("final.vsh".into(), VERTEX.into()),
(
"final.fsh".into(),
"#version 130\n/*\nconst int colortex1Format = RGBA32F;\n*/\n\
uniform sampler2D colortex0;\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
void main() {\n\
\x20 uint n = min(uint(texture(colortex1, uv).r * 16777215.0 + 0.5), 16777215u);\n\
\x20 vec4 depth = vec4(uvec4(n >> 16u, (n >> 8u) & 255u, n & 255u, 255u)) / 255.0;\n\
\x20 gl_FragColor = uv.y > 0.5 ? depth : texture(colortex0, uv + vec2(0.0, 0.5));\n}\n"
.into(),
),
]
}
/// GL window depth of a point `distance` in front of the camera along its view axis, for the
/// fixture's perspective: near 0.05, far `depth_far`.
fn window_depth(distance: f64, far: f64) -> f64 {
let near = 0.05;
let ndc = (far + near) / (far - near) - 2.0 * far * near / ((far - near) * distance);
ndc * 0.5 + 0.5
}
/// The pack's picture of a frame of the fixture, whose world drew nothing but left
/// `world_depth` (GL window depth) everywhere.
fn picture(
gpu: &Gpu,
program: &str,
world_depth: f32,
celestial: &[crate::mesher::Vertex],
) -> Vec<[u8; 4]> {
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// The world's depth is reverse-Z.
gpu.clear(&color, &depth, 1.0 - world_depth);
let sun = gpu.texture(T::Rgba8Unorm);
let mut post = gpu.load(
files(program),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: Some(sun),
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
horizon_shaders::GameInputs {
sky: Some(super::super::pack_sky::inputs()),
celestial: Some(super::super::pack_sky::celestial_inputs()),
..Default::default()
},
);
assert!(post.sky.is_some(), "{:?}", post.notes);
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.skybox = true;
frame.celestial = celestial;
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
}
fn decode(p: [u8; 4]) -> f64 {
f64::from(u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2])) / 16_777_215.0
}
/// The sky disc lies 16 blocks above the eye: the fixture looks level with a 90° field of view,
/// so a pixel `row` rows below the top of the 16-pixel picture sees it along the view axis at
/// 16 / tan(elevation) = 16 / (1 - (row + 0.5) / 8) blocks.
#[test]
fn the_sky_disc_reads_its_own_depth() {
let Some(gpu) = Gpu::new() else { return };
let pixels = picture(&gpu, "gbuffers_skybasic", 1.0, &[]);
for row in 0..4 {
let along = 16.0 / (1.0 - (row as f64 + 0.5) / 8.0);
let expected = window_depth(along, 1000.0);
for x in 0..SIZE[0] as usize {
let got = decode(pixels[row * SIZE[0] as usize + x]);
assert!(
(got - expected).abs() < 2e-5,
"row {row}, column {x}: gl_FragCoord.z {got}, expected {expected}"
);
}
}
}
/// The sun's quad (and the moon's behind the camera), 100 blocks away across the view axis:
/// one depth everywhere.
#[test]
fn the_sun_reads_its_own_depth() {
let Some(gpu) = Gpu::new() else { return };
let quad = |z: f32| {
[
[-400.0, -400.0],
[400.0, -400.0],
[400.0, 400.0],
[-400.0, 400.0],
]
.map(|[x, y]| crate::mesher::Vertex {
pos: [x, y, z],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
})
};
let quads: Vec<_> = quad(100.0).into_iter().chain(quad(-100.0)).collect();
let pixels = picture(&gpu, "gbuffers_skytextured", 1.0, &quads);
let expected = window_depth(100.0, 1000.0);
for (i, &p) in pixels[..pixels.len() / 2].iter().enumerate() {
let got = decode(p);
assert!(
(got - expected).abs() < 2e-5,
"pixel {i}: gl_FragCoord.z {got}, expected {expected}"
);
}
}
/// Where the world left a depth nearer than the far plane, even one farther away than the sky
/// disc, the world shows (the fixture's grey), not the sky's red.
#[test]
fn the_sky_shows_only_where_the_world_left_the_far_plane() {
let Some(gpu) = Gpu::new() else { return };
let half = SIZE[0] as usize * SIZE[1] as usize / 2;
let far = picture(&gpu, "gbuffers_skybasic", 1.0, &[]);
let near = picture(&gpu, "gbuffers_skybasic", 0.9999, &[]);
// The sky disc covers the upper half of the view (row 7 sees it 256 blocks away, at a
// window depth of 0.9998).
for (i, (f, n)) in far[half..].iter().zip(&near[half..]).enumerate() {
assert_eq!(*f, [255, 0, 0, 255], "far plane, pixel {i}");
assert_eq!(*n, [16, 16, 16, 255], "depth 0.9999, pixel {i}");
}
}
@@ -0,0 +1,348 @@
//! Solid and cutout terrain through `gbuffers_terrain_solid` and `gbuffers_terrain_cutout`
//! (#9137). The expected programs are what Iris 1.11.7 drew with our probe packs (the PR has
//! the numbers): with either program the pack draws solid terrain (quads over opaque texels, and
//! lava) through `gbuffers_terrain_solid` and cutout terrain through `gbuffers_terrain_cutout`,
//! the missing one falling back to `gbuffers_terrain`; solid terrain keeps a fragment written
//! with alpha 0.05, cutout terrain loses it. Each quad draws whole with one of them, whatever
//! its vertices' alphas (with `separateAo`, their own ambient occlusion). The programs here are
//! the probes', cut down.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::{TextureFormat as T, util::DeviceExt};
const RED: [u8; 3] = [255, 0, 0];
const GREEN: [u8; 3] = [0, 255, 0];
const BLUE: [u8; 3] = [0, 0, 255];
/// A program writing `rgb` with `alpha` into colortex0.
fn program(name: &str, rgb: [u8; 3], alpha: f32) -> [(String, String); 2] {
let [r, g, b] = rgb.map(|c| f32::from(c) / 255.0);
[
(
format!("{name}.vsh"),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
),
(
format!("{name}.fsh"),
format!(
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){{gl_FragData[0]=vec4({r:?},{g:?},{b:?},{alpha:?});}}"
),
),
]
}
/// Lava's sprite layer in the fixture.
const LAVA: u16 = 5;
/// The pack (`final` shows colortex0, cleared to black) drawn over three strips of terrain, left
/// to right: a quad over opaque texels, a cutout quad, a lava quad, all with opaque vertex
/// colours. Each strip's colour, `None` where nothing drew; `None` overall without a GPU.
fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> {
strips_with_alphas(programs, [255; 4])
}
/// `strips` with the quads' corners' vertex alphas (bottom left, bottom right, top right, top
/// left). Fails unless each strip is one colour.
fn strips_with_alphas(
programs: &[(&str, [u8; 3], f32)],
alphas: [u8; 4],
) -> Option<[Option<[u8; 3]>; 3]> {
let files = programs
.iter()
.flat_map(|&(name, rgb, alpha)| program(name, rgb, alpha))
.collect();
let (strips, _) = strips_of(files, alphas)?;
Some(strips)
}
/// `strips_with_alphas` with the pack's terrain programs' `files`, and the built pack's notes
/// when it draws no terrain itself (`None` when it does).
#[allow(clippy::type_complexity)]
fn strips_of(
programs: Vec<(String, String)>,
alphas: [u8; 4],
) -> Option<([Option<[u8; 3]>; 3], Option<Vec<String>>)> {
let Some(gpu) = Gpu::new() else {
eprintln!("TERRAIN_PARTS_NO_ADAPTER");
return None;
};
let mut files = vec![
("final.vsh".to_owned(), VERTEX.to_owned()),
(
"final.fsh".to_owned(),
"#version 120\nuniform sampler2D colortex0;varying vec2 uv;\
const vec4 colortex0ClearColor = vec4(0.0, 0.0, 0.0, 1.0);\
void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}"
.to_owned(),
),
];
files.extend(programs);
// The game's terrain inputs as far as the parts read them: the sprite layer and the colour.
let terrain = horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n\
layout(location=2)in uint hz_v_layer;\n\
layout(location=3)in vec4 hz_v_color;\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(),
..Default::default()
};
let inputs = horizon_shaders::GameInputs {
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
&terrain,
&[LAVA, LAVA + 1],
)),
terrain: Some(terrain),
..Default::default()
};
const ATTRS: [wgpu::VertexAttribute; 3] =
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
let opaque = u32::from(crate::mesher::OPAQUE_LAYER);
let mut vertices: Vec<u32> = Vec::new();
for (i, layer) in [opaque | 1, 2, u32::from(LAVA)].into_iter().enumerate() {
let (x0, x1) = (
-1.0 + i as f32 * 2.0 / 3.0,
-1.0 + (i + 1) as f32 * 2.0 / 3.0,
);
let corners = [[x0, -1.0], [x1, -1.0], [x1, 1.0], [x0, 1.0]];
for corner in [0, 1, 2, 0, 2, 3] {
let [x, y] = corners[corner];
let color = u32::from(alphas[corner]) << 24 | 0x00FF_FFFF;
vertices.extend([x.to_bits(), f32::to_bits(y), 0f32.to_bits(), layer, color]);
}
}
let buffer = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Terrain parts fixture"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let undrawn = (!post.has_terrain()).then(|| post.notes.clone());
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::Solid {
rp.set_vertex_buffer(0, buffer.slice(..));
rp.draw(0..18, 0..1);
}
});
// Every pixel of each strip (columns 0-4, 6-9 and 11-15; 5 and 10 straddle two strips).
let colour = |x: usize, y: usize| {
let [r, g, b, _] = pixels[y * SIZE[0] as usize + x];
Some([r, g, b]).filter(|&c| c != [0, 0, 0])
};
let strips = [0..5, 6..10, 11..16].map(|columns| {
let first = colour(columns.start, 0);
for x in columns.clone() {
for y in 0..SIZE[1] as usize {
assert_eq!(colour(x, y), first, "strip {columns:?} at ({x}, {y})");
}
}
first
});
Some((strips, undrawn))
}
#[test]
fn solid_and_cutout_programs_draw_their_own_terrain() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 1.0),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)]);
}
#[test]
fn without_a_cutout_program_cutout_terrain_falls_back_to_gbuffers_terrain() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(GREEN), Some(RED), Some(GREEN)]);
}
#[test]
fn without_a_solid_program_solid_terrain_falls_back_to_gbuffers_terrain() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
}
#[test]
fn solid_terrain_keeps_alpha_0_05_and_cutout_loses_it() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 0.05),
("gbuffers_terrain_cutout", BLUE, 0.05),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(GREEN), None, Some(GREEN)]);
// The solid part through its fallback, as the probe's `gbuffers_terrain` writing 0.05.
let programs = [
("gbuffers_terrain", RED, 0.05),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
let Some(got) = strips(&programs) else { return };
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
}
/// With `separateAo` a vertex's alpha is its own ambient occlusion, so the corners of one quad
/// differ; the quad still draws whole, with its part's program.
#[test]
fn a_quad_draws_whole_whatever_its_vertex_alphas() {
let programs = [
("gbuffers_terrain", RED, 1.0),
("gbuffers_terrain_solid", GREEN, 1.0),
("gbuffers_terrain_cutout", BLUE, 1.0),
];
for alphas in [[255, 160, 255, 160], [128, 255, 200, 64], [160; 4]] {
let Some(got) = strips_with_alphas(&programs, alphas) else {
return;
};
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)], "{alphas:?}");
}
}
#[test]
fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() {
let Some(got) = strips(&[("gbuffers_terrain", RED, 1.0)]) else {
return;
};
assert_eq!(got, [Some(RED); 3]);
}
/// A pack whose only terrain program is `gbuffers_terrain_solid` (or `_cutout`) draws that part,
/// and nothing draws the other: Iris 1.11.7 left it undrawn with our probes `tsc-solidonly` and
/// `tsc-cutoutonly`.
#[test]
fn a_lone_split_program_draws_only_its_own_terrain() {
let solid = strips(&[("gbuffers_terrain_solid", GREEN, 1.0)]);
let Some(solid) = solid else { return };
assert_eq!(solid, [Some(GREEN), None, Some(GREEN)]);
let cutout = strips(&[("gbuffers_terrain_cutout", BLUE, 1.0)]);
assert_eq!(cutout, Some([None, Some(BLUE), None]));
}
/// When one part's pipeline can't be built (here a cutout program writing no buffer, which
/// Horizon skips), the other part doesn't draw alone either: the game draws all terrain.
#[test]
fn a_part_that_fails_to_build_takes_the_other_with_it() {
let mut files: Vec<(String, String)> = [
program("gbuffers_terrain", RED, 1.0),
program("gbuffers_terrain_solid", GREEN, 1.0),
]
.concat();
files.push((
"gbuffers_terrain_cutout.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
));
files.push((
"gbuffers_terrain_cutout.fsh".into(),
"#version 120\nvoid main(){}".into(),
));
let Some((got, undrawn)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [None; 3]);
let notes = undrawn.expect("the game draws terrain");
for note in [
"gbuffers_terrain_cutout: writes no buffer; skipped",
"gbuffers_terrain_solid: the other part of terrain can't draw",
] {
assert!(notes.iter().any(|n| n.starts_with(note)), "{notes:?}");
}
}
/// Iris 1.11.7 with our probe packs pe4u (only `gbuffers_terrain`) and pe4s (only
/// `gbuffers_terrain_solid` and `gbuffers_terrain_cutout`), each writing one alpha per screen
/// strip (#9307): solid terrain kept every fragment, alpha 0.0 included, and cutout terrain
/// kept those above 0.5 (0.51 kept; 0.5, 0.49 and lower discarded), whichever program drew it.
#[test]
fn cutout_terrain_keeps_alphas_above_0_5_and_solid_terrain_keeps_all() {
for (alpha, cutout) in [(0.0, false), (0.49, false), (0.5, false), (0.51, true)] {
let Some(got) = strips(&[("gbuffers_terrain", RED, alpha)]) else {
return;
};
let want = [Some(RED), cutout.then_some(RED), Some(RED)];
assert_eq!(got, want, "gbuffers_terrain writing alpha {alpha}");
let programs = [
("gbuffers_terrain_solid", GREEN, alpha),
("gbuffers_terrain_cutout", BLUE, alpha),
];
let Some(got) = strips(&programs) else { return };
let want = [Some(GREEN), cutout.then_some(BLUE), Some(GREEN)];
assert_eq!(got, want, "split programs writing alpha {alpha}");
}
}
/// `alphaTestRef` in the same probes: 0 for solid terrain and 0.5 for cutout terrain, through
/// `gbuffers_terrain` alone or through the split programs.
#[test]
fn alpha_test_ref_is_0_for_solid_terrain_and_0_5_for_cutout() {
let program = |name: &str| {
[
(
format!("{name}.vsh"),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
),
(
format!("{name}.fsh"),
"#version 120\nuniform float alphaTestRef;\n/* DRAWBUFFERS:0 */\n\
void main(){gl_FragData[0]=vec4(1.0,alphaTestRef*2.0,0.0,1.0);}"
.to_owned(),
),
]
};
const YELLOW: [u8; 3] = [255, 255, 0];
for names in [
&["gbuffers_terrain"][..],
&["gbuffers_terrain_solid", "gbuffers_terrain_cutout"],
] {
let files = names.iter().flat_map(|n| program(n)).collect();
let Some((got, _)) = strips_of(files, [255; 4]) else {
return;
};
assert_eq!(got, [Some(RED), Some(YELLOW), Some(RED)], "{names:?}");
}
}
@@ -0,0 +1,130 @@
//! In gbuffers programs a sampler the game gives no texture reads texture unit 0, the atlas
//! (OptiFine's shaders.txt: `gtexture` is unit 0 there), and so do `colortex0` to `colortex3`,
//! which aren't gbuffers samplers in that table. Our probe packs (#9307) read the block atlas
//! through a sampler name of their own, and through each of `colortex0` to `colortex3` and
//! `gnormal`, in gbuffers_terrain in Iris 1.11.7.
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
use super::*;
use crate::hud::HudBatch;
use wgpu::{TextureFormat as T, util::DeviceExt};
const ATLAS: [u8; 4] = [10, 200, 30, 255];
#[test]
fn unknown_samplers_and_colortex0_to_3_in_gbuffers_terrain_read_the_atlas() {
let Some(gpu) = Gpu::new() else { return };
let files = vec![
("final.vsh".to_owned(), VERTEX.to_owned()),
(
"final.fsh".to_owned(),
"#version 120\nuniform sampler2D colortex0;varying vec2 uv;\
void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}"
.to_owned(),
),
(
"gbuffers_terrain.vsh".to_owned(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".to_owned(),
),
(
"gbuffers_terrain.fsh".to_owned(),
"#version 120\nuniform sampler2D probeOwnName;\nuniform sampler2D colortex0;\n\
uniform sampler2D gdepth;\n/* DRAWBUFFERS:0 */\nvoid main(){\n\
\x20 float x = gl_FragCoord.x;\n\
\x20 gl_FragData[0] = x < 6.0 ? texture2D(probeOwnName, vec2(0.5))\n\
\x20 : x < 11.0 ? texture2D(colortex0, vec2(0.5)) : texture2D(gdepth, vec2(0.5));\n}"
.to_owned(),
),
];
let terrain = 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(),
..Default::default()
};
let inputs = horizon_shaders::GameInputs {
terrain: Some(terrain),
..Default::default()
};
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let atlas = gpu
.device
.create_texture_with_data(
&gpu.queue,
&wgpu::TextureDescriptor {
label: Some("Original one-colour atlas"),
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: T::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&ATLAS.repeat(4),
)
.create_view(&Default::default());
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
let mut post = gpu.load(
files,
World {
atlas: Some((atlas, [2, 2])),
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
assert!(post.has_terrain(), "{:?}", post.notes);
let corners = [[-1.0f32, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
let vertices: Vec<f32> = [0, 1, 2, 0, 2, 3]
.into_iter()
.flat_map(|c| [corners[c][0], corners[c][1], 0.0])
.collect();
let buffer = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original full-screen quad"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::Solid {
rp.set_vertex_buffer(0, buffer.slice(..));
rp.draw(0..6, 0..1);
}
});
// Columns 0-5 read the pack's own name, 6-10 colortex0 (which the program also writes),
// 11-15 gdepth (colortex1).
for (i, p) in pixels.iter().enumerate() {
assert_eq!(*p, ATLAS, "column {}", i % SIZE[0] as usize);
}
}
@@ -0,0 +1,277 @@
//! Original synthetic weather oracle: no pack assets or external shader algorithms.
use super::*;
use std::io::{Cursor, Write};
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() {
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());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let mut depth_failures = Vec::new();
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),
] {
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]] {
post.resize(&device, size);
let mut encoder = device.create_command_encoder(&Default::default());
let targets = post.targets.as_ref().unwrap();
let color = &targets.color[0].as_ref().unwrap()[0];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[attach(
color,
wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
store: wgpu::StoreOp::Store,
},
)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &targets.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.5),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
post.set_weather(Some(geometry(&device)));
post.draw_weather(&device, &queue, &mut encoder, size);
assert!(post.weather_draw.is_none(), "draw consumes this frame only");
assert!(post.has_weather(), "pipelines survive the draw");
// A dry frame must neither redraw old quads nor discard the pipelines.
post.set_weather(None);
post.draw_weather(&device, &queue, &mut encoder, size);
assert!(post.has_weather());
let targets = post.targets.as_ref().unwrap();
let color = targets.color[0].as_ref().unwrap()[0].texture();
let colors = readback(&device, &mut encoder, color);
let depths = readback(&device, &mut encoder, &targets.depth_texture);
queue.submit([encoder.finish()]);
for buffer in [&colors, &depths] {
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let colors = colors.slice(..).get_mapped_range().unwrap();
let depths = depths.slice(..).get_mapped_range().unwrap();
for y in 0..size[1] as usize {
for x in 0..size[0] as usize {
let quad = x / (size[0] as usize / 6);
let visible = quad.is_multiple_of(3);
// First quad is in front; second is behind scene depth; third discards.
let expected_color = if visible && quad < 3 {
[128, 0, 127, 255]
} else if visible {
[0, 128, 127, 255]
} else {
[0, 0, 255, 255]
};
let offset = y * 256 + x * 4;
let actual_color = &colors[offset..offset + 4];
for (actual, expected) in actual_color.iter().zip(expected_color) {
assert!(
i16::from(*actual).abs_diff(expected) <= 1,
"{program} {property:?} size={size:?} ({x},{y}): color {actual_color:?}"
);
}
let depth =
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
if depth != expected_depth {
depth_failures.push(format!(
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
));
}
}
}
eprintln!(
"Weather oracle {program} {property:?} {size:?}: color, occlusion and discard pass; cumulative depth mismatches={}",
depth_failures.len()
);
}
}
}
assert!(depth_failures.is_empty(), "{}", depth_failures.join("\n"));
}
fn pack(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
program: &str,
property: &str,
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (extension, source) in [
(
"vsh",
"#version 120\nvarying vec4 color; varying float marker; void main(){gl_Position=gl_Vertex;color=gl_Color;marker=gl_MultiTexCoord0.x;}",
),
(
"fsh",
"#version 120\nvarying vec4 color; varying float marker; /* DRAWBUFFERS:0 */ void main(){if(marker>1.5) discard;gl_FragData[0]=color;}",
),
] {
zip.start_file(
format!("shaders/{program}.{extension}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-weather-depth",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse(property, &macros);
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
weather: Some(super::super::pack_weather::inputs()),
..Default::default()
},
);
assert!(post.notes.is_empty(), "{:?}", post.notes);
assert_eq!(post.weather.as_ref().unwrap().name, program);
let lightmap = 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,
view_formats: &[],
})
.create_view(&Default::default());
let mut post = PackPost::new(
device,
queue,
adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap,
terrain: None,
block_ids: &[],
sprite_rects: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: true,
hand: false,
},
)
.expect("weather pack runs");
assert!(
post.has_weather(),
"production weather pipelines must compile: {:?}",
post.notes
);
post.copied |= 1; // Keep colortex0 readable for the independent color oracle.
post
}
fn geometry(device: &wgpu::Device) -> Weather {
let mut vertices = Vec::new();
let mut indices = Vec::new();
for quad in 0..6u32 {
let left = quad as f32 / 3.0 - 1.0;
let right = (quad + 1) as f32 / 3.0 - 1.0;
let z = if quad % 3 == 1 { 0.5 } else { -0.5 };
for pos in [
[left, -1.0, z],
[right, -1.0, z],
[right, 1.0, z],
[left, 1.0, z],
] {
vertices.push(super::super::Vertex {
pos,
uv: [(quad % 3) as f32, 0.0],
layer: 0,
color: u32::from_le_bytes(if quad < 3 {
[255, 0, 0, 128]
} else {
[0, 255, 0, 128]
}),
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
});
}
indices.extend([0, 1, 2, 0, 2, 3].map(|i| quad * 4 + i));
}
let buffer = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
Weather {
vertices: buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX),
indices: buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX),
quads: [0..3, 3..6],
sprites: [None, None],
generation: 1,
}
}
fn readback(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> wgpu::Buffer {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(256 * texture.height()),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
aspect: if texture.format().is_depth_stencil_format() {
wgpu::TextureAspect::DepthOnly
} else {
wgpu::TextureAspect::All
},
..texture.as_image_copy()
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
buffer
}
@@ -43,6 +43,64 @@ fn a_pack_without_final_reads_the_replaced_world_colour() {
replacement(Change::Colour, false);
}
/// A Resolution Scale leaves the pack's picture smaller than the window. Its `final` draws at
/// the picture's size and one copy upsizes it to the window (#9291), so a final that fetches
/// its pixel by `gl_FragCoord` (as Photon's does) covers every pixel, not a quarter of them.
#[test]
fn a_scaled_picture_fills_the_whole_window() {
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(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
})) else {
return;
};
eprintln!("scaled picture adapter: {:?}", adapter.get_info());
let picture = [4, 2];
let window = [8, 4];
let colour = texture(&device, picture, T::Rgba8Unorm);
let depth = texture(&device, picture, T::Depth32Float);
clear(&device, &queue, &colour, &depth, [32, 64, 16, 255], 0.25);
let mut post = pack_with(
&device,
&queue,
&adapter,
&colour,
&[
(
"final.vsh",
"#version 120\nvoid main(){gl_Position=ftransform();}",
),
(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex0;\nvoid main(){gl_FragColor=texelFetch(colortex0,ivec2(gl_FragCoord.xy),0);}",
),
],
);
let hud = HudBatch::new(window);
let pixels = render(
&device,
&queue,
&mut post,
&colour,
&depth,
&frame(&hud),
picture,
window,
);
// Independently specified: the world's colour at every window pixel.
let wrong = pixels.iter().filter(|p| **p != [32, 64, 16, 255]).count();
assert_eq!(
wrong,
0,
"{wrong} of {} pixels are not the picture",
pixels.len()
);
}
fn replacement(change: Change, final_shader: bool) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
@@ -62,7 +120,9 @@ fn replacement(change: Change, final_shader: bool) {
let mut post = pack(&device, &queue, &adapter, &colour, final_shader);
let hud = HudBatch::new(size);
let frame = frame(&hud);
let initial = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let initial = render(
&device, &queue, &mut post, &colour, &depth, &frame, size, size,
);
let expected = if final_shader {
[32, 64, 191, 255]
} else {
@@ -93,9 +153,13 @@ fn replacement(change: Change, final_shader: bool) {
0.75
};
clear(&device, &queue, &colour, &depth, pixel, reverse_depth);
let actual = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let actual = render(
&device, &queue, &mut post, &colour, &depth, &frame, size, size,
);
let mut fresh = pack(&device, &queue, &adapter, &colour, final_shader);
let reference = render(&device, &queue, &mut fresh, &colour, &depth, &frame, size);
let reference = render(
&device, &queue, &mut fresh, &colour, &depth, &frame, size, size,
);
// Independently specified RG channels and GL depth (1 - reverse-Z), in UNORM8.
let expected = if !final_shader {
[160, 96, 48, 255]
@@ -178,13 +242,8 @@ fn pack(
lightmap: &wgpu::TextureView,
final_shader: bool,
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
if final_shader {
for (name, source) in [
let programs: &[(&str, &str)] = if final_shader {
&[
(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}",
@@ -193,10 +252,29 @@ fn pack(
"final.fsh",
"#version 120\nvarying vec2 uv;uniform sampler2D colortex0;uniform sampler2D depthtex0;\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rg,texture2D(depthtex0,uv).r,1.0);}",
),
] {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
]
} else {
&[]
};
pack_with(device, queue, adapter, lightmap, programs)
}
/// A pack of `programs` (`name`, GLSL source) over `lightmap`.
fn pack_with(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
lightmap: &wgpu::TextureView,
programs: &[(&str, &str)],
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
for (name, source) in programs {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-world-inputs",
@@ -241,12 +319,13 @@ fn render(
depth: &wgpu::TextureView,
frame: &Frame,
size: [u32; 2],
window: [u32; 2],
) -> Vec<[u8; 4]> {
let screen = texture(device, size, T::Rgba8Unorm);
let screen = texture(device, window, T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(stride * size[1]),
size: u64::from(stride * window[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
@@ -261,7 +340,7 @@ fn render(
frame,
&Default::default(),
size,
size,
window,
&[],
&mut |_, _, _, _| {},
);
@@ -285,7 +364,7 @@ fn render(
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..size[0] as usize * 4]
row[..window[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
@@ -0,0 +1,56 @@
//! A built-in uniform declared with another GLSL type than the one Iris 1.11.7 gives is disabled
//! there ("Wrong uniform type ... Disabling that uniform") and reads 0; our probe packs (#9307)
//! read 0 for each such declaration. Declared with its own type, it keeps its value.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv = gl_MultiTexCoord0.xy; gl_Position = ftransform(); }\n";
/// composite declares two floats as `int` and `vec2` and an int as `float`, and writes what it
/// reads into colortex1; final declares the same names with their own types and adds them.
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
add("final.vsh", VERTEX);
add(
"composite.fsh",
"#version 130\nuniform int viewWidth;\nuniform vec2 viewHeight;\nuniform float fogShape;\n\
void main() {\n/* DRAWBUFFERS:1 */\n\
\x20 gl_FragData[0] = vec4(float(viewWidth), viewHeight.x, fogShape + 1.0, 255.0) / 255.0;\n}\n",
);
add(
"final.fsh",
"#version 130\nuniform sampler2D colortex1;\nuniform float viewWidth;\nuniform int fogShape;\n\
varying vec2 uv;\nvoid main() {\n\
\x20 vec4 c = texture(colortex1, uv);\n\
\x20 gl_FragColor = vec4(c.r, c.g + viewWidth * 4.0 / 255.0, c.b + float(fogShape + 2) * 10.0 / 255.0, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-wrong-uniform-types",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
#[test]
fn built_ins_declared_with_another_type_read_zero() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
// 8 pixels wide; post programs read `fogShape` -1. composite reads 0, 0 and 0 (+1 = 1);
// final reads 8 (green 32) and -1 (+2 = 1, blue 10).
for p in gpu.render(&mut post, [8, 8]) {
assert_eq!(p, [0, 32, 11, 255]);
}
}
@@ -35,14 +35,8 @@ pub(super) struct ShadowTargets {
pub depth: (wgpu::Texture, wgpu::TextureView),
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `shadowtex0`/`1` as a plain sampler filters them: Iris 1.11.7 reads them linearly unless
/// a pack asks for the nearest texel (measured). wgpu filters a depth format only for a
/// comparison, so these are `R32Float` copies of the final depths, when read that way.
pub linear: [Option<wgpu::TextureView>; 2],
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
/// and their stride in bytes.
staging: Option<(wgpu::Buffer, u32)>,
/// Both sides of each shadowcolor buffer used.
/// Both sides of each shadowcolor buffer used; one texture for both when no `shadowcomp`
/// program draws into or flips the buffer.
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
/// The clear colour of each buffer cleared every frame.
pub clears: [Option<[f32; 4]>; SHADOW_COLORS],
@@ -84,15 +78,15 @@ fn target(
impl ShadowTargets {
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
/// sampler). The resolution is kept within the device's limit.
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
depth1: bool,
linear: u8,
) -> Self {
let size = shadow
.resolution
@@ -101,8 +95,8 @@ impl ShadowTargets {
let depth = wgpu::TextureFormat::Depth32Float;
let color = std::array::from_fn(|n| {
(colors & 1 << n != 0).then(|| {
[0, 1].map(|side| {
let label = format!("Shader pack shadowcolor{n} {}", ["A", "B"][side]);
let side = |side: &str| {
let label = format!("Shader pack shadowcolor{n} {side}");
target(
device,
&label,
@@ -111,46 +105,20 @@ impl ShadowTargets {
wgpu::TextureUsages::empty(),
)
.1
})
};
let a = side("A");
let b = if doubled & 1 << n != 0 {
side("B")
} else {
a.clone()
};
[a, b]
})
});
let linear = [0, 1].map(|n| {
(linear & 1 << n != 0).then(|| {
let label = format!("Shader pack shadowtex{n} (filtered)");
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(&label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
.create_view(&Default::default())
})
});
let staging = linear.iter().any(Option::is_some).then(|| {
let stride = (size * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack shadow depth rows"),
size: u64::from(stride) * u64::from(size),
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
(buffer, stride)
});
Self {
size,
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
linear,
staging,
color,
clears: std::array::from_fn(|n| {
let b = &shadow.colors[n];
@@ -256,38 +224,4 @@ impl ShadowTargets {
);
}
}
/// `linear`: the filtered copies of the depths the shadow pass left (`shadowtex1` from its
/// copy before translucent geometry).
pub fn copy_linear(&self, encoder: &mut wgpu::CommandEncoder) {
let Some((buffer, stride)) = &self.staging else {
return;
};
for (n, linear) in self.linear.iter().enumerate() {
let Some(linear) = linear else {
continue;
};
let depth = match (n, &self.depth1) {
(1, Some(d)) => &d.0,
_ => &self.depth.0,
};
let rows = wgpu::TexelCopyBufferInfo {
buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(*stride),
rows_per_image: None,
},
};
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
aspect: wgpu::TextureAspect::DepthOnly,
..depth.as_image_copy()
},
rows,
depth.size(),
);
encoder.copy_buffer_to_texture(rows, linear.texture().as_image_copy(), depth.size());
}
}
}
@@ -94,10 +94,12 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaColor".into(), "hz_Color".into()),
("vaNormal".into(), "hz_Normal".into()),
],
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
after: " gl_Position.z = gl_Position.w;\n".into(),
// Tested at the far plane: the sky shows only where nothing else drew, and it writes no
// depth. Its fragments read their own depth, as in Iris.
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: true,
}
}
@@ -121,9 +123,10 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
("vaColor".into(), "hz_Color".into()),
("vaNormal".into(), "hz_Normal".into()),
],
after: " gl_Position.z = gl_Position.w;\n".into(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
far_depth: true,
}
}
@@ -153,6 +156,38 @@ fn disc(y: f32, kind: u32, out: &mut Vec<Vertex>) {
}
}
/// Cover the horizon and downward sky rays as well as the upper vanilla disc.
/// Uncovered directions retain the buffer's clear colour, affecting later sky/fog
/// compositing. Enclosing the camera gives the sky program fragments there too.
/// Depth stays at the far plane through `inputs`, so terrain remains in front.
fn horizon(out: &mut Vec<Vertex>) {
let corners = [
[-512.0, -512.0, -512.0],
[512.0, -512.0, -512.0],
[512.0, 512.0, -512.0],
[-512.0, 512.0, -512.0],
[-512.0, -512.0, 512.0],
[512.0, -512.0, 512.0],
[512.0, 512.0, 512.0],
[-512.0, 512.0, 512.0],
];
for [a, b, c, d] in [
[0, 1, 2, 3],
[4, 7, 6, 5],
[0, 4, 5, 1],
[3, 2, 6, 7],
[0, 3, 7, 4],
[1, 5, 6, 2],
] {
for i in [a, b, c, a, c, d] {
out.push(Vertex {
pos: corners[i],
kind: SKY,
});
}
}
}
pub(super) struct PackSky {
vertices: wgpu::Buffer,
celestial: wgpu::Buffer,
@@ -166,6 +201,7 @@ pub(super) struct PackSky {
impl PackSky {
pub fn new(device: &wgpu::Device) -> Self {
let mut v = Vec::new();
horizon(&mut v);
disc(16.0, SKY, &mut v);
let sky = 0..v.len() as u32;
let apex = Vertex {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,218 @@
//! Original GPU probe of a pack's `ftransform()`, in gbuffers and shadow programs.
use wgpu::util::DeviceExt;
/// Iris 1.11.7's `ftransform()` read back with a probe pack of ours (#8464) has the bits of
/// `gl_ProjectionMatrix * (gl_ModelViewMatrix * gl_Vertex)` at every vertex our probes read: the
/// model-view matrix first, then the projection, not their product matrix. These matrices tell
/// the two apart (one rounding against two): the model-view matrix takes 16777 from x, so the
/// vertex at 16777.25 comes out at exactly 0.25, and a third of that is one rounding; the product
/// matrix holds a third of 16777 rounded to a float, which leaves an error near 1e-4 after the
/// subtraction.
#[test]
fn ftransform_applies_the_model_view_matrix_before_the_projection() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let third = 1.0f32 / 3.0;
let model_view = glam::Mat4::from_translation(glam::Vec3::new(-16777.0, 2.0, 0.0));
let projection = glam::Mat4::from_scale(glam::Vec3::new(third, 0.5, 1.0));
let vertices = [[16777.25f32, 1.0, -3.0], [16778.0, -2.0, 5.0]];
// `0.25 * third` and `1.0 * third` are exact products: one rounding of each.
let expected = [[0.25 * third, 1.5, -3.0, 1.0], [third, 0.0, 5.0, 1.0]];
let mut failures = Vec::new();
for shadow in [false, true] {
let got = ftransform(&device, &queue, model_view, projection, &vertices, shadow);
for ((v, want), got) in vertices.iter().zip(&expected).zip(&got) {
if got.map(f32::to_bits) != want.map(f32::to_bits) {
failures.push(format!("{v:?}, shadow={shadow}: {got:?} != {want:?}"));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// `ftransform()` of each of `vertices` in a gbuffers program (or a shadow program, with the
/// matrices as the shadow camera's), read back from a point per vertex as float bits.
fn ftransform(
device: &wgpu::Device,
queue: &wgpu::Queue,
model_view: glam::Mat4,
projection: glam::Mat4,
vertices: &[[f32; 3]],
shadow: bool,
) -> Vec<[f32; 4]> {
let pp = |code: &str| horizon_shaders::Preprocessed {
code: code.to_owned(),
..Default::default()
};
let vertex = pp(&format!(
"varying vec4 probe;\nvoid main() {{\n probe = ftransform();\n \
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2.0 / vertices.len() as f32,
));
// An integer target keeps the float bits on any adapter.
let fragment = pp(
"varying vec4 probe;\nlayout(location = 0) out uvec4 bits;\n\
void main() { bits = floatBitsToUint(probe); }",
);
let inputs = horizon_shaders::GbuffersInputs {
declarations: "layout(location = 0) in vec3 hz_v_pos;".into(),
body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n hz_Color = vec4(1.0);\n".into(),
..Default::default()
};
let program = if shadow {
horizon_shaders::translate_shadow(&vertex, &fragment, &inputs)
} else {
horizon_shaders::translate_gbuffers(
&vertex,
&fragment,
horizon_shaders::Target::Buffers,
&inputs,
)
}
.unwrap();
let prefix = if shadow { "shadow" } else { "gbuffer" };
let mut block = program.interface.initial_values.clone();
block.resize(program.interface.uniform_size.max(16) as usize, 0);
for (name, matrix) in [("ModelView", model_view), ("Projection", projection)] {
let name = format!("{prefix}{name}");
let at = (program.interface.uniforms.iter())
.find(|u| u.name == name)
.unwrap_or_else(|| panic!("no {name}"))
.offset as usize;
block[at..at + 64].copy_from_slice(bytemuck::cast_slice(&matrix.to_cols_array()));
}
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &block,
usage: wgpu::BufferUsages::UNIFORM,
});
let data = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
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 group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&group_layout)],
..Default::default()
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex);
let fs = module(program.fragment);
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::PointList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Uint,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: vertices.len() as u32,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Uint,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = output.create_view(&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(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_vertex_buffer(0, data.slice(..));
pass.draw(0..vertices.len() as u32, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout::default(),
},
output.size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
bytemuck::cast_slice::<u8, [f32; 4]>(&mapped).to_vec()
}
@@ -45,6 +45,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
reference: 0.1,
}),
item_id_declared: false,
far_depth: false,
}
}
@@ -475,7 +475,8 @@ impl<T> Resident<T> {
}
let oy = f64::from(self.min_y + y * 16) - camera.y;
let min = Vec3::new(min_x, oy as f32, min_z);
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
let bounds = self.bounds[slot as usize].filter(|_| frustum.tight());
let (bmin, bmax) = section_box(min, bounds);
if !frustum.intersects(bmin, bmax) {
continue;
}
@@ -319,7 +319,7 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude()
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
@@ -43,9 +43,7 @@ struct TaaUniform {
jitter: [f32; 4],
}
/// TAA's GPU resources, absent when the device cannot compile the shader
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
/// from depth textures).
/// TAA's GPU resources, absent when the device cannot compile the shader.
struct TaaResources {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
@@ -146,9 +144,6 @@ impl Post {
cache: None,
})
};
// The OpenGL backend's GLSL translator does not support textureLoad
// from depth textures: on devices running through OpenGL (such as older
// Intel GPUs on Linux), TAA cannot compile its pipeline.
let taa_resources = {
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon TAA bindings"),
@@ -161,7 +156,9 @@ impl Post {
count: None,
},
texture(2, float),
texture(3, wgpu::TextureSampleType::Depth),
// A depth view through an unfilterable float binding: the
// texel is the same, and the GLSL backend can read it.
texture(3, wgpu::TextureSampleType::Float { filterable: false }),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
@@ -0,0 +1,208 @@
//! NORMAL world text: alpha blended, back-face culled, ordinary depth writes.
pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Normal world text"),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_entity"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn world_text_blends_culls_and_obeys_world_depth() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
// Use the production pipeline/vertex layout with a pass-through shader
// to isolate its actual depth, winding and blend behavior from lighting.
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
r#"
struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
return Out(vec4(pos,1.0),color);
}
@fragment fn fs_entity(v:Out)->@location(0) vec4<f32> { return v.color; }
"#
.into(),
),
});
let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm);
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let mut vertices = Vec::new();
let mut indices = Vec::new();
let mut quad = |pixel: u32, z: f32, color: [u8; 4], back: bool| {
let x = -1.0 + pixel as f32 * 0.5;
let first = vertices.len() as u32;
for pos in [
[x, -1.0, z],
[x + 0.5, -1.0, z],
[x + 0.5, 1.0, z],
[x, 1.0, z],
] {
vertices.push(super::super::Vertex {
pos,
uv: [0.0; 2],
color: u32::from_le_bytes(color),
layer: 0,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
});
}
for i in if back {
[0, 2, 1, 0, 3, 2]
} else {
[0, 1, 2, 0, 2, 3]
} {
indices.push(first + i);
}
};
quad(0, 0.6, [255, 0, 0, 255], false);
// A hair behind: the entity model pipeline's 128-ulp depth bias
// would incorrectly put the green quad in front of the red surface.
quad(0, 0.599_999, [0, 255, 0, 255], false);
quad(1, 0.8, [0, 255, 0, 255], false);
quad(1, 0.7, [255, 0, 0, 255], false);
quad(2, 0.5, [255, 0, 0, 128], false);
quad(3, 0.5, [255, 255, 0, 255], true);
let buffer = |data: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: data,
usage,
})
};
let vertex = buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX);
let index = buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX);
let readback = 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 color = color.create_view(&Default::default());
let depth = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_vertex_buffer(0, vertex.slice(..));
pass.set_index_buffer(index.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
color.size(),
);
queue.submit([encoder.finish()]);
let (tx, rx) = std::sync::mpsc::channel();
readback.map_async(wgpu::MapMode::Read, .., move |result| {
tx.send(result).unwrap()
});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
rx.recv().unwrap().unwrap();
let pixels = readback.get_mapped_range(..).unwrap();
assert_eq!(
&pixels[..16],
&[
255, 0, 0, 255, 0, 255, 0, 255, 128, 0, 127, 255, 0, 0, 255, 255
]
);
}
}
+82 -1
View File
@@ -532,6 +532,67 @@ pub fn smooth_gradients_offered() -> bool {
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// Whether GPU Culling (`Graphics::gpu_culling`) can be switched on here, and why not: set when a
/// renderer starts, and again when its pass can't be set up or its buffers stop fitting in video
/// memory. Where it isn't offered the settings grey its switch out with the reason, and the saved
/// value waits for a renderer that offers it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GpuCullingOffer {
/// No count draws the GPU wrote, compute shaders or working first instances (OpenGL, Metal,
/// Vulkan without `VK_KHR_draw_indirect_count`, Intel Gen11/Gen12's Vulkan driver), vanilla
/// frames drawn flat, or a pass the driver couldn't build.
Unsupported = 0,
/// A software adapter (WARP, lavapipe): the pass would run on the CPU, beside the frame.
Software = 1,
/// Its buffers stopped fitting in video memory: off until the next renderer.
OutOfMemory = 2,
/// DX12 with flat page groups, whose count draws leave each draw's base vertex out of
/// what the near sections' packed faces are read by (`gpu_culling::loses_base_vertex`).
Dx12 = 3,
/// The graphics device was lost while its frames were drawn (`lose_gpu_culling`): off
/// for the renderers after it, until Horizon restarts.
DeviceLost = 5,
Offered = 4,
}
impl GpuCullingOffer {
/// The greyed switch's tooltip; `None` where it is offered.
pub fn reason(self) -> Option<&'static str> {
match self {
Self::Unsupported => Some("Your GPU or graphics API doesn't support it."),
Self::Software => Some("Not with software rendering, which runs it on the CPU."),
Self::OutOfMemory => Some("Out of video memory: off until the next start."),
Self::Dx12 => Some("Not on DirectX 12 yet: try the Vulkan graphics API."),
Self::DeviceLost => Some("The graphics device was lost: off until the next start."),
Self::Offered => None,
}
}
}
static GPU_CULLING_OFFER: std::sync::atomic::AtomicU8 =
std::sync::atomic::AtomicU8::new(GpuCullingOffer::Unsupported as u8);
pub fn offer_gpu_culling(offer: GpuCullingOffer) {
GPU_CULLING_OFFER.store(offer as u8, std::sync::atomic::Ordering::Relaxed);
}
pub fn gpu_culling_offer() -> GpuCullingOffer {
match GPU_CULLING_OFFER.load(std::sync::atomic::Ordering::Relaxed) {
1 => GpuCullingOffer::Software,
2 => GpuCullingOffer::OutOfMemory,
3 => GpuCullingOffer::Dx12,
4 => GpuCullingOffer::Offered,
5 => GpuCullingOffer::DeviceLost,
_ => GpuCullingOffer::Unsupported,
}
}
/// Set once the graphics device was lost while GPU culling drew the frames (the game's
/// `recover_device`): the renderers made after it don't offer it (`GpuCullingOffer::DeviceLost`),
/// so a fault in its frames can't lose the device again after every recovery.
static GPU_CULLING_LOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn lose_gpu_culling() {
GPU_CULLING_LOST.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn gpu_culling_lost() -> bool {
GPU_CULLING_LOST.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -608,8 +669,15 @@ pub struct Graphics {
pub blue_map: crate::bluemap::Settings,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Near solid terrain culled, and its draws listed, on the GPU (`gfx::gpu_culling`), where it
/// is offered (`gpu_culling_offer`). Off by default (GPU Culling, Performance graphics); it
/// applies at once, and no preset sets it. Every save writes it, so a default turned on later
/// needs a new key, as `batchTranslucent` did.
pub gpu_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Minimum entity population for coarse AABB culling; zero disables it.
pub entity_culling_precheck: u32,
/// Skip particles outside the view or hidden behind opaque terrain.
pub particle_culling: ParticleCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
@@ -716,7 +784,9 @@ impl Default for Graphics {
far_detail: FarDetail::Full,
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
gpu_culling: false,
entity_culling: EntityCullingMode::Off,
entity_culling_precheck: 64,
particle_culling: ParticleCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
cache_shulker_boxes: false,
@@ -812,7 +882,11 @@ impl Graphics {
}
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"gpuCulling" => g.gpu_culling = flag(g.gpu_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"entityCullingPrecheck" => {
g.entity_culling_precheck = number(g.entity_culling_precheck).min(512)
}
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"blockEntityCache" => {
@@ -906,12 +980,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.entity_culling_precheck,
self.particle_culling.name(),
self.leaf_culling.name(),
bool_text(self.cache_shulker_boxes),
@@ -959,6 +1034,7 @@ impl Graphics {
self.shader_pack,
bool_text(self.translucent_batching),
bool_text(self.hdr_smooth_gradients),
bool_text(self.gpu_culling),
);
text += &self.blue_map.serialize();
text
@@ -1097,7 +1173,9 @@ mod tests {
servers: Vec::new(),
},
cave_culling: false,
gpu_culling: true,
entity_culling: EntityCullingMode::Synchronous,
entity_culling_precheck: 128,
particle_culling: ParticleCullingMode::Asynchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
cache_shulker_boxes: true,
@@ -1183,6 +1261,9 @@ mod tests {
assert!(Graphics::parse("hdr:true\n").wide_frame());
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// GPU Culling is opt-in: only the file or its switch turns it on.
assert!(!Graphics::default().gpu_culling);
assert!(Graphics::parse("gpuCulling:true\n").gpu_culling);
// 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);
File diff suppressed because it is too large. Load diff
Loaded 100 of 814 files, more files were not shown because too many files have changed in this diff. Show more