Compare commits

...
Author SHA1 Message Date
Ludovic Marie 9fb6e4f20c Merge pull request #9235 from horizon-rust/feat/launcher-paddle-claim
🤖 Agent: launcher-only. The claim opens a checkout URL only on Horizon's own /shop/pay page (prefix allowlist, tested), and the 3 s poll stops after 20 min or once the set is claimed; without a checkout in the answer the one-click claim is unchanged.
2026-11-04 15:23:36 +01:00
Ludovic Marie 7dfbd05ac3 Merge pull request #9110 from DenisHumen/fix/offhand-tempt
🤖 Agent: TemptGoal.shouldFollow and TemptingSensor test both hands; player_view now fills off_hand, and lan_guest_views swaps each guest's off hand in through the same function, so single player on the server thread gets it too.
2026-11-04 15:23:31 +01:00
Ludovic Marie 4ece2302d4 Merge pull request #9103 from DenisHumen/fix/unicode-font-sign-stretch
🤖 Agent: default ascii.png glyphs keep their 0..8 box, so sign text without Force Unicode draws exactly as before; only Unifont's half-scale glyphs change width.
2026-11-04 15:23:27 +01:00
Ludovic Marie 8453750c94 Merge pull request #8936 from Cheezer1656/upstream/native-send-chat
🤖 Agent: the message is trimmed and the Chat option (Commands Only, Hidden) applied as the chat screen does; the test needs prepared resources and is #[ignore]d, so CI does not run it.
2026-11-04 15:23:23 +01:00
Ludovic Marie 2f29699b0a Merge pull request #8938 from Cheezer1656/upstream/client-chat-received
🤖 Agent: Only Show Secure Chat now drops a line before mods hear it (ChatListener.showMessageToPlayer's order, then onClientPlayerChat), and chat_received returns at once with no mods loaded, so the chat-type registry lookup costs nothing without them.
2026-11-04 15:23:18 +01:00
Ludovic Marie 0073f3bb30 Merge pull request #8872 from Delete228/create-named-damage
🤖 Agent: controlled particles are dropped on a resource reload so a stale sprite-set index cannot pick another set, and the only cost for vanilla particles is one more match arm in tick and in the translucent test.
2026-11-04 15:23:14 +01:00
Ludovic Marie 31fac68df4 Merge pull request #8822 from AfrobamaYT/camera-pose-hook
🤖 Agent: one dispatch per frame, each non-finite value falls back to vanilla's on its own, and posed_rotation is only set when the pose differs, so billboards keep the vanilla rotation path when no mod moves the camera.
2026-11-04 15:23:09 +01:00
Ludovic Marie 07a1a4af76 Merge pull request #8698 from ErikKalikas/mod-menu-extra
🤖 Agent: the 32 KiB cap is enforced on both encode and decode, a vanilla client ignores the unknown horizon:menu_extra channel, and the lan_bot test pins that the payload arrives right before its own window's OpenScreen.
2026-11-04 15:23:04 +01:00
Ludovic Marie 1077a75e5f Merge pull request #8636 from GabrielNichols/codex/port-pack-harness-verdict
🤖 Agent: an explicit passed:false or a result with zero executed checks now stays Partial, and a legacy count-only result with passes still reads harness green, as carried_harness_keeps_current_and_legacy_successes pins.
2026-11-04 15:22:59 +01:00
Ludovic Marie c77c40b021 Merge pull request #8512 from hildlabs/shader-option-sliders
🤖 Agent: a drag saves the option and rebuilds the pack once, on release, not on every pointer move; the manifest already refuses min >= max and step <= 0, so slider_position and slider_value cannot divide by zero.
2026-11-04 15:22:54 +01:00
Ludovic Marie 12336db449 Merge pull request #8438 from ErikKalikas/mod-reload-listeners
🤖 Agent: the pack stack is rebuilt only when a mod registered a listener, so a /reload without native mods pays nothing, and folder_ok refuses '..' and absolute paths, so a listener cannot read outside data/<namespace>/.
2026-11-04 15:22:49 +01:00
Ludovic Marie f010091162 Merge pull request #7226 from Corbonat/fix/gui-glint-atlas-5851
🤖 Agent: GUI glint now takes vanilla's ITEMS-atlas rectangles (Stitcher's order, split and filtering padding reproduced) while CIT glint keeps sprite-local UVs; the cost is one more decode of the item textures per pack load in prepare_glint_atlas, nothing per frame.
2026-11-04 15:22:45 +01:00
Ludovic Marie c42b5b17f0 Merge pull request #9187 from Domanffe/chore/stamp-native-hooks-399
🤖 Agent: re-running tools/stamp_native_hooks.py on this PR's base (38df83828) reproduces the tree byte for byte, and main has not touched a fragment since; the native-hook-stamp workflow that should have opened this PR has failed its last five runs, which the orchestrator will look at.
2026-11-04 15:22:41 +01:00
Ludovic MarieandClaude Opus 5.5 ab3eceeeb9 Launcher Shop: the founder claim opens Paddle's free checkout; purchase history
- When the cosmetics service answers a claim with a checkout (the 0 CHF Paddle checkout
  Ludovic chose for founder claims), the launcher opens it in the browser, but only
  Horizon's own checkout page (https://playhorizon.dev/shop/pay?…). It then asks the
  service every 3 s, for 20 minutes at most, until Paddle's webhook has granted the set.
  The button says "Waiting for the checkout…" and a link opens the checkout again.
- The Wardrobe tab's claim button takes the same path.
- "Your purchases" under the founder panel: date, item, price (Free at 0), state (Paid,
  Partly refunded, Refunded, Charged back) and the order number from Paddle's receipt,
  for Mojang's purchase-history rule.
- The claim note no longer promises "no card asked" (unconfirmed at 0 CHF).
- 11 new strings in all 86 tables; the dropped one removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:20:33 +01:00
Ludovic Marie 14868bea32 Merge pull request #8575 from MSedgeMC/fix/xp-orb-light
🤖 Agent: matches ExperienceOrbRenderer: getBlockLightLevel is block+7 clamped with sky untouched (was a floor of 10 on both), the quad spans (-0.5,-0.25)..(0.5,0.75) at 0.3, and light is read at the eye (0.425).
2026-11-04 15:19:56 +01:00
Ludovic Marie 7950c14bba Merge pull request #8947 from NikitaMGrimm/offscreen-hdr
🤖 Agent: without HORIZON_HDR_OUTPUT, offscreen_output() is None and every new branch is skipped; the crash marker is only written with a surface, so an offscreen HDR run cannot turn HDR off for the next windowed start.
2026-11-04 15:18:06 +01:00
Ludovic Marie 2c90c298c1 Merge pull request #8543 from NerfPhaselock/perf/gpu-culling-g1b
🤖 Agent: inert until gpu-culling is switched on (only control.rs and scene_replay do), and the fade-end heap means a frame with no fade due does no extra work; martipops' read found every fade start sets the flag before prepare.
2026-11-04 15:16:22 +01:00
Ludovic Marie 5dc270d3f9 Merge pull request #8817 from itarqos5/test/lod-bench-span-histogram
🤖 Agent: example-only; the last histogram bucket (32) is printed as 32+ so a capped count is not misread as exact.
2026-11-04 15:15:08 +01:00
Ludovic Marie 482d31394c Merge pull request #8766 from NikitaMGrimm/translucent-batching-checkbox
🤖 Agent: switch() now ANDs translucent_batching_offered() exactly as hdr_smooth_gradients already does, and the saved preference is untouched, so a device that can batch gets it back.
2026-11-04 15:15:03 +01:00
Ludovic Marie 3a2bc05ae2 Merge pull request #8738 from NikitaMGrimm/lan-close-test-windows
🤖 Agent: the loop now asserts the outbox really holds bytes (pending > 0 three times 50 ms apart) before sending Disconnect, which the old 64 MiB burst only assumed; the 256-MiB cap fails loudly instead of hanging.
2026-11-04 15:14:58 +01:00
Ludovic Marie 81d1cf35d1 Merge pull request #8671 from Qwerner88880/pr-ready-beside
🤖 Agent: only Public tree and Python tests move beside cargo, and every Python test that names cargo mocks it, so they never fight cargo for the target dir; Parity stays after cargo since it builds.
2026-11-04 15:14:54 +01:00
Ludovic Marie e1597349dd Merge pull request #8711 from NikitaMGrimm/install-sh-test-skip-windows
🤖 Agent: skipIf(os.name == "nt") sits above the bash/openssl skip, so Windows never reaches System32's WSL bash (#5383), and CI's Linux job still runs all signature cases.
2026-11-04 15:14:49 +01:00
Ludovic Marie 6d43a3593b Merge pull request #9212 from horizon-rust/fix/8669-useitemon-freeze
🤖 Agent: The freeze came from map searches opened from chests, which built full jigsaw starts and scanned every chunk of each saved region for each candidate. Same answers on main and this branch (24 targets × 5 points × 4 runs); on a pregenerated world the bamboo-camp map search drops from 2.1–3.6 s to 0.04–0.07 s of server-thread CPU, and main hit the 60 s watchdog in 4 of 9 runs. fmt, both clippies and the touched crates' tests pass, plus 10 harnesses incl. server_world_gen (/locate against vanilla); dedicated_server's vanilla oracle crashed on the loaded desktop, so CI runs it.
2026-11-04 15:09:32 +01:00
Ludovic Marie 6475f62f3c Merge pull request #8844 from TiggerTakkyTTT/test/server-api-backup-goldens
🤖 Agent: Server API backup goldens: test-only, the download case reads the real streamed bytes. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. management_protocol (--fresh, on that union): every case of this PR matches vanilla; the only 3 differences were #8204's heartbeat cases, fixed there. Other harnesses PASS on the union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. dedicated_server could not run locally (the vanilla oracle's world generation hit its watchdog under the desktop's memory pressure), so it and the rest of the selection run in CI run-parity.
2026-11-04 15:01:34 +01:00
Ludovic Marie 48684a9b1b Merge pull request #8834 from TiggerTakkyTTT/test/server-api-lifecycle-goldens
🤖 Agent: Server API lifecycle goldens: test-only, type-checked replacement of the volatile fields. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. management_protocol (--fresh, on that union): every case of this PR matches vanilla; the only 3 differences were #8204's heartbeat cases, fixed there. Other harnesses PASS on the union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. dedicated_server could not run locally (the vanilla oracle's world generation hit its watchdog under the desktop's memory pressure), so it and the rest of the selection run in CI run-parity.
2026-11-04 15:01:28 +01:00
Ludovic Marie e1fca55972 Merge pull request #8128 from TiggerTakkyTTT/fix/server-management-entity-range
🤖 Agent: Entity broadcast range: vanilla logs the requested percentage, then clamps 10..1000 and saves the clamped value. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. management_protocol (--fresh, on that union): every case of this PR matches vanilla; the only 3 differences were #8204's heartbeat cases, fixed there. Other harnesses PASS on the union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. dedicated_server could not run locally (the vanilla oracle's world generation hit its watchdog under the desktop's memory pressure), so it and the rest of the selection run in CI run-parity.
2026-11-04 15:00:17 +01:00
Ludovic Marie 4d6f1253d2 Merge pull request #8620 from horizon-rust/feat/launcher-shop-ui
🤖 Agent: Ludovic approved the shop and the dark founder set (11-04). verify, launcher, parity-scope green on this exact head (rdbtCVS's options-test fix included); desktop fmt/clippy/tests passed on the shop changes. The claim window stays closed on the service until the Paddle 0 CHF checkout follow-up lands, so merging only shows the shop and worn cosmetics.
2026-11-04 14:48:32 +01:00
Ludovic Marie f521bd9eb1 Merge pull request #8194 from Akirabane/feat/prometheus-entities-chunks
🤖 Agent: Prometheus entity/chunk counts read only when the supervisor samples server-status; main merged (rdbt's d938f6a1dd). The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. Harnesses PASS on that union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. boat_submerged, boat_paddle_reset and dedicated_server did not run locally: the vanilla oracle's world generation hit its watchdog 3 times under the desktop's memory pressure, before any comparison. They and the rest of the selection run in CI run-parity.
2026-11-04 14:48:28 +01:00
Ludovic Marie 971ded7bc7 Merge pull request #8171 from Akirabane/fix/8156-boat-powder-snow
🤖 Agent: Boat powder snow matches PowderSnowBlock.entityInside (stuck 0.9/1.5/0.9, nextBoolean then two randomBetween draws). The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. Harnesses PASS on that union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. boat_submerged, boat_paddle_reset and dedicated_server did not run locally: the vanilla oracle's world generation hit its watchdog 3 times under the desktop's memory pressure, before any comparison. They and the rest of the selection run in CI run-parity.
2026-11-04 14:48:24 +01:00
Ludovic Marie c6734d6acb Merge pull request #8167 from Akirabane/fix/8156-rider-frost
🤖 Agent: Rider frost walker / block effects for passengers; old_position reset keeps the sweep to the vehicle's cell. The union of this wave's 15 PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop. Harnesses PASS on that union: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container. boat_submerged, boat_paddle_reset and dedicated_server did not run locally: the vanilla oracle's world generation hit its watchdog 3 times under the desktop's memory pressure, before any comparison. They and the rest of the selection run in CI run-parity.
2026-11-04 14:48:20 +01:00
Alexander KrylovandHermes Agent b85e07a5de Stamp the pending native hook fragments through API 399
main carries 40 fragments whose markers have not been consumed, so
`python tools/stamp_native_hooks.py --check` exits 1 there. This is one run of
that tool on current main: it numbers the fragments in first-parent merge
order, writes the markers into the hand-written API types, and inserts each
`doc_summary` as an `API N adds ...` paragraph in docs/native-mods.md.

No hand-written line: the stamp pass owns native-hooks.json, NATIVE_HOOKS_API
and docs/native-mods.md, and editing any of them by hand is what the fragments
replaced.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-11-04 16:38:00 +03:00
Ludovic Marie f95da0d5e7 Merge pull request #8619 from Akirabane/fix/8488-execute-on-owner
🤖 Agent: execute on owner resolves the owner guest first. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:49 +01:00
Ludovic Marie 837e0498c2 Merge pull request #8520 from Akirabane/perf/pool-idle-per-worker
🤖 Agent: Per-worker idle release; cross-worker idle is mutexed, per-job cost a lock + Instant::now. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:45 +01:00
Ludovic Marie 89d08be7cf Merge pull request #8218 from Akirabane/fix/7843-server-pack-tags
🤖 Agent: World pack tags merged only for registries with tag files. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:41 +01:00
Ludovic Marie 398aa7aa09 Merge pull request #8188 from Akirabane/fix/8180-bucket-hollow-blocks
🤖 Agent: Bucket ray uses ClipContext.Block.OUTLINE like BucketItem.use; main merged. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:37 +01:00
Ludovic Marie 5c1f65bb9c Merge pull request #8182 from Akirabane/fix/8175-bool-std430
🤖 Agent: Shader bool/bvec buffer rewrite; rdbt's main merge 0e010734d9 (one mod-line hunk) re-tested: horizon-shaders 249 tests OK. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:32 +01:00
Ludovic Marie 7bc9b358dc Merge pull request #8170 from Akirabane/fix/2912-routing
🤖 Agent: Routing of flat worlds through the pipeline; lands after #8163 (carries its commits). the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:27 +01:00
Ludovic Marie abfb7aee9e Merge pull request #8163 from Akirabane/fix/2912-flat-features
🤖 Agent: Superflat features match FlatLevelGeneratorSettings.adjustGenerationSettings. the union of all 15 of this wave's PRs on main acd184c1 passed fmt, clippy -D warnings, 3388 tests and the Python suite on the desktop; harness subset on that union PASS: flat_generation, flat_place, chunk_generation, datapack_worldgen, command_exec, pick_block_data, powder_snow_collision, snow_freeze, boat_authority, vehicle_container (only a subset ran locally; the full selection runs in CI run-parity).
2026-11-04 14:28:22 +01:00
Ludovic MarieandClaude Opus 5.5 a13845ca22 Map searches: skip chunks no wanted start fits, and read one chunk to tell an old one
Opening a generated chest with an exploration map nobody searched yet (a camp chest's camp
or secret map, a shipwreck's treasure map) runs the map's structure search on the server
thread. Two things made one such UseItemOn cost seconds on the official server (#8669):

- Every candidate chunk got its full starts built (all of its sets, jigsaw layouts
  included). Vanilla's StructureCheck.canCreateStructure only places the wanted
  structure's start piece and checks its biome. `may_start` now does that for jigsaws
  (`place_start` / `lay_out_children` split out of `assemble_drawing`), and `locate`
  builds a chunk's starts only where a wanted structure may start.
- Each candidate asked whether its chunk was old through the blender's region scan,
  which decodes every chunk record of each region file it touches. Vanilla's
  StructureCheck reads the one chunk. `OldWorld::is_old_chunk` now answers from a
  scanned region's bits, else from that chunk's record alone.

The slow-event line also names the item, block, position and face of a use, and the use
is split into use_mods / use_block / use_item / open_loot / open_maps /
use_advancements spans (`spans::guard` ends a span where its scope does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:28:06 +01:00
rdbt 99f0a14b40 Merge branch 'main' into fix/xp-orb-light 2026-11-04 13:17:01 +00:00
Ludovic Marie 38df83828d Merge pull request #9087 from arslanarm/shaderpacks/ignored-format-declarations
🤖 Agent: clean-room check passed (black-box probes of their own, no source). Merged main and fixed #8999's test call to the removed Config::finish. Verified main + this head on the desktop: fmt, clippy -D warnings (shaders, render, all targets), 1270 tests, 0 failures; GPU fixtures not run.
2026-11-04 13:43:13 +01:00
rdbtCVSandCodex gpt-6.1-sol c700853616 Retain the latest Shop updates alongside the options test correction
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:23:31 +00:00
rdbtCVSandCodex gpt-6.1-sol 9eac9f79b1 Match the world light type and parenthesize the remaining packed light expectation
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:21:41 +00:00
Ludovic MarieandClaude Opus 5.5 af6e2b1295 Read #8999's shadow test config without finish()
#9087 removes Config::finish (its only work was the gdepth rule); the test reads the settings as they are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:20:57 +01:00
Ludovic Marie 67992e9fed Merge remote-tracking branch 'origin/main' into cg28/9087 2026-11-04 13:20:51 +01:00
rdbtCVSandCodex gpt-6.1-sol bc6ac0ab30 Retain upstream game systems removed by the earlier XP orb branch update
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:19:11 +00:00
rdbtCVSandCodex gpt-6.1-sol 0e010734d9 Merge main and preserve both shader routing and tag changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:15:32 +00:00
Ludovic Marie c2b104827c Merge pull request #9097 from horizon-rust/dezer/logos
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this merge head; the merge keeps #9091's clickable edition tabs with the new grass block and brand icon.
2026-11-04 13:13:42 +01:00
Ludovic Marie 7481e652fc Merge pull request #9093 from horizon-rust/dezer/settings-spy
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this merge head; the merge keeps both new Launcher fields (settings_marked beside #9099's mods_sticky).
2026-11-04 13:13:38 +01:00
Ludovic Marie 2d4f8380f2 Merge pull request #9092 from horizon-rust/dezer/lists
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this merge head; the main merge only re-applied the 14 px inset to Home's new head layout from #9091.
2026-11-04 13:13:34 +01:00
rdbtCVSandCodex gpt-6.1-sol 9a223ef014 Scroll the cosmetics cycle button into view in the options behavior test
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:10:38 +00:00
Ludovic Marie a55b9a2e52 Merge pull request #9007 from arslanarm/shaderpacks/uint-colortex0-blit
🤖 Agent: updated onto main after #8042, #8510 and #9002 landed (merge of main; #8999 fixture gets item_id_declared/material_maps; #9007 is stacked on #9005 so the trailing test modules resolve once). Verified on the desktop, main + #8999 + #9007 (+ #9005): fmt, clippy -D warnings (shaders, render, all targets), 1268 tests, 0 failures; GPU fixtures not run (no adapter over ssh).
2026-11-04 13:09:03 +01:00
Ludovic Marie 8350e76e2a Merge pull request #9005 from arslanarm/shaderpacks/const-local-sample-offset
🤖 Agent: updated onto main after #8042, #8510 and #9002 landed (merge of main; #8999 fixture gets item_id_declared/material_maps; #9007 is stacked on #9005 so the trailing test modules resolve once). Verified on the desktop, main + #8999 + #9007 (+ #9005): fmt, clippy -D warnings (shaders, render, all targets), 1268 tests, 0 failures; GPU fixtures not run (no adapter over ssh).
2026-11-04 13:08:58 +01:00
Ludovic Marie e4fe4e8122 Merge pull request #8999 from arslanarm/shaderpacks/shadow-map-coverage
🤖 Agent: updated onto main after #8042, #8510 and #9002 landed (merge of main; #8999 fixture gets item_id_declared/material_maps; #9007 is stacked on #9005 so the trailing test modules resolve once). Verified on the desktop, main + #8999 + #9007 (+ #9005): fmt, clippy -D warnings (shaders, render, all targets), 1268 tests, 0 failures; GPU fixtures not run (no adapter over ssh).
2026-11-04 13:08:55 +01:00
Ludovic MarieandClaude Opus 5.5 f28f5b6d50 Launcher: the Shop's item wells lit for the dark founder set
The founder set is now navy and charcoal (Ludovic, 11-04). On the dark
appearance's near-black well they all but vanished (well 17,11,31 against a
tee front of 33,32,41), so the dark well is a violet grey, lighter at the
top like a showcase. The light well stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:08:37 +01:00
rdbtCVSandCodex gpt-6.1-sol 01bf400ff7 Merge main while preserving random tick caching and fixing light test lints
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:03:51 +00:00
Ludovic Marie ea06a53506 Merge pull request #9098 from horizon-rust/dezer/look
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head (473+85); menus open at once because an animation inside a deferred menu kept its invisible first frame under GPUI Fast (seen on the desktop run), pages and toasts keep the 200 ms pop.
2026-11-04 13:03:21 +01:00
rdbtCVSandCodex gpt-6.1-sol d938f6a1dd Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:02:46 +00:00
rdbtCVSandCodex gpt-6.1-sol c37a6c6543 Merge main and reconcile parallel changes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:02:44 +00:00
Ludovic Marie 26a79956fd Merge pull request #9049 from senjinthedragon/portal-shadow-casters
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 13:02:11 +01:00
Ludovic Marie e2cf10bbd0 Merge pull request #9048 from senjinthedragon/glow-frame-entity-id
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 13:02:07 +01:00
Ludovic Marie c0df7b1959 Merge pull request #9047 from senjinthedragon/pack-format-decimal
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 13:02:02 +01:00
Ludovic MarieandClaude Opus 5.5 dab0067925 Merge #9005's branch: both trailing test modules stay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:01:47 +01:00
Ludovic Marie 40c4441550 Merge remote-tracking branch 'origin/main' into cg28/9007
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-11-04 13:01:32 +01:00
Ludovic MarieandClaude Opus 5.5 09022c09b9 Give #8999's shadow filtering fixture the fields main added
#8042 added GbuffersInputs.item_id_declared and #8510 World/Geometry.material_maps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:01:26 +01:00
Ludovic Marie e221e9d786 Merge remote-tracking branch 'origin/main' into cg28/9005
# Conflicts:
#	crates/horizon-client-render/src/gfx/pack_post.rs
2026-11-04 13:00:50 +01:00
Ludovic Marie f5243832bb Merge remote-tracking branch 'origin/main' into cg28/8999 2026-11-04 13:00:50 +01:00
rdbtCVSandCodex gpt-6.1-sol 9b6841e04e Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:39 +00:00
rdbtCVSandCodex gpt-6.1-sol 1161302d27 Merge main to reconcile upstream changes and CI fixes
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-04 12:00:38 +00:00
Ludovic Marie b3ec6f2d35 Merge pull request #9023 from arslanarm/shaderpacks/clear-false-ping-pong
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:25 +01:00
Ludovic Marie 73d3ee3d20 Merge pull request #9022 from arslanarm/shaderpacks/forced-flip-written
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:20 +01:00
Ludovic Marie 04b8b27a3b Merge pull request #9020 from arslanarm/shaderpacks/nether-pack-shade
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:16 +01:00
Ludovic Marie 638c0044f0 Merge pull request #9019 from arslanarm/shaderpacks/clouds-textured-fallback
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:12 +01:00
Ludovic Marie 797ca1fab9 Merge pull request #9015 from arslanarm/shaderpacks/custom-uniform-unknown-names
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:08 +01:00
Ludovic Marie 4b1157a697 Merge pull request #9014 from arslanarm/shaderpacks/feature-flag-case
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:59:03 +01:00
Ludovic Marie c60bb5a341 Merge pull request #9004 from arslanarm/shaderpacks/length-array-size
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:58 +01:00
Ludovic Marie e927a62b23 Merge pull request #9002 from arslanarm/shaderpacks/trinary-minmax
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:54 +01:00
Ludovic Marie 440d486f39 Merge pull request #8994 from arslanarm/shaderpacks/ssbo-vector-atomics
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:50 +01:00
Ludovic Marie 3c9dd1fb50 Merge pull request #8993 from arslanarm/shaderpacks/custom-uniform-option-macros
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:47 +01:00
Ludovic Marie ee5ef77630 Merge pull request #8975 from arslanarm/shaderpacks/properties-feature-macros
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:42 +01:00
Ludovic Marie 9dc66afd4d Merge pull request #8974 from arslanarm/shaderpacks/custom-smooth-fade-time
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:39 +01:00
Ludovic Marie 37aba3abe5 Merge pull request #8510 from gigamans442/fix/independent-terrain-materials-20261103
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:34 +01:00
Ludovic Marie 7d8f45fedd Merge pull request #8474 from gigamans442/fix/independent-uniform-defaults-20261103
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:29 +01:00
Ludovic Marie f897927efa Merge pull request #8042 from arslanarm/shaderpacks/rendered-item-id
🤖 Agent: verified in a union of the 21 open shader-pack PRs on main 3fb4c3138 (desktop): fmt, clippy -D warnings on all targets, 3766 tests, 0 failures. GPU fixtures not run (no adapter over ssh); harnesses not run (shader-pack-only code).
2026-11-04 12:58:24 +01:00
Ludovic MarieandClaude Opus 5.5 0efb67fc12 launcher: the Language list keeps a solid backing under its glass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:51:41 +01:00
Ludovic MarieandClaude Opus 5.5 435b2837cd Keep the input ledger in the PR, not in the repo
The repo holds no per-PR research records; the ledger stays linked from the PR at its commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:44 +01:00
Ludovic MarieandClaude Opus 5.5 b12f0ad465 Keep the input ledger in the PR, not in the repo
The repo holds no per-PR research records; the ledger stays linked from the PR at its commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:44 +01:00
Ludovic MarieandClaude Opus 5.5 c38354c637 Give the captured frame no hand items
Main's capture.rs builds a Frame, which now has hand_items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:38 +01:00
Ludovic Marie 6ea3787844 Merge remote-tracking branch 'origin/main' into cg28/8042 2026-11-04 12:46:26 +01:00
Ludovic MarieandClaude Opus 5.5 9d5e59e762 Management: log the entity broadcast range asked for, as vanilla does
MinecraftServerSettingsServiceImpl.setEntityBroadcastRangePercentage logs the
requested percentage before DedicatedServerProperties clamps it to 10..1000.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:46:19 +01:00
Ludovic MarieandClaude Opus 5.5 247d9941f2 launcher: fmt the Language list's anchor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:43:55 +01:00
Ludovic Marie acd184c1d9 Merge pull request #9094 from horizon-rust/dezer/modrinth
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head; Modrinth's Java mods stay listed for Horizon instances with the existing Works/Partly/Doesn't run mark, and installs still check SHA-512 before writing.
2026-11-04 12:43:20 +01:00
Ludovic MarieandClaude Opus 5.5 4ceaf4a8c2 Merge origin/main into dezer/logos
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:42:25 +01:00
Ludovic MarieandClaude Opus 5.5 7d41c97f80 Merge origin/main into dezer/settings-spy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:42:01 +01:00
Ludovic MarieandClaude Opus 5.5 78f8d86785 Merge origin/main into dezer/lists
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:41:46 +01:00
Ludovic Marie a04888e69e Merge pull request #9096 from horizon-rust/dezer/avatar
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head (474+83); one session-server lookup per account per 24 h, the empty answer (default skin) cached too, so an offline launcher doesn't retry each frame.
2026-11-04 12:41:18 +01:00
Ludovic MarieandClaude Opus 5.5 88ee593627 launcher: menus open at once; the Language list stays in the window
The pop animation inside a deferred menu kept its first frame under GPUI Fast: the
version menu never showed on the desktop run, the Language list lost its plate. Menus
open at once again (pages and toasts keep the pop); the Language list is anchored and
opens upwards when there is no room below.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:38:44 +01:00
Ludovic Marie 056d097a9a Merge pull request #8687 from degepie/fix/looking-at-vehicles
🤖 Agent: vanilla: PlayerPredicate lookingAt over every non-spectator entity; tick/location gated by may_look. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:30 +01:00
Ludovic Marie d234d6daaa Merge pull request #8683 from degepie/fix/damage-sources
🤖 Agent: vanilla: indirectMagic(potion|cloud, owner) instead of main's Damage::player; overlaps #9053 (TheMrZZ), second to land resolves. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:25 +01:00
Ludovic Marie 1294fca5e5 Merge pull request #8662 from degepie/fix/bare-ids
🤖 Agent: vanilla: 26.3 EntityPredicate is a dispatchedMap by sub-predicate id; TagPredicate.id is a HolderSet. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:21 +01:00
Ludovic Marie 23261d97b3 Merge pull request #8654 from degepie/fix/throw-bucket-triggers
🤖 Agent: vanilla: hurtServer fires PLAYER_HURT_ENTITY at 0 damage outside i-frames; interact trigger after filled_bucket. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:17 +01:00
Ludovic Marie 422d9b6bbb Merge pull request #9051 from glitchsnake/fix/8785-wither-block-slowdown
🤖 Agent: vanilla: WitherBoss.makeStuckInBlock is empty; berry damage kept. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:13 +01:00
Ludovic Marie 6e9a91f185 Merge pull request #8964 from glitchsnake/fix/8957-spectator-container-vibrations
🤖 Agent: vanilla: ContainerOpenersCounter edges for CONTAINER_OPEN/CLOSE; tautological test removed. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:09 +01:00
Ludovic Marie 8737bbfe7e Merge pull request #8952 from glitchsnake/fix/8934-elder-guardian-home
🤖 Agent: vanilla: ElderGuardian.customServerAiStep setHomeTo(blockPosition, 16); tests trimmed; carries #8966's commits (guardian.rs conflict). Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:05 +01:00
Ludovic Marie 7332139da4 Merge pull request #8966 from glitchsnake/fix/8933-silent-guardian-flop
🤖 Agent: vanilla: Guardian.aiStep flop under !isSilent; tests folded into one. Lands before #8952 (stacked on it). Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:34:00 +01:00
Ludovic Marie 7e7336db35 Merge pull request #8501 from glitchsnake/fix/8419-summon-autocomplete
🤖 Agent: vanilla: SUMMONABLE_ENTITIES = canSummon types; only player and fishing_bobber are noSummon in 26.3. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:56 +01:00
Ludovic Marie 641cba9b30 Merge pull request #8887 from HrafnaValkyrja/fix/warden-vanilla-rules
🤖 Agent: vanilla: AbstractBoat.tick startRiding branch, a refused rider is neither seated nor pushed (#8916 closed as its duplicate). Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:52 +01:00
Ludovic Marie 26ae1d2991 Merge pull request #8864 from contactjayclatty/fix/8724-glow-squid-environmental-damage
🤖 Agent: vanilla: GlowSquid.hurtServer dims only if Squid.hurtServer is true, which needs getLastHurtByMob. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:48 +01:00
Ludovic Marie be63efabcc Merge pull request #8857 from contactjayclatty/fix/8776-wither-takes-no-effect
🤖 Agent: fixed on the branch: the refusal is WitherBoss.addEffect's (Mob::add_effect); canBeAffected stays vanilla so AreaEffectCloud keeps the wither as a victim. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:44 +01:00
Ludovic Marie 1b3e7c0f7f Merge pull request #8853 from contactjayclatty/fix/8721-zero-duration-effects
🤖 Agent: vanilla: MobEffectInstance.tickServer returns false at 0 (hasRemainingDuration), tickEffects removes it. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:40 +01:00
Ludovic Marie 9dcad939f7 Merge pull request #8843 from contactjayclatty/fix/8790-wither-cannot-ride-a-minecart
🤖 Agent: vanilla: OldMinecartBehavior/NewMinecartBehavior seat through startRiding, WitherBoss.canRide false. Proof: union of main b1f5b238 + this batch's 14 heads on the desktop: fmt, clippy -D warnings, tests of horizon-client/-lib/-core/horizon-world green (GPU gfx/post_chain/menu_blur skipped). Harness subset only (not the full parity.py selection, which CI runs on this head): 22 passed (active_effect_tick active_effects effect_notifications status_effect consume_effect sculk block_entity_anim chest_pair command_parse chat_screen command_exec peaceful_summon living_hurt living_damage damage_source player_hurt damage_reduction server_player_damage boat_authority vehicle_container powder_snow_collision squid_client); dedicated_server and server_entities failed on the vanilla oracle's watchdog under desktop load, mob_ai not finished (oracle 1 h timeout under load): left to CI.
2026-11-04 12:33:36 +01:00
Ludovic Marie f74b20d999 Merge the branch's fmt fix and main
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/guardian.rs
2026-11-04 12:28:20 +01:00
Ludovic Marie 725a5dcfa4 Merge #8966 into #8952: both append to guardian.rs
The two branches each added a test module at the end of guardian.rs, so
whichever lands second conflicts. Carry #8966 here; land #8966 first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/mobs/guardian.rs
2026-11-04 12:27:13 +01:00
DenisHumenandClaude Opus 5.5 269718fac1 Draw sign text glyphs over their own box, not a fixed 8x8 cell
Sign text drew every glyph as an 8x8 quad at the pen. Vanilla's
BakedSheetGlyph draws x+left..x+right and y+up..y+down, which the HUD
already does. With Force Unicode Font a Latin letter is an 8x16 Unifont
glyph at half scale (4 units wide, advance 5), so stretching it to 8
units overlapped its neighbours.

Fixes #8699

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:26:18 +02:00
Ludovic Marie 23ae9e7f82 Merge pull request #9091 from horizon-rust/dezer/home
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head (473+84 passed); below 1280 px the mods button folds into ⋯ so the invite field keeps a 150 px minimum to the right of Play (screenshots at 1000×700).
2026-11-04 12:24:28 +01:00
Ludovic Marie 3319108619 Merge pull request #9095 from horizon-rust/dezer/partner
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head; the GitHub mark is our own SVG in horizon-theme's glass icons, the Sculk card keeps its 2 px hover lift without the border.
2026-11-04 12:15:47 +01:00
Ludovic Marie 7a969a6122 Merge pull request #9099 from horizon-rust/dezer/mods
🤖 Agent: desktop fmt/build/clippy -D warnings/tests 0 at this head; the card re-renders only when its offset changes (on_scroll + next frame), so a long list costs nothing extra while scrolling; a scrollbar-thumb drag catches up on the next event (noted in the body).
2026-11-04 12:11:24 +01:00
Ludovic MarieandClaude Opus 5.5 9dfb3149fb Launcher: the Shop's countdown rounds whole days to the nearest
From three days up the span showed whole days rounded down, so a seven-day
window read "6 days left" a minute after it opened.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:08:53 +01:00
DenisHumenandClaude Opus 5.5 c6efd15f0c Mobs see the player's off-hand item (tempting food in the off hand)
Game::player_view left PlayerView.off_hand as None, so TemptGoal, the
animal brains' TemptingSensor, piglins' loved items, goats, happy
ghasts and taming never saw what the player holds in the off hand.
Vanilla's TemptGoal.shouldFollow and LivingEntity.isHolding check both
hands. Fill it from the off-hand slot, as main_hand is filled.

Fixes #8858

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 13:04:58 +02:00
Ludovic MarieandClaude Opus 5.5 99725aa079 launcher: the scroll spy takes the scroll off the sections' layout tops
A section's bounds from the ScrollHandle are its layout position before the scroll, so
the list stayed on the first section until the pane reached its end (seen on the desktop
run: Account still marked over the Launcher card).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 12:03:43 +01:00
LokiandAntigravity 0b831b1dca mobs: cargo fmt guardian.rs
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 19:53:23 +09:00
Loki 81e9973c62 Merge remote-tracking branch 'origin/main' into fix/8934-elder-guardian-home 2026-11-04 19:51:29 +09:00
Ludovic MarieandClaude Opus 5.5 e584b02ce2 launcher: the knob's slide owns its ends; fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:42:02 +01:00
Ludovic MarieandClaude Opus 5.5 9cab156727 launcher: fmt the instance dialog's logos
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:40:27 +01:00
Ludovic MarieandClaude Opus 5.5 a61d9f473d launcher: menus in the cards' glass, the website's motion, a whiter light look
Menus and popovers take the cards' glass with a shadow instead of the darker float, and
arrive as the website's hzl-pop (fade and 6 px rise, 200 ms); Settings' Language list is
one of them now (it was the old square box and ran off the window). Hovers ease on the
design's quick curve, pages and toasts pop, the glass switch's knob slides; reduced
motion draws each at its end. In light, the Play group's icons take the accent's ink and
the dawn sky is washed with white.

Addresses dezer-x's launcher feedback (points 7, 8 and 11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:40:21 +01:00
Ludovic MarieandClaude Opus 5.5 a6ab89c89b Launcher: the Shop in the glass look, the new founder set, First Light in Settings
The Shop and Wardrobe tabs on glass plates in both appearances (Geist type, the
glass ink, light wells a soft violet grey so cream and violet items keep their
edges); the founder panel stacks under a 1280 px window. The Wardrobe gets a Shirt
row; its launcher theme row is gone: launcher themes are free for everyone in
Settings > Appearance (Launcher theme: Horizon or First Light), never claimed.
The top bar's Wardrobe follows the cosmetics service only. Shop previews use the
7-day window. The Shop strings in the 61 languages main added since, and the new
ones everywhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:39:59 +01:00
Ludovic MarieandClaude Opus 5.5 47222e152f Cosmetics: a torso slot, top-level parts on their own bone, on by default
A shirt's sleeves ride the arms: a top-level part may name its own bone (Item::limbs,
one root per bone under the item's attachment, Item::roots). The game draws each root
on its bone, the launcher's preview too. Slot::Torso, hidden under a chestplate.

Cosmetics run by default (Ludovic, 2026-11-04: on when the shop opens); other
players' and your own already default to shown. HORIZON_COSMETICS=0 turns them off;
horizon_cosmetics::forced() is HORIZON_COSMETICS=1 for the launcher's Wardrobe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:39:59 +01:00
Ludovic Marie 3fb4c3138e Merge pull request #8815 from itarqos5/perf/reduce-a-far-chunk-when-read
🤖 Agent: reviewed by literal-review (agent-state/literal-review.md): a far chunk is reduced only to the cell width that reads it (405 → 103 µs per chunk in its bench), same output; verify + launcher green at this head.
2026-11-04 11:37:02 +01:00
Arslan ArmanulyandClaude Opus 5.5 c94eb80064 Shader packs: read colortex formats only where Iris 1.11.7 does
Probe packs of ours, run in Iris 1.11.7, set colortexNFormat in many places. Horizon now
follows what Iris did with them:
- a format in a block comment counts only where `const` starts its line (our probes' Iris
  values fit this; it isn't a rule Iris states): not on the `/*` line, not after ` * `, not
  after another declaration on the line;
- a format in a vertex shader doesn't count;
- the colortex formats of the shadow programs' fragment shaders are read when the shadow pass
  doesn't run (observed in our probes for shadow.fsh and shadowcomp.fsh); nothing else of
  those files is read then (Horizon's choice: nothing else was probed);
- declaring `gdepth` no longer makes colortex1 RGBA32F.

Fixes #9063

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 15:30:59 +05:00
Ludovic Marie 17d4944fa9 Merge branch 'dezer/mods' into dezer/modrinth 2026-11-04 11:23:48 +01:00
Ludovic MarieandClaude Opus 5.5 e4b7c5723f launcher: fmt the Mods page's sticky card and sort
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:23:47 +01:00
Ludovic MarieandClaude Opus 5.5 57dba96d66 launcher: our own grass block for Vanilla, the real Horizon icon for Horizon
The Vanilla mark was baked from Mojang's grass textures in the prepared files, so the
Java Edition tab and the New instance card showed a letter until the game was prepared.
It is now an original drawing (tools/launcher_grass_block.py), always there. Horizon's
mark was a redrawn tile with the bar in the accent colour; it is now the brand icon.

Addresses dezer-x's launcher feedback (points 5 and 6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:23:40 +01:00
Ludovic MarieandClaude Opus 5.5 83d38f5735 launcher: fmt the scroll spy's tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:21:14 +01:00
Ludovic Marie 201026e91e Merge pull request #9077 from horizon-rust/fix/mgmt-stop-exits
🤖 Agent: management server/stop now ends horizon-server as vanilla's does. Desktop: management_protocol FAIL on main 017c84f1fe (supervisor TimeoutExpired), PASS on e132eeba6c (--fresh, 167 s); dedicated_server PASS both; horizon-server fmt/clippy -D warnings/134 tests pass; the new supervisor test fails with the fix reverted.
2026-11-04 11:17:59 +01:00
Ludovic MarieandClaude Opus 5.5 ad7e2b3594 launcher: the scroll spy's test reads the section at 1000 px right
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:14:09 +01:00
Ludovic MarieandClaude Opus 5.5 6c597549a9 launcher: the Mods page's Modrinth placeholder follows its tab
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 0b15d4a0d5 launcher: the top bar wears the signed-in player's face even when the account kept no skin
dezer-x's launcher feedback, point 14. The chip draws the account's head from its saved skin URL;
an account saved without one (older sign-ins, or a profile answered without its skin) fell back to
the default skin from a prepared version's files, and with none prepared to the user icon.
Such an account now asks Mojang's public session server (no token) for its skin, keeps the URL a
day beside the cached skins, and the face comes from the skin as before (textures.minecraft.net
only). Signed out or offline with nothing cached, the user icon stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:10:11 +01:00
Ludovic MarieandClaude Opus 5.5 e2639c66d6 launcher: Home drops the Resources rows, keeps the invite code beside Play, edition tabs switch
dezer-x's launcher feedback, points 1, 2 and 15:
- Overview (glass look) ends with the screenshots and the patch log: the Resources and
  Minecraft folder rows (and their Sign in with Microsoft) are gone; Prepare stays in Play's
  menu, the folder in the folder menu, signing in in the top bar.
- The invite code stays on the actions' row at every width; under 1280 px Play and the version
  are narrower, the puzzle gives way (the Mods tab does the same) and the field takes the rest,
  with the shorter "Paste an invite code".
- The Java Edition / Rust Edition tabs switch: to that edition's instance played last, or to the
  New instance dialog on Vanilla or Horizon when there is none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:06:47 +01:00
Ludovic MarieandClaude Opus 5.5 aba52d49b6 launcher: Modrinth is back on the Mods page, every kind has its tab
Ludovic's point 16 on dezer-x's launcher feedback: mod translators are coming and the launcher
plays Java instances, so the Mods page searches Modrinth again (undoes what 0416fe600c hid, in
the 0.2.38 design).
- Tabs: Mods, Modpacks, Resource packs, Shader packs, Data packs.
- Mods, Modpacks and Resource packs pick their source under the search: Horizon's registry or
  Modrinth, CurseForge beside them as coming soon, inert. Shader packs stay on the registry
  (Horizon's .hshader), data packs on Modrinth, into the world picked there (its worlds read again
  when the tab opens; source_for gave data packs Horizon's registry, which has none).
- A Horizon instance's Modrinth mods are listed, each marked Works, Partly or Doesn't run in
  Horizon once its jar is checked; a Java instance's tabs still search Modrinth for its version and
  loader. Installs keep the SHA-512 check before writing.
- docs/mod-browser.md, launcher/README.md and the Mods page's module comments say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:06:29 +01:00
Ludovic MarieandClaude Opus 5.5 02f481fe07 launcher: the Mods page's card stays in view, Sort by sits beside the search
dezer-x's launcher feedback, points 3 and 10:
- The project card beside the results stays in view as the page scrolls (the mockup's
  lg:sticky lg:top-0): sticky.rs measures the row while it paints and moves the card down by what
  the page scrolled past it, never past the row's end. A scrolled page is drawn from its layer,
  so the page's scroll asks on the next frame whether the card must follow, and redraws only then.
- Sort by is on the search's right, as tall and as round as the search, its choices on a glass
  popover with the chosen one ticked; Modrinth's filter bar no longer repeats it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:02:39 +01:00
Ludovic MarieandClaude Opus 5.5 9f7e16a7b6 launcher: Settings' list follows the scroll, each last section in turn
dezer-x's launcher feedback, point 9. GPUI Fast draws a scrolled pane from its cached layer
without rendering the view, so the list only moved when something else redrew the page; at
the end the mockup's rule then jumped straight to Data collection.
- The pane's scroll asks, on the next frame, which section is read, and redraws only when it
  changed.
- The reading line sits at 30 % of the pane, then slides to its bottom over the last 70 % of a
  pane of scroll, so About and Data collection, too short to reach 30 %, each come in turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:58:53 +01:00
Ludovic MarieandClaude Opus 5.5 ac67a24940 fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:47 +01:00
Ludovic MarieandClaude Opus 5.5 57c08c432b launcher: versions and patch notes get their gap, the pages stop short of the top bar
dezer-x's launcher feedback, point 4:
- Each Versions and Patch notes card carries the 10 px bottom margin cull::column already
  counted as their gap (the mockup's gap-2.5): the cards were fused.
- The glass look's scroll area starts 14 px under the top bar, so cards scrolled up stop short of
  it; the pages' heads hold 14 px less above their titles, so nothing moves at rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:43 +01:00
Ludovic MarieandClaude Opus 5.5 fd381c7ad7 Merge origin/main into feat/launcher-shop-ui
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:12 +01:00
Ludovic MarieandClaude Opus 5.5 a53ae76d3c Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:34 +01:00
Ludovic MarieandClaude Opus 5.5 3faaa3f304 Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:34 +01:00
Ludovic MarieandClaude Opus 5.5 e81cf7e35c Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:34 +01:00
Ludovic MarieandClaude Opus 5.5 09d7b3625e Merge main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:53:33 +01:00
Ludovic MarieandClaude Opus 5.5 f6e559c186 mobs: one flop test over both guardians, loud and silent
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:41 +01:00
Ludovic MarieandClaude Opus 5.5 5bea3820c1 game: drop the opener edge test that computed its own answer
It derived the open and close edges in the test from recheck_opener_count's
return, which the existing opener tests already pin; it never reached the
game events this PR gates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 efb950a10d Merge main into fix/8933-silent-guardian-flop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 8bd408af30 Merge main into fix/8957-spectator-container-vibrations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:24 +01:00
Ludovic MarieandClaude Opus 5.5 425145eb50 mobs: one elder guardian home test, beside the code it pins
Keep the home and keep-existing-home cases and the restriction they give
in one test right after elder_server_ai_step (so it does not collide with
the flop tests #8966 appends to guardian.rs), drop the test that pinned
extra.home staying empty, and keep Mob::toward private.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:51:17 +01:00
Ludovic MarieandClaude Opus 5.5 1fea8858dd Merge main into fix/8934-elder-guardian-home
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:50:53 +01:00
Ludovic MarieandClaude Opus 5.5 b500705d23 mobs: the wither's addEffect refuses, its canBeAffected does not
WitherBoss overrides addEffect to return false and canBeAffected to refuse
the wither effect only. AreaEffectCloud.serverTick picks its victims with
canBeAffected, so a cloud of strength or healing still takes the boss as a
victim: its instant effects land and the cloud shrinks. Keep
mob_can_be_affected and Mob::can_be_affected vanilla's canBeAffected and
put the blanket refusal in Mob::add_effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:50:46 +01:00
Ludovic MarieandClaude Opus 5.5 6581318519 Merge main into fix/8776-wither-takes-no-effect
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:50:14 +01:00
Ludovic MarieandClaude Opus 5.5 e132eeba6c Server: name vanilla's stop path in the comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:36:38 +01:00
TiggerTakkyTTTandCodex 9c92abadf9 Merge resolved simulation distance branch into entity range management
Carry the resolved management stack into the entity broadcast range PR while preserving all routes, port-ledger entries, and parity cases from current main.

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 15:25:24 +07:00
Ludovic MarieandClaude Opus 5.5 eb03a89c7c Server: the management protocol's server/stop ends the supervisor
Since #8245 a stop that doesn't come from the supervisor's stdin or the
control API leaves the supervisor in its stopped phase. The management
`server/stop` only set `stop`, so horizon-server kept running after it,
where vanilla's process exits (the management_protocol harness timed out).
It now shuts down as `POST /api/v1/stop` does, through one helper.

Fixes #9027

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:23:49 +01:00
LokiandAntigravity d237664ed1 mobs: exclude wither from sweet berry bush and powder snow slowdown (fixes #8785)
In Vanilla Java Edition 26.3, WitherBoss.makeStuckInBlock overrides Entity.makeStuckInBlock as an empty NO-OP.
While Cobweb (WebBlock) already ignored Wither alongside Spiders, Wither was still getting stuck in and having its fall distance reset by Sweet Berry Bushes and Powder Snow.

This aligns effects_from_blocks with vanilla Java bytecode:
- Wither is immune to slowdown and fall distance resets from sweet berry bushes and powder snow.
- Wither still takes sweet berry bush damage when moving in mature bushes.
- Powder snow snowflake particles continue to spawn when moving.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 17:19:46 +09:00
SenjinandClaude Opus 5.5 c0d5806f45 Shader packs: End portal faces reach the shadow pass with their block entity
The shadow pass drew a block entity's plain faces but left out its End portal or gateway faces
(the portal run of #8361). A pack's shadow pass sees block entities whole, so the portal faces
cast too, with end_portal.png bound as for the gbuffers draw; beams still do not cast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:08:33 +01:00
SenjinandClaude Opus 5.5 04104943fb Shader packs: a glow item frame and what it holds draw as glow_item_frame
Every frame's span was `item_frame`, so a pack's entity.properties could not tell glow frames
(and the item or map in them) from plain ones. Each frame, and the item or map it holds, now
takes its own frame's type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:06:11 +01:00
SenjinandClaude Opus 5.5 b711d2336d Packs: a decimal pack format reads as its whole part
`Codec.INT` reads a JSON number through `Number.intValue`, so a pack.mcmeta with
`"min_format": [97.1]` declares format 97 and is compatible. Horizon rejected the decimal,
so such packs (Patrix 26.3) were listed as incompatible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:05:55 +01:00
Arslan ArmanulyandClaude Opus 5.5 0625049299 Shader packs: a Clear = false buffer starts each frame from its last write
Every frame now starts with texture A as each colortex buffer's "main". A
buffer that isn't cleared and ended the last frame on B has B copied to A
first; B keeps what was last written into it. So where a program leaves
pixels alone (discard) or blends, the texture it writes holds what Iris
1.11.7 shows there, measured with probe packs of our own: the previous
frame's last write in #9009's case 1, one accumulation per frame for a
buffer only blended onto, and 0 where a buffer flipped twice a frame was
never written.

Fixes #9009

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:58:15 +05:00
Arslan ArmanulyandClaude Opus 5.5 67191f028a Shader packs: light Nether faces at the Nether's up-face 0.9, as Iris does
With a shader pack lighting the faces (`oldLighting` off), the mesher gave every block and fluid
face 1.0. Iris 1.11.7 gives our probe pack 0.9 (229 of 255) on every Nether face, lava sides
included, and 1.0 in the overworld. These values fit the dimension's up-face value of vanilla's
CardinalLighting (NETHER up 0.9, DEFAULT up 1.0); that is a fit of our probe's Iris values, not an
established Iris rule. Fluid sides under a pack drop vanilla's side factor.

Fixes #9012

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:53:46 +05:00
Arslan ArmanulyandClaude Opus 5.5 b9fdf62763 Shader packs: a custom uniform naming what Iris can't see reads 0 whole
In Iris 1.11.7 a `uniform.*`/`variable.*` expression that names
something its custom uniforms don't know fails as a whole and reads 0,
even when the name is in an `if` branch that isn't taken, and so does
every uniform or variable that uses it (probe packs of our own). Horizon
read only the name as 0 and kept the rest, and saw fogStart, fogColor
and eight more built-in names that Iris's custom uniforms don't.

Custom now works out, on its first evaluation, which items name an
unknown or hidden name, directly or through another item, and gives
them 0. Two names Iris sees that Horizon doesn't give yet
(currentRenderedItemId, lightningBoltPosition) still read 0 alone.

Fixes #8995

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:34:10 +05:00
Arslan ArmanulyandClaude Opus 5.5 5b58cd736e Shader packs: without gbuffers_clouds, draw the clouds with gbuffers_textured, else gbuffers_basic
A pack without `gbuffers_clouds` left the clouds to the game, so they never reached the pack's
buffers. Iris 1.11.7 draws them with `gbuffers_textured`, else `gbuffers_basic` (not
`gbuffers_textured_lit`), blended, as its ShaderDoc's gbuffers table lists and as our probe packs
measure.

Fixes #9011

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:22:28 +05:00
Arslan ArmanulyandClaude Opus 5.5 47b400625f Shader packs: a forced flip of a buffer the program writes cancels its flip
`flip.<program>.<buffer> = true` on a buffer the program also writes was
ORed with the write's own flip, so the buffer flipped once. Iris 1.11.7
leaves it unflipped (probe packs of our own): a later program samples the
side the program didn't write (0 for a cleared buffer), and a blended
write lands on what the program wrote (0.25 + 0.5 reads 0.749). The
forced flips are now XORed with the written ones.

Fixes #9010

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:21:01 +05:00
Arslan ArmanulyandClaude Opus 5.5 e317145573 Shader packs: match iris.features names in any case, as Iris does
Iris 1.11.7 accepts the names of `iris.features.optional` and
`iris.features.required` in any case, and defines `IRIS_FEATURE_<name>`
with the name as the pack wrote it (probe packs of our own). Horizon
compared them with its uppercase list exactly, so `ssbo` defined nothing
and `compute_shaders` as required was noted as unsupported. The
HIGHER_SHADOWCOLOR check now matches in any case too.

Fixes #8996

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:58:28 +05:00
Arslan ArmanulyandClaude Opus 5.5 c4e0dd1600 Shader packs: a pack with an integer colortex0 loads
The world blit wrote its float colour into colortex0 whatever its
format, so a pack giving colortex0 an integer format (RGBA32UI, Arc
v0.15.2) failed wgpu's pipeline validation and was turned off. An
integer colortex0 now gets only what the pack writes: the blit leaves it
out (still copying depth and clearing the other buffers), it is cleared
in a pass of its own, and a pack without `final` gets a cleared screen
instead of a float copy of it.

Fixes #8459

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:58:00 +05:00
Arslan ArmanulyandClaude Opus 5.5 ab7164bba1 Shader packs: a const local may be a texel offset
A `const` local whose initializer is a constant expression is a
constant expression itself (GLSL 4.60, 4.3.3, as #8462 quotes it).
naga's GLSL frontend kept it as a local variable, so a texel offset
given by one failed with "Sample offset must be a const-expression". It
now becomes a module constant, as a global `const` does, also inside a
loop.

Fixes #8462

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:57:52 +05:00
Arslan ArmanulyandClaude Opus 5.5 72ca9db476 Shader packs: .length() of a sized buffer member as an array size
`.length()` of an explicitly sized array is a constant expression
whether or not the array is (GLSL 4.60, 4.3.3, as #8463 quotes it). In
a constant context (an array size) naga's GLSL frontend lowered the
array itself there and failed with "Unexpected runtime-expression"; the
array is now lowered outside it, for its type only.

Fixes #8463

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:52:03 +05:00
Arslan ArmanulyandClaude Opus 5.5 6d40db2be5 Shader packs: read shadowtex0/1 linearly through a plain sampler, as Iris does
Iris 1.11.7 filters shadowtex0 and shadowtex1 linearly when a program reads
them through a plain sampler2D (probe packs of our own: a quarter texel past a
texel's centre moves a quarter of the way to its neighbour, in gbuffers and in
composite), unless the pack sets shadowtex0Nearest, shadowtexNearest or
shadow0MinMagNearest (shadowtex0), or shadowtex1Nearest or
shadow1MinMagNearest (shadowtex1). Horizon read the nearest texel, so a lookup
next to a block's edge took the higher block's depth and shadowed faces Iris
lights, with stripes on far terrain.

wgpu filters a depth format only in a comparison, so a shadowtex read that way
now reads an R32Float copy of the final depth (copied through a buffer after
the shadow pass's terrain), with the linear sampler, on devices with
FLOAT32_FILTERABLE. Others keep the nearest texel.

Part of #8464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:46:14 +05:00
Arslan ArmanulyandClaude Opus 5.5 c218bbe9a0 Shader packs: atomic functions on a component of a buffer block's vector
GLSL 4.60 8.11 lets an atomic memory function take a single component
of a vector in a buffer, as RenderPearl's hand light does with
atomicAdd(hand_light.right.x, rg); naga has no atomic vectors, so the
stage failed with "Pointer to atomic is invalid" and was skipped.

The rewrite that turns such shared vectors into arrays of components
now also covers members of std430 buffer blocks (named or not), in
graphics stages as in compute ones. The block keeps its std430 layout:
a padding member where the vector's alignment moved it, and four
elements per 3-component vector of an array. Other layouts, runtime-
sized arrays and blocks whose layout isn't known up to the vector stay
as written.

Fixes #8977

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:31:55 +05:00
Arslan ArmanulyandClaude Opus 5.5 a64ff0e803 Shader packs: min3, max3 and mid3 of GL_AMD_shader_trinary_minmax
A stage that enables GL_AMD_shader_trinary_minmax gets the extension's
min3, max3 and mid3 (genType, genIType and genUType overloads: the
per-component minimum, maximum and median of x, y and z) in its prelude,
for each one it calls and doesn't define itself. naga has none of them,
so such programs were skipped with "Unknown function 'max3'".

Fixes #8460

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 10:07:59 +05:00
Arslan ArmanulyandClaude Opus 5.5 0127a87ccf Shader packs: expand macros in custom uniform and variable lines
Iris 1.11.7 reads a custom uniform's or variable's line in
shaders.properties with the macros expanded: the pack's options, the
standard macros and the file's own #defines (function-like ones too),
in the name as well as the expression (probe pack of our own). Horizon
read them raw, so an option such as RenderPearl's FOG read 0.

#define in .properties files now goes through the preprocessor's own
parsing (Macros::define_line), so function-like macros work there too.

Fixes #8976

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 09:39:10 +05:00
LokiandAntigravity 08187d1377 mobs: suppress Guardian and Elder Guardian flop sounds when silent (fixes #8933)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 13:14:18 +09:00
LokiandAntigravity f32999331f game: guard container open and close game events by opener count edge (fixes #8957)
In vanilla Java 26.3, `GameEvent.CONTAINER_OPEN` and `CONTAINER_CLOSE`
are fired exclusively when `ContainerOpenersCounter.openerCountChanged`
detects an opener count transition across zero (0 -> 1 on open, 1 -> 0
on close). Spectators never join `ContainerOpenersCounter`, and
subsequent openers on an already open container do not re-trigger edges.

Guarding `post_block_game_event` with `if first` and `if last` prevents
spectators from emitting vibrations that alert sculk sensors and wardens.

Co-Authored-By: Antigravity <[email protected]>
2026-11-04 12:48:30 +09:00
Loki e8794c917c Merge remote-tracking branch 'origin/main' into fix/8934-elder-guardian-home 2026-11-04 12:14:42 +09:00
LokiandAntigravity f77fd4fbed mobs: set Elder Guardian wander restriction on ai.home (fixes #8934)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 12:14:12 +09:00
Nikita Maximilian Grimm 0d4e0a1b1d Merge remote-tracking branch 'origin/main' into offscreen-hdr 2026-11-04 03:48:24 +01:00
Nikita Maximilian Grimm e96694c5e8 Merge remote-tracking branch 'origin/main' into translucent-batching-checkbox
# Conflicts:
#	crates/horizon-client-render/src/graphics_ui.rs
2026-11-04 03:48:10 +01:00
GabrielNichols f11f2f3736 Merge commit '367b20268dacb3c6fc2b73af18f5e5439ffd9045' into codex/port-pack-harness-verdict 2026-11-03 23:25:40 -03:00
Cheezer1656andClaude Opus 5.5 9df8b23636 Native hooks: ClientChatReceivedEvent (client_chat_received)
A client mod is told of each chat line from the server just before it
shows, and may replace or cancel it: system messages (chat and action
bar), player chat and disguised chat. Chat Delay's queue now keeps each
message's sender and chat type, so the hook fires when the message
leaves the queue, as vanilla's showMessageToPlayer does, and a
cancelled message lets the next one through.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:20:30 -07:00
Cheezer1656andClaude Opus 5.5 332f192412 Native mods: native_send_chat sends chat or a command to the server
A client mod sends what the player could type, through send_chat
(ClientPacketListener.sendChat / sendCommand): a leading / is a command,
anything else signed chat. No client chat event and no chat history. An
error when not in a server's play state, for an empty message, and when the
Chat option blocks the message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:15:51 -07:00
Nikita Maximilian GrimmandClaude Opus 5.5 19942359aa HDR: screenshots from an --offscreen run, without an HDR display
`HORIZON_HDR_OUTPUT=pq` (or `scrgb`) now offers that output to an
`--offscreen` renderer, which has no display to ask. HDR is drawn as with
a window (the 16-bit frame included) and a screenshot writes its `-hdr.png`
beside the SDR one; nothing is presented, and Auto takes 203 and 1000 nits.
A 16-bit frame with HDR off reaches the offscreen target as it reaches a
swapchain. Nothing changes without the variable or with a window.

Fixes #8879

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 02:44:01 +01:00
contactjayclattyandOpenCode 5470844f14 mobs: environmental damage does not dim a glow squid
Squid.hurtServer reports a hit only when a living entity caused it, and GlowSquid.hurtServer dims the squid only when that returns true. Horizon dimmed on every hit, so a glow squid on a campfire, suffocating or drying out lost its glow.

Fixes #8724

Co-Authored-By: OpenCode (Jay's Unintelligence) <[email protected]>
2026-11-04 08:43:44 +08:00
deleteandCodex c57982648b Merge current Horizon main for native mod API review
Resolve adjacent, independent mob test modules while preserving both test suites.

Co-authored-by: Codex <[email protected]>
2026-11-04 03:37:34 +03:00
HrafnaValkyrjaandClaude Sonnet 5.5 9ad64d72c3 Boats never take a Warden or Wither aboard, like Entity.canRide
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 19:31:05 -05:00
contactjayclattyandOpenCode 726cd34fb2 mobs: the wither takes no potion effect
WitherBoss.addEffect returns false, so nothing lands on the boss. Horizon refused only the wither effect itself (the WitherSkeleton rule), so splash potions, area clouds, /effect and tipped arrows all worked on it.

Both paths that add an effect now refuse every effect for the wither; the wither skeleton keeps its own rule.

Fixes #8776

Co-Authored-By: OpenCode (Jay's Unintelligence) <[email protected]>
2026-11-04 08:29:31 +08:00
deleteandCodex a5f384f89c Discard native controlled particles on resource reload
Keep owned sprite-set indices from referring to a different declared particle after the resource sets are rebuilt.

Co-authored-by: Codex <[email protected]>
2026-11-04 03:25:14 +03:00
Delete228andCodex 66c3284171 Expose controlled particle visibility without removing its state
Keep hidden controller ticks and population accounting while emitting no geometry, matching visibility-gated source particles.

Co-Authored-By: Codex <[email protected]>
2026-11-04 03:16:14 +03:00
contactjayclattyandOpenCode 4a4ec9a5ff mobs: a zero-duration effect is over, not infinite
LivingEntity.tickEffects drops an effect whose duration is 0, because MobEffectInstance.hasRemainingDuration is false there. Horizon counted it down from 0 to -1, the infinite-duration sentinel, so an effect loaded with duration 0 stayed forever and even took its first tick.

Fixes #8721

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 08:14:47 +08:00
TiggerTakkyTTTandCodex 5657fabce7 Server API: pin backup replies
Add JSON golden cases for backup listing, creation, download, deletion, whole-world restore, dimension restore, and representative errors. Exercise streamed download bytes and normalize only the generated backup name.

Fixes #8839

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 07:07:49 +07:00
contactjayclattyandOpenCode 46387fb392 game: a wither never rides a minecart
WitherBoss.canRide returns false, so OldMinecartBehavior.pushAndPickupEntities cannot seat the boss in a moving cart. can_ride_cart excluded only the warden.

Fixes #8790

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 08:07:20 +08:00
Delete228andCodex d6700da628 Allow owned native particle tick controllers in the ordinary engine
Retain sprite sets, particle setting and distance gates, interpolation, lifetime helpers, blending and normal population limits. Verify owned clone state, movement, expiry, sprite frames and rendered alpha/layer.

Co-Authored-By: Codex <[email protected]>
2026-11-04 03:04:19 +03:00
TiggerTakkyTTTandCodex 50888d15bc Server API: pin lifecycle replies
Add reusable JSON golden cases for the stable status, metrics, logs, start, stop, and restart response contracts. Normalize only release, process, and elapsed-time values while checking their types.

Fixes #8816

Co-Authored-By: Codex (GPT-5.6) <[email protected]>
2026-11-04 06:51:24 +07:00
Delete228andCodex 31ce9b1787 Support named native mob damage types and combat classifications
Preserve custom source IDs through ordinary mob damage, protection, immunity, knockback and loot paths. Skip enchantment protection for bypasses_effects damage.

Co-Authored-By: Codex <[email protected]>
2026-11-04 02:48:49 +03:00
AfrobamaYTandClaude Opus 5.5 4f6a3fc20c Native mods: a camera to draw the world from (native_camera_pose)
A client mod can move and turn the camera a frame is drawn from: after
vanilla placed it, native_camera_pose gets its position, yaw and pitch and
may set them (a cinematic, replay or film camera). The picture, culling, the
sound's listener and what faces the camera follow it; the player, their aim,
the hand and chunk loading stay the player's. Values that aren't finite keep
vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:38:35 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 c726e412a1 Translucent Batching: the box reads Off where the GPU can't batch, as its label does
Fixes #8759.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 00:13:16 +01:00
itarqos5andClaude Opus 5 d934726850 test(lod bench): report how many spans a far column holds, not just the mean
`lod_grid_bench` printed a mean of 5.3 to 5.9 spans a column. A mean
alone says nothing about the shape of the distribution, which is what
decides whether a column's spans fit a fixed inline capacity or take a
heap block; sizing `Spans` off the mean would be a guess.

Print the distribution beside it, with a running total so the share that
fits any given capacity can be read straight off.

Measured at seed 0, 40 tiles a level, Full far detail: four spans or
fewer covers 38% of columns at level 0, 28% at level 2, 26% at level 4
and 32% at level 6, with a tail reaching 14 to 16.

These are the spans worldgen returns, before the client's `shade_sky`
splits fluid and leaf runs a light level at a time.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-03 23:01:49 +00:00
itarqos5andClaude Opus 5 ec5b8b4c31 perf(far terrain): reduce a far chunk only to the cell width that reads it
A chunk's detail arrived as `ReducedColumns`, which reduced the 16x16
columns to cells 2, 4, 8 and 16 blocks wide up front: 85 columns beside
the chunk's 256, and four passes of `Column::reduced` over every span of
every footprint.

One chunk is covered by one far tile, so one of the four widths is what
gets read, and a chunk whose detail arrives while full-detail terrain
still draws it reads none. Building all four cost 405 us of a far
terrain thread for every chunk the world sent (measured over 200 chunks
of 5.7 spans a column, the mean `lod_grid_bench` reports); reducing on
the first read of a width costs 103 us, and nothing at all for a chunk
no far tile draws reduced.

`OnceLock` a level each, so a second mesh job reading the same width
takes the reduction already built rather than racing to repeat it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2
2026-11-03 22:59:03 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 c1ab6def52 LAN test: fill the socket until the outbox waits, so the disconnect waits on Windows too
Windows takes a write of any size whole while its buffer has room, so the 64 MiB the test sent all went
through and the disconnect with it. Fixes #8734.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:37:05 +01:00
degepie f23ab6c91e Merge remote-tracking branch 'origin/main' into fix/bare-ids 2026-11-04 03:16:59 +05:00
degepie 0daeda2021 Merge remote-tracking branch 'origin/main' into fix/throw-bucket-triggers 2026-11-04 03:12:26 +05:00
Nikita Maximilian GrimmandClaude Opus 5.5 ee8c5b7b13 install.sh signature test: skip on Windows, where install.sh doesn't install
Fixes #8708.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:59:16 +01:00
NerfPhaselock 6ea9e9608f Merge remote-tracking branch 'upstream/main' into perf/gpu-culling-g1b
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-11-03 16:39:17 -05:00
ErikKalikasandClaude Opus 5.5 9841d6bffd Native mods: a mod menu type opened with extra data (openMenu with an extra-data writer)
A mod block opening its content mod's menu type asks native_menu_extra_data for bytes
of the mod's own; a horizon:menu_extra payload carries them with the window and the
block's position to Horizon clients right before OpenScreen. The client takes the
block from it instead of the last clicked one, and native_screen_pos /
native_screen_extra_data read both while the mod's screen is open.

Fixes #8675

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:28:50 +02:00
degepieandClaude Opus 5.5 dd3b316fc9 Advancements: looking_at sees every entity, in every trigger
PlayerPredicate.matches looks for the entity the player looks at
(ProjectileUtil.getEntityHitResult over every non-spectator entity but
the player, then hasLineOfSight) whatever the trigger. Horizon searched
only the mobs, and only for using_item, so a looking_at boat, minecart,
armor stand, frame, painting or crystal never matched, and nothing
matched in tick, location or any other trigger.

looking_at_entity now walks every entity list plus frames, paintings
and crystals, and player_entity sets looking_at for every trigger. The
per-tick tick and location triggers cast the ray only when one of their
criteria may ask (names looking_at or a reference predicate), worked
out once per tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 02:10:03 +05:00
OpenAI Codex 11121fc59c Merge public main with visibility audit and span test isolation fixes
Co-Authored-By: Codex <[email protected]>
2026-11-03 16:08:24 -05:00
huiandClaude Opus 5.5 d9692d81b1 pr_ready: run the Python checks beside cargo
The Python tests and the public-tree check only read files, yet cargo
waited for them: about two minutes of every run. They now run in a
thread beside rustfmt, clippy and the tests; the harnesses, which build
with cargo, still come after it, and --verbose keeps everything in
order so the output doesn't interleave.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 21:31:36 +01:00
OpenAI Codex e4b9dfa9a4 Format the split material census adapter
Co-Authored-By: Codex <[email protected]>
2026-11-03 15:23:32 -05:00
degepieandClaude Opus 5.5 5d6752f7bd Potion and cloud kills and falling-block hits fire their advancement triggers
A harming splash potion or lingering cloud hurt mobs through Mob::hurt, a
player_attack from the player whoever threw it, and never recorded the thrower
in last_hurt_by_player, so on the server thread the kill reached nobody's
player_killed_entity. They now deal indirectMagic(potion or cloud, owner)
(HealOrHarmMobEffect.applyInstantaneousEffect), the thrower's kill when a
player threw it, and the mob keeps the potion or cloud as the killing blow's
direct entity (Mob::last_direct).

A falling block hitting the player now fires entity_hurt_player with the
falling block as causing and direct entity (FallingBlockEntity.causeFallDamage,
LivingEntity.hurtServer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 01:18:33 +05:00
OpenAI Codex 797d6d0ae9 Count all split material textures in the new GPU census
Co-Authored-By: Codex <[email protected]>
2026-11-03 15:11:37 -05:00
GabrielNichols 06f5846948 Merge remote-tracking branch 'upstream/main' into codex/port-pack-harness-verdict 2026-11-03 17:08:33 -03:00
degepieandClaude Opus 5.5 02205a31e0 Advancement predicates read a bare id as a minecraft: one
A data pack that writes an id, tag or registry key without a namespace
("panda", "#planks", "the_nether", "inventory_changed") never matched:
the predicates compared the JSON string literally. Vanilla reads them
through Identifier codecs, where a missing namespace is minecraft.

`full` qualifies the id wherever a predicate compares one: id_matches
(plain id and #tag), the location dimension, entity sub-predicate keys,
effect keys, the loot condition type, the trigger name, the inventory and
recipe indexes, and recipe_unlocked / changed_dimension. damage_source's
tags[].id is a holder set (TagPredicate) and goes through id_matches.
The entity predicate's short key aliases follow from the qualification;
the non-vanilla "type" alias is gone (vanilla 26.3 refuses that key).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 00:56:01 +05:00
OpenAI Codex 6833688d8a Merge public main preserving material and RGB test modules
Co-Authored-By: Codex <[email protected]>
2026-11-03 14:53:12 -05:00
hildlabs 85e7b3bfb5 Merge remote-tracking branch 'origin/main' into shader-option-sliders
# Conflicts:
#	docs/api/shaders.md
2026-11-03 20:40:47 +01:00
GabrielNichols 2fb4d8f7a3 fix(port-pack): reject empty and failed harness verdicts 2026-11-03 16:34:24 -03:00
Ludovic MarieandClaude Opus 5.5 0e98e286f7 Launcher: the Shop tab, the founder set to claim for free while its window is open
The Wardrobe page gets Wardrobe | Shop tabs when the cosmetics service shows the
Shop. The Shop has the founder set: when it can be claimed until, the time left, one
Claim button, the four items on cards (Free, Owned, Equipped, Soon), and every state
the mockups list: signed out, loading, offline, service down, claiming, claimed,
owned, not open yet, a preview for one account, closed (the items are gone; owned
ones stay), the claim failing. Equip stays in the Wardrobe. No price anywhere.

HORIZON_SHOP_STATE=<state> previews each one in development builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 20:14:58 +01:00
CorbonatandCodex b87aee2af6 Adapt glint test quad to the merged model fields
Use upstream's neutral light, color, AO, render type and shade defaults in the generated glint fixture. Preserve its geometry, UVs and assertions.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-03 22:13:07 +03:00
AkirabaneandClaude Sonnet 5.5 dd762d48ff Commands: /execute on owner no longer finds the guest in place when the owner is not in the world
`Game::target_with_uuid` answered `Target::Player` for the level's own player
UUID. During a guest's turn the player in place is that guest, so a wolf
whose owner is the world's player (and not among the players online, for
example a single-player world moved to a dedicated server) made
`execute as <wolf> on owner run give @s ...` act on whoever ran the command.

Vanilla's `on owner` is `OwnableEntity.getOwner`, which resolves the stored
UUID through the level (`EntityReference.getLivingEntity`, then the level's
UUID lookup): an owner who is not in the level gives no entity. The player in
place is now the acting guest's UUID during a guest's turn and the level's
player otherwise. An owner who is online as another guest was already found
first by `target_by_uuid`.

Fixes #8488

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 20:11:23 +01:00
degepieandClaude Opus 5.5 36b6b3e935 Thrown snowballs, eggs and pearls and bucketed mobs fire their advancement triggers
A player's snowball, egg or ender pearl that hurts a mob (0 damage
included, as LivingEntity.hurtServer reports it) now credits the hit to
the thrower and fires player_hurt_entity with the projectile as the
direct entity, as the content-mod throwable arm already did. Scooping a
fish, axolotl or tadpole into a water bucket now fires
player_interacted_with_entity with the water bucket and the mob, as
ServerGamePacketListenerImpl.handleInteract does after
Bucketable.bucketMobPickup succeeds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-11-04 00:06:36 +05:00
MSedgeMC e4db5de23c port_map: ExperienceOrbRenderer.getBlockLightLevel names the orb's rule
The submit rows said the billboard was inline; the light the billboard is
drawn with had no row at all.
2026-11-03 18:33:34 +00:00
MSedgeMC 8e935b200d test: pin the orb's light, its probe and its quad
A dark cell packs block light 7 where the old code packed 10 in both nibbles,
torchlight 9 clamps to 15, and daylight keeps the sky byte; the quad's four
corners resolve to a quarter of its height below the orb.
2026-11-03 18:33:34 +00:00
MSedgeMC 26261d9643 fix: an experience orb is lit seven levels above its block, and its sprite hangs right
ExperienceOrbRenderer.getBlockLightLevel is the level's block light plus
seven, clamped, with the sky light left alone; the pass raised both nibbles
to a floor of 10 instead. submit's quad runs from (-0.5, -0.25) to
(0.5, 0.75) under a 0.3 scale after translate(0, 0.1, 0), so the sprite is a
whole block tall about the orb and hangs a quarter of it below; Horizon drew
a symmetric 0.3 square 0.075 too low. The probe is the orb's eye, half its
box above its feet.
2026-11-03 18:33:34 +00:00
AkirabaneandClaude Opus 5.5 c4bc3a29d1 Worker pool: release idle generators while the client keeps meshing
After a world loads, the client keeps queueing a few meshes a second for a
minute or more (vanilla's leaf distance pass reshapes the trees). Two things
in the shared pool then kept the server's generator buffers and caches,
which nothing reads until the player moves into new ground:

- Every wake restarted every sleeping worker's idle wait, even a worker
  that got no job, so with jobs queued every few seconds no worker ever
  reached IDLE_RELEASE. A worker now counts from its own last job.
- `all_idle` (#7519 empties the generator's shared caches there) needs
  every worker idle at once, which never happens while one source stays
  busy. A source with nothing queued or running for IDLE_RELEASE is now
  told on its own: `idle` for each worker's state and `all_idle`, once
  until its next job. The release frees nothing a later job needs: the
  generator's caches are emptied whole, as at their limit (#7519).

Each freed release hands the pages back to the OS (`release_free_memory`).

Measured with the alloc-census build, seed 1234, RD 12, offscreen, 150 s
after opening a new world (two runs): stream.generate 171.8 -> 45.3 MB,
the biome cache 1366 chunks -> 0, the live heap 645 -> 527 MB, the resident
set 1063 -> 1001-1014 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 19:02:00 +01:00
LokiandAntigravity 9cda105a19 chat: include entity_types and wind charges in summon autocomplete (fixes #8419)
Co-Authored-By: Antigravity <[email protected]>
2026-11-04 02:50:13 +09:00
ErikKalikas 9a99293888 Merge remote-tracking branch 'upstream/main' into mod-reload-listeners
# Conflicts:
#	crates/horizon-client/src/mod_api.rs
#	crates/horizon-mod-api-gen/dispatch-tests/src/lib.rs
2026-11-03 19:49:27 +02:00
OpenAI Codex 3a0132d80e Make encoded material GPU controls detect filtering and mip regressions
Strengthen only the original synthetic oracle: fractional contrasting data, hostile unused mip contents and deliberately invalid filtering sampler negative control. Production/API code is byte-identical to the already completed full-check head ec6054c.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:48:18 -05:00
AkirabaneandClaude Sonnet 5.5 ce0d76008a fix(boats): a boat in powder snow is slowed and throws snowflakes
`PowderSnowBlock.entityInside` makes any entity that is not a living one stuck
(`makeStuckInBlock` 0.9, 1.5, 0.9) and, on the server, sends a snowflake when
the entity moved horizontally. Boats did neither: no particle showed when a
boat entered powder snow, and it was not slowed (#8156).

A boat whose box, or the box it had before its move, reaches into powder snow
now keeps a stuck multiplier at the end of its tick, as `AbstractBoat.tick`'s
`applyEffectsFromBlocks` does, and its next move goes its velocity times the
multiplier and spends the velocity (`Entity.move`). The server also sends the
snowflakes, twice a tick as `AbstractBoat.tick` applies the block effects twice,
for boats a player drives as for the others. A boat that is not in powder snow
moves exactly as before. Only the boat's own box is scanned.

Refs #8156

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:46:07 +01:00
AkirabaneandClaude Sonnet 5.5 d056f6d157 horizon-server: Prometheus metrics by dimension (loaded chunks and entities)
The game's status now lists each level's loaded chunks and entities
(`levels`), the level in place and the parked ones counted the same way
(players and block entities apart). prometheus() turns them into
horizon_loaded_chunks{dimension} and horizon_entities{dimension}; a level
with no count has no line rather than a 0. The docs drop the item from
"Not there yet".

Fixes #8191

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 18:43:31 +01:00
Arslan ArmanulyandClaude Opus 5.5 db726a1c49 Shader packs: read shaders.properties with the feature macros, as Iris does
Iris 1.11.7 reads shaders.properties with IRIS_FEATURE_<name> defined for
the features it has, whether the pack lists them as optional, as required
or not at all (probe pack); its programs still see only the optional ones.
Horizon defined them only after reading the file, so a custom uniform or
a screen behind `#ifdef IRIS_FEATURE_*` was left out. The Shader Options
page reads the file with the same macros.

Fixes #8493

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:37:01 +05:00
Arslan ArmanulyandClaude Opus 5.5 29d8afbf25 Shader packs: smooth() fade times count tenths of a second, as in Iris
Iris 1.11.7's values in our probe fit a half-life of a tenth of the fade
time's seconds (probe pack across the scene suite's weather changes):
smooth(rainStrength, 20, 20) reads 0.87 four seconds after the rain is
full, where Horizon's fade of 20 seconds gave 0.2. Horizon's smooth()
lagged ten times behind those values.

Fixes #8492

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 22:37:01 +05:00
Ludovic MarieandClaude Opus 5.5 d7bf2ad63c Merge main (main's rustfmt fix for client_lib.rs, #8040)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:34:43 +01:00
hildlabsandClaude Opus 5.5 01d6c3d88b Shader packs: a Horizon pack's float and int options are sliders
On a Horizon pack's options page a float or int option is a slider, as FOV
is, where it was a button stepping through every value. A float takes the
nearest step from its minimum (the last slot its maximum), an int whole
numbers, rounded to the f32 the pack's override gets so a slider back in the
middle is the default exactly. The value is saved, and the pack rebuilt, once
when the slider is let go, and only if it changed. Shift-click puts it back
to its default; Tab, Enter and the arrows work it as they do vanilla's
sliders, a step at a time and stopping at the ends. Bool and choice options
stay buttons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:25:16 +01:00
CorbonatandCodex ab9032a188 Merge pinned main 49a6e02 into GUI glint atlas fix
Preserve upstream line shader setup alongside GUI glint atlas preparation.
Keep local Windows parity helper repairs outside this contribution.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-03 20:16:12 +03:00
OpenAI Codex ec6054c5c0 Record baseline native material API sanity-check input
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:14:28 -05:00
OpenAI Codex 881a8b8be6 Record pinned public-main API integration inputs
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:13:25 -05:00
OpenAI Codex b227d63fe8 Record pinned public-main material API integration inputs
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:13:25 -05:00
OpenAI Codex a900d9ad88 Initialize authored-content API in a new public-main test literal
Actual final-main Clippy/test compile identified the newly added Interface construction; retain its previous neutral48-byte contents. Independent production behavior unchanged.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:11:38 -05:00
OpenAI Codex 49d6e26bc2 Initialize material API fields in new public-main test literals
Preserve existing fixture behavior with absent companion maps and no block-surface scope. Consulted only the necessary current-main struct construction sites for this integration.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:11:13 -05:00
OpenAI Codex 5ca8699133 Initialize material scope for the new main line geometry API
The independent candidate reserves block companions for terrain; the newly added main line draw has no colour surface identity and explicitly uses defaults.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:10:46 -05:00
OpenAI Codex 2d0f159464 Record nonbehavioral material integration comment updates
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:08:06 -05:00
OpenAI Codex ef9d8b67d5 Transport terrain resource-pack material maps to shader samplers
Proposed independently developed replacement for closed #7800, addressing #7747. Preserve colour-layer identity across split arrays, nearest encoded data transport, neutral defaults, resource ownership and colour-selected companion frames. Add original CPU and actual-GPU controls with a pinned input/exposure ledger. Frozen candidate production hunks preserved; integration adjusts a test-module append and clarifies comments.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 12:07:54 -05:00
OpenAI Codex 9315b5a43f Preserve authored ordinary uniform initial values
Proposed independently developed replacement for closed #7842, addressing #7811. Preserve compiler-evaluated constants in actual UBO layout, retain runtime-write precedence, and add original typed and real-GPU controls. Public research input ledger records pinned permissive shader-pack inputs, frozen candidate lineage and exposure limits.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-03 11:57:59 -05:00
Ludovic MarieandClaude Opus 5.5 76ae2acc14 Merge #8122, #8118 and #8108 (management series) into entity broadcast range management
Stacks the management series so the queue lands them in order; every
handler, setter, port-map row and harness check of the four is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 17:40:00 +01:00
ErikKalikasandClaude Opus 5.5 420ef24de8 Native mods: a mod's own data folders, read on each data load
native_add_server_reload_listeners lists a mod's listeners (NeoForge's
AddServerReloadListenersEvent): an id and a folder under
data/<namespace>/. On each data load of a server (a world opening,
/reload), before native_server_data_loaded,
native_server_reload_listener_apply hands over the folder's .json files
as vanilla's SimpleJsonResourceReloadListener reads them: <ns>:<path>
ids, the top pack's file for each id over the resource root and the
world's enabled data packs, a file that isn't JSON logged and left out.
Fixes #8400.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 18:30:59 +02:00
NerfPhaselockandClaude Opus 5.5 ce776dff98 Renderer: GPU culling leaves fading sections to the CPU
Since the pass culls the near solid sections itself, a section it draws
appears whole where a CPU frame fades it in or out (vanilla's chunk
fade-in, far terrain's fades). A recorded section now gets FADING in its
record, which cull skips, and its walk mark, so the CPU draws it with its
fade, exactly while the instance loop's own expression for it is not a
whole section's (section_visibility and the far-terrain fade, bit for bit).
The flag is decided at upload, when a section leaves or is held for far
terrain, and anew for every record when the fade-in time changes.

A heap of fade ends in GpuCulling says when to look again, so a frame with
no end due does no fade work; Records counts the flagged records, and
switching GPU culling off forgets the flags with the records. The debug
check also compares each flag and mark with what the fades say.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 09:16:16 -05:00
AkirabaneandClaude Opus 5.5 acc6d017c4 A server sends its world packs' tags for every registry, not only dialogs
TagNetworkSerialization.serializeToNetwork sends each synchronized registry's
tags as TagLoader bound them from the whole pack stack. Horizon's server read
the resource root's tag files only and merged the world's data packs into
the dialog tags alone, so no client (vanilla or Horizon) was told that a
pack put dirt in #minecraft:mineable/pickaxe or a block in #climbable. After
/reload, keep_registries read the root's tags again without any pack, the
packs' dialog tags included.

merge_pack_tags merges, for every synchronized registry the world's packs
have tag files for (tags/<path>, a mod's registry under tags/<ns>/<path> as
Registries.tagsDirPath has it), the root's files and then each pack's in
order ("replace": true, nested tags expanded), at load and on /reload.
Registries no pack touches keep load_tags' tags.

Refs #7843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 14:03:41 +01:00
TiggerTakkyTTTandCodex 63554a33a7 Merge upstream main into entity range management
Preserve entity broadcast range parity alongside the merged allow-flight management changes and log clamped values.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 19:24:37 +07:00
AkirabaneandClaude Sonnet 5.5 685d0b1e8c Buckets: the fluid ray passes through the hollow of a torch, fence or flower
pick_fluid stopped at any non-air cell without a fluid instead of clipping
the block's outline, so a water bucket could not fill through the empty part
of a torch's cell. ClipContext.Block.OUTLINE lets the ray through; the ray
now stops only where it meets an outline box. This covers the buckets
(empty, fish), glass bottle, lily pad / frogspawn and spawn eggs, which
all use this ray.

Fixes #8180

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:15:02 +01:00
AkirabaneandClaude Sonnet 5.5 ee480fe577 fix(shaders): a bool member of a std430 buffer translates
GLSL gives a `bool` in a `layout(std430)` buffer a size and an alignment of
4. naga does not let a bool into a storage buffer at all, so the program
failed with "Global variable [6] '' is invalid: ... The type is not
host-shareable": no name, no member, and the line of the include site (#8175).

Such a member now becomes a `uint` of the same size and every use of it is
rewritten before the stage is scanned: a read is `(flag != 0u)` (also through
an instance, `state.flag`, and an array, `flags[i]`), a plain assignment is
`flag = uint(...)`. Nothing is added or removed from a line, so the pack's
line numbers stay right. It applies to render and compute programs.

What the rewrite cannot make sure of is refused with the member's name and its
line, instead of naga's anonymous error: a compound assignment (`flag |= x`),
an assignment used as a value, a local or parameter of the same name, a `bool`
vector member and an instance array of such a buffer.

Tests: the five shapes of the issue translate; the refusals name the member and
the line; the rewritten text is checked exactly; a compute program reads and
writes a bool member. Before the change the first test fails with the exact
message of the issue for every shape.

Fixes #8175

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 13:05:45 +01:00
AkirabaneandClaude Opus 5.5 54a8a4e47c Superflat worlds without structures place their features and lakes too
A superflat world that decorates (`features` or `lakes`) but generates no
structure set, because `generate_structures` is off or its
`structure_overrides` is `[]`, still went through FlatTerrain, which lays the
layers and the Void platform only. FlatLevelSource decorates with
adjustGenerationSettings whatever its structures do.

FlatTerrain::structure_settings becomes pipeline_settings: the settings of a
superflat world that lets a structure set generate or that decorates. World
loading streams such a world on the flat pipeline when its structures generate
or it decorates; the pipeline then has no structure starts when
generate_structures is off, and no set when the overrides are empty.
FlatTerrain keeps the worlds with neither, and the Void.

Refs #2912

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:29:30 +01:00
AkirabaneandClaude Sonnet 5.5 209f2fd34d fix(motion): a passenger freezes in powder snow, and the frostbite overlay shows it
A player carried by a boat only ran `passenger_pose_tick`, never the part of
`LivingEntity.aiStep` that applies the effects of the blocks it is in. Powder
snow's FREEZE never added a tick of frost to the window's own player, and
nothing thawed it, so `ticks_frozen`, which the frostbite overlay reads, stayed
where it was: the server froze and hurt the rider, who saw no frost (#8156).

`passenger_block_effects_tick` samples the blocks the passenger is in, as the
client does on foot, and thaws two ticks a tick out of powder snow as the
server does; the riding branch of the client tick calls it after the pose.
The server's copy of a riding guest is untouched: it already freezes in
`server_block_damage`.

Refs #8156

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-03 12:10:24 +01:00
AkirabaneandClaude Opus 5.5 b40b532a67 Parity harness: superflat chunks against vanilla's FlatLevelSource
tools/test_flat_generation_parity.py runs VanillaChunkGenerationOracle with
HORIZON_FLAT=<flat_level_generator_preset>, which builds a real FlatLevelSource
from the preset registry (with ChunkMap's random state for a generator without
noise, and createState's structure state), and chunk_dump with the same
variable on OverworldGenerator::flat. Every block, quart biome and block entity
of the target chunks is compared after decoration, for the three presets that
decorate (Overworld, Desert, Tunnelers' Dream) and three that only place layers
and structures. A decorating preset must hold blocks beyond its layers in
vanilla, so the decoration is known to be covered.

Refs #2912

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:03:18 +01:00
AkirabaneandClaude Opus 5.5 679b9f53be Superflat worlds with structures place their features and lakes
A superflat world on the structure pipeline (Generator::flat, #5509) built its
decoration with no placed feature at all. FlatLevelSource decorates with
FlatLevelGeneratorSettings.adjustGenerationSettings of its biome (26.3
bytecode): the lava lakes of createLakesList in the lakes step when `lakes` is
on; the biome's own features when `features` is on and the layers are not all
air (or the biome is the_void), except those of the underground and surface
structure steps, and of the lakes step when `lakes` is on.

FlatSettings now carries `features` and `lakes`, and Decoration::new_with takes
that adjustment. The FillLayerFeatures stay last in TOP_LAYER_MODIFICATION, as
the builder adds them after the biome's features.

Refs #2912

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 12:03:17 +01:00
TiggerTakkyTTTandCodex f90f7aac52 Add live entity broadcast range management
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-11-03 17:00:44 +07:00
Arslan ArmanulyandClaude Opus 5.5 514bada684 Shader packs: currentRenderedItemId per draw, and the hand per held item
`currentRenderedItemId` was a frame uniform reading 0 in every program, and
a hand program that read it without declaring it failed to translate, so
the hand fell back to gbuffers_textured_lit.

- `HzDraw`'s fourth int, a pad until now, is `currentRenderedItemId`: -1 for
  terrain, water and the sky, 0 for entity runs (items drawn in the level
  are not done).
- The hand draws one run per arm and held item, each with its own `HzDraw`
  slot. A block item reads its block's block.properties id, or 0 when that
  file lacks it, whatever item.properties says; any other item reads its
  item.properties id, 65535 when the file lacks it, and 0 without the file.
- The hand's programs may read `currentRenderedItemId` undeclared; terrain's
  and entities' may not.

This is what Iris 1.11.7 does, observed with probe packs of our own.

Part of #6812

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 09:23:27 +05:00
CorbonatandCodex 8a529538f2 Merge pinned upstream main for GUI glint validation
Integrate e2385d0ddb while retaining logical item-atlas glint coordinates, metadata rejection and GUI/CIT separation.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-03 01:51:00 +03:00
CorbonatandCodex c5adff2477 Merge main and reject invalid GUI glint atlas texture metadata
Preserve main's face-normal and sprite-rectangle caches while keeping GUI
vanilla atlas coordinates separate from CIT sprite-local glint UVs.
Reject malformed texture sections before stitching and read metadata from
the same prepared snapshot as rendered textures. Add rejection, control,
prepared-resource and real-oracle emitted-UV regression coverage.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-02 14:55:28 +03:00
CorbonatandCodex 709fa73ee3 Fix GUI item glint UV scale using the vanilla item atlas
Reconstruct the pinned item atlas layout from the active sprite sources so
standard GUI glint receives the same atlas UVs as vanilla. Use the device
limit and selected filtering when rebuilding the renderer; keep the HUD
texture coordinates and CIT glint coordinates separate.

Add independent vanilla UV and packing regressions, including animation,
generated sources, padding, invalid metadata and atlas-limit boundaries.

Co-Authored-By: Codex (gpt-6.1-sol) <[email protected]>
2026-11-02 13:16:52 +03:00
344 changed files with 22346 additions and 2572 deletions

No files matched your search

@@ -173,6 +173,16 @@ impl Registries {
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
if self.summonable.is_empty() {
self.summonable = strings(&r["summonable"]);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types = strings(&r["entity_types"]);
self.entity_types.sort();
self.entity_types.dedup();
}
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
@@ -236,6 +246,16 @@ impl Registries {
fill(&mut self.templates, &e.templates);
fill(&mut self.persistent_components, &e.persistent_components);
fill(&mut self.learnable_recipes, &e.learnable_recipes);
if self.summonable.is_empty() {
self.summonable.clone_from(&e.summonable);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types.clone_from(&e.entity_types);
self.entity_types.sort();
self.entity_types.dedup();
}
for (name, ids) in &e.by_registry {
if !self.by_registry.contains_key(name) {
self.by_registry.insert(name.clone(), ids.clone());
@@ -378,6 +378,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -11,6 +11,7 @@ use crate::{
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
font::Glyph,
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
@@ -660,7 +661,7 @@ fn sign_text_calls(
} else {
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).1;
let advance = |c: char| (scene.glyph)(c).advance;
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
text.lines
.iter()
@@ -699,10 +700,19 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
let (cell, advance) = (scene.glyph)(ch);
if let Some(r) = cell {
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
let g = (scene.glyph)(ch);
if let Some(cell) = g.rect {
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
let (x0, x1) = (x + g.left, x + g.right);
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let c =
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
@@ -721,7 +731,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
});
}
}
x += advance;
x += g.advance;
}
};
if call.outline != 0 {
@@ -818,8 +828,8 @@ pub struct Scene<'a> {
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
pub glyph: &'a dyn Fn(char) -> Glyph,
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
@@ -3342,7 +3352,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
@@ -3610,7 +3620,13 @@ mod tests {
w: 8,
h: 8,
};
(Some(rect), 6.0)
Glyph {
rect: Some(rect),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
}
};
let scene = Scene {
camera: DVec3::ZERO,
@@ -3660,6 +3676,78 @@ mod tests {
assert!(out.iter().all(|v| v.pos[2] == 0.0));
}
/// BakedSheetGlyph draws a glyph over its own box from the pen
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
#[test]
fn sign_text_draws_each_glyph_over_its_own_box() {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.125,
w: 8,
h: 16,
}),
left: 0.0,
top: 0.0,
right: 4.0,
bottom: 8.0,
advance: 5.0,
offset: 0.5,
};
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,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
x: 10.0,
y: 2.0,
text: "ab".into(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
let boxes: Vec<_> = out
.chunks(4)
.map(|q| {
let xs = q.iter().map(|v| v.pos[0]);
let ys = q.iter().map(|v| v.pos[1]);
[
xs.clone().fold(f32::MAX, f32::min),
xs.fold(f32::MIN, f32::max),
ys.clone().fold(f32::MAX, f32::min),
ys.fold(f32::MIN, f32::max),
]
})
.collect();
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
@@ -3714,7 +3802,13 @@ mod tests {
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
Glyph {
rect: (c != ' ').then_some(rect),
right: 8.0,
bottom: 8.0,
advance: wide.get(),
..Glyph::default()
}
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
@@ -3879,7 +3973,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let mut verts = Vec::new();
@@ -3964,7 +4058,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
@@ -4,6 +4,7 @@ use crate::{
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
},
chunk::Chunk,
font::Glyph,
hud::Rect,
mesher::Vertex,
signs::{SignData, SignText},
@@ -355,7 +356,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Reverse local-position order to check that cache indexing does not
@@ -443,7 +444,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -497,7 +498,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([100, 70, -32], 0)];
@@ -651,18 +652,19 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
};
let sign_data = &data;
let sign = move |_: [i32; 3]| Some(sign_data);
let glyph = |_: char| {
(
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
6.0,
)
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
};
let scene = Scene {
camera: DVec3::new(2.25, 64.5, -3.75),
@@ -735,7 +737,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -836,7 +838,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -890,7 +892,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -951,7 +953,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1046,7 +1048,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1134,7 +1136,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
@@ -1209,7 +1211,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1287,7 +1289,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
sign: &|_| None,
banner: &banner_patterns,
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1353,7 +1355,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1400,7 +1402,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -1505,7 +1507,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -58,6 +58,24 @@ impl DataPack {
}
out
}
/// The files of `data/<namespace>/<folder>/` in every namespace, as
/// `list` names them; a folder pack walks only those folders.
pub fn list_folder(&self, folder: &str) -> Vec<String> {
let files = self.list_kinds(&[folder]);
let Source::Zip(_) = &self.source else {
return files;
};
files
.into_iter()
.filter(|f| {
f.strip_prefix("data/")
.and_then(|r| r.split_once('/'))
.is_some_and(|(_, r)| {
r.strip_prefix(folder).is_some_and(|r| r.starts_with('/'))
})
})
.collect()
}
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
+1 -1
View File
@@ -24,7 +24,7 @@ pub const DEFAULT: &str = "minecraft:default";
/// A glyph ready to draw: atlas UVs and its box relative to the pen
/// position (GlyphBitmap left/top/right/bottom), in GUI pixels.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, Default)]
pub struct Glyph {
pub rect: Option<Rect>,
pub left: f32,
File diff suppressed because it is too large. Load diff
@@ -885,6 +885,7 @@ mod tests {
sorted_indices: None,
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1081,6 +1082,7 @@ mod tests {
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1307,6 +1309,7 @@ mod tests {
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -485,6 +485,7 @@ impl Scene {
let f = &self.frame;
Frame {
weather: &[],
hand_items: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: DVec3::from_array(f.camera),
yaw: f.yaw,
@@ -14,9 +14,11 @@
//! each in one draw (the pipelines cull the faces turned away), with the origin
//! the walk would give it: `resident::origin`'s f32 bits, copied, never
//! computed on the GPU. Within the render distance the CPU's walk then lists
//! only the sections whose translucent quads it draws, or that have no record
//! (`Resident::walk`); past it, it lists every section as before.
//! Unlike the walk, the pass draws sections fading in or out whole.
//! only the sections whose translucent quads it draws, that have no record, or
//! that fade (`Resident::walk`); past it, it lists every section as before. A
//! section whose instance carries a fade, fading in or out, has its record
//! flagged (`FADING`, gfx.rs `mark_fades`) until it is whole: the pass leaves
//! it out, and the CPU draws it with its fade.
//!
//! The graph is the one the walk reads that frame (`occlusion::Reach`). Its
//! search keeps the cells it reached as bits too, and `Occlusion` a generation
@@ -59,6 +61,7 @@ use super::{
upload::Uploads,
};
use glam::{DVec3, DVec4, Vec3};
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
/// One section (`gpu_culling.wgsl`'s `Section`).
#[repr(C)]
@@ -68,10 +71,14 @@ pub(super) struct Record {
key: [i32; 4],
/// The first vertex of its solid quads (a draw's base vertex, an i32: a
/// page holds fewer vertices), its solid quads, its box (`rounded_box`),
/// then a word kept at 0.
/// then its flags (`FADING`).
a: [u32; 4],
}
/// In `Record::a[3]`: the CPU draws the section's solid quads while it fades
/// (gfx.rs `SectionGpu::gpu_fading`), and the pass leaves it out.
const FADING: u32 = 1;
impl Record {
const FREE: Self = Self {
key: [0, 0, 0, -1],
@@ -86,9 +93,19 @@ impl Record {
a: [start as u32, quads, boxed, 0],
}
}
/// The record, `FADING` when `on`.
pub fn fading(mut self, on: bool) -> Self {
if on {
self.a[3] |= FADING;
}
self
}
fn page(&self) -> Option<u32> {
u32::try_from(self.key[3]).ok()
}
fn is_fading(&self) -> bool {
(self.a[3] & FADING) != 0
}
}
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
@@ -419,6 +436,8 @@ pub(super) struct Records {
all_dirty: bool,
/// Per arena page, the records on it.
page_sections: Vec<u32>,
/// The records flagged `FADING`.
fading: u32,
}
impl Records {
@@ -435,6 +454,7 @@ impl Records {
};
self.records[id as usize] = record;
self.add_to(record.key[3] as u32);
self.fading += u32::from(record.is_fading());
self.mark(id);
id
}
@@ -455,6 +475,7 @@ impl Records {
continue;
};
self.page_sections[page as usize] -= 1;
self.fading -= u32::from(self.records[id as usize].is_fading());
self.records[id as usize] = Record::FREE;
// shortcut: a teleport frees more than `DIRTY_LIMIT`, so `prepare` writes the
// whole table (0.6-2.6 ms at render distance 64); upgrade to a list of freed ids
@@ -502,6 +523,22 @@ impl Records {
self.add_to(page);
self.mark(id);
}
/// The section with `id` began to draw faded, or stopped (gfx.rs
/// `SectionGpu::gpu_fading`): its record is `FADING` when `on`.
pub fn set_fading(&mut self, id: u32, on: bool) {
let page = self.records.get(id as usize).and_then(Record::page);
debug_assert!(page.is_some(), "no record {id} to flag");
if page.is_none() || self.records[id as usize].is_fading() == on {
return;
}
self.records[id as usize].a[3] ^= FADING;
if on {
self.fading += 1;
} else {
self.fading -= 1;
}
self.mark(id);
}
/// Every section went.
pub fn clear(&mut self) {
self.records.clear();
@@ -510,12 +547,17 @@ impl Records {
self.dirty = Vec::new();
self.all_dirty = false;
self.page_sections.clear();
self.fading = 0;
}
/// The sections with a record.
pub fn live(&self) -> usize {
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
self.records.len() - free as usize
}
/// The sections whose record is `FADING`, which the CPU draws.
pub fn fading(&self) -> u32 {
self.fading
}
/// The record of the section with `id`; `None` for a free id.
#[cfg(any(test, debug_assertions))]
pub fn at(&self, id: u32) -> Option<&Record> {
@@ -549,6 +591,42 @@ impl Records {
}
}
/// The flagged sections by when their fade is over at the latest (gfx.rs
/// `SectionGpu::fade_end`), for gfx.rs `mark_fades` to look at them again
/// then, and the chunk fade-in time their flags were decided with: `None`
/// until a frame has decided them, just after GPU culling was switched on.
/// The ends of sections that went or were handed back are skipped as they come.
#[derive(Default)]
pub(super) struct Fades {
due: BinaryHeap<Reverse<(Instant, SectionKey)>>,
pub fade_ms: Option<u64>,
}
impl Fades {
/// The section at `key` is looked at again at `end`.
pub fn push(&mut self, end: Instant, key: SectionKey) {
self.due.push(Reverse((end, key)));
}
/// The section of the earliest end at or before `now`, taken out.
pub fn pop_due(&mut self, now: Instant) -> Option<SectionKey> {
let &Reverse((end, key)) = self.due.peek()?;
if end > now {
return None;
}
self.due.pop();
Some(key)
}
/// Every end goes; `fade_ms` stays.
pub fn clear(&mut self) {
self.due.clear();
}
/// The sections an end is waited for.
#[cfg(debug_assertions)]
pub fn pending(&self) -> impl Iterator<Item = SectionKey> {
self.due.iter().map(|r| r.0.1)
}
}
/// 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 {
@@ -574,6 +652,9 @@ pub(super) struct GpuCulling {
/// that the arena's compaction moves records while it borrows the
/// renderer's other fields.
pub records: Records,
/// When the flagged records' fades end, a field of its own as `records`
/// is: gfx.rs `mark_fades` takes both beside the renderer's sections.
pub fades: Fades,
/// This frame's pages with records: (page, first draw of its region, the
/// region's size).
pages: Vec<(u32, u32, u32)>,
@@ -700,6 +781,7 @@ impl GpuCulling {
reach_generation: None,
group: None,
records: Records::default(),
fades: Fades::default(),
pages: Vec::new(),
ids: 0,
n_updates: 0,
@@ -975,8 +1057,8 @@ mod tests {
}
/// Where a section's solid quads are: key, arena page, first vertex, quads,
/// and its box.
type Placed = (SectionKey, u32, u64, u32, u32);
/// its box, and whether it fades (`FADING`).
type Placed = (SectionKey, u32, u64, u32, u32, bool);
/// `gpu_culling.wgsl`'s `near`, on the CPU.
fn near(params: &Params, x: i32, z: i32) -> bool {
@@ -1025,7 +1107,7 @@ mod tests {
/// (and the pages `prepare` gives them).
fn keeps(params: &Params, words: &[u32], record: &Record) -> bool {
let (key, a) = (record.key, record.a);
if key[3] < 0 || a[1] == 0 || !near(params, key[0], key[2]) {
if key[3] < 0 || a[1] == 0 || (a[3] & FADING) != 0 || !near(params, key[0], key[2]) {
return false;
}
let c: [u32; 3] = std::array::from_fn(|i| key[i].wrapping_sub(params.first[i]) as u32);
@@ -1099,10 +1181,13 @@ mod tests {
Some([min, max])
}
/// Ids, page counts and changes against a plain list of what each id holds:
/// the lowest free id comes back, free ids at the end leave, counts follow
/// moves (to pages past the last too), a move keeps the box, a free is a
/// change, and more changes than updates take mark the whole table.
/// 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
/// move keeps the box and the flag, a free is a change, a flag set changes
/// its record only when it differs, the flagged ones are counted through
/// inserts, flags, removals and clears, and more changes than updates take
/// mark the whole table.
#[test]
fn records_keep_ids_page_counts_and_moves() {
let mut records = Records::default();
@@ -1140,7 +1225,7 @@ mod tests {
};
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
// What each id holds; `None`: a free id.
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11))];
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11, false))];
for step in 0..4_000 {
let live: Vec<u32> = (0..model.len() as u32)
.filter(|&id| model[id as usize].is_some())
@@ -1149,20 +1234,22 @@ mod tests {
rng.below(4 + step / 500),
u64::from(rng.below(1 << 20)) * 256,
);
match rng.below(5) {
let dirty = records.dirty.len();
match rng.below(6) {
op if op < 2 || live.is_empty() => {
let key = (rng.range(-9, 9), rng.range(0, 23), rng.range(-9, 9));
let (quads, boxed) = (1 + rng.below(500), rng.below(1 << 30));
let fading = rng.below(4) == 0;
let lowest = model
.iter()
.position(Option::is_none)
.unwrap_or(model.len());
let record = Record::new(key, page, start, quads, boxed);
let record = Record::new(key, page, start, quads, boxed).fading(fading);
assert_eq!(records.insert(record) as usize, lowest);
if lowest == model.len() {
model.push(None);
}
model[lowest] = Some((key, page, start, quads, boxed));
model[lowest] = Some((key, page, start, quads, boxed, fading));
}
op => {
let id = live[rng.below(live.len() as u32) as usize];
@@ -1170,6 +1257,12 @@ mod tests {
if op < 4 {
records.relocate(id, page, start);
(held.1, held.2) = (page, start);
} else if op == 4 {
let on = rng.below(2) == 0;
records.set_fading(id, on);
let changed = usize::from(held.5 != on);
assert_eq!(records.dirty.len(), dirty + changed, "step {step}");
held.5 = on;
} else {
records.remove(id);
// Marked: the GPU must have it free before the pass culls.
@@ -1183,11 +1276,13 @@ mod tests {
}
assert_eq!(records.ids() as usize, model.len(), "step {step}");
assert_eq!(records.live(), model.iter().flatten().count());
let flagged = model.iter().flatten().filter(|held| held.5).count();
assert_eq!(records.fading() as usize, flagged, "step {step}");
let mut pages = vec![0; records.page_sections().len()];
for (id, held) in model.iter().enumerate() {
let record = held.map(|(key, page, start, quads, boxed)| {
let record = held.map(|(key, page, start, quads, boxed, fading)| {
pages[page as usize] += 1;
Record::new(key, page, start, quads, boxed)
Record::new(key, page, start, quads, boxed).fading(fading)
});
assert_eq!(
records.at(id as u32),
@@ -1204,16 +1299,17 @@ mod tests {
let (mut each, mut all) = (Records::default(), Records::default());
for n in 0..1_000 {
let (key, page, start) = ((n % 31, n % 24, n / 31), n as u32 % 3, n as u64 * 256);
let record = Record::new(key, page, start, 1, 0);
let record = Record::new(key, page, start, 1, 0).fading(n % 7 == 0);
assert_eq!(each.insert(record), all.insert(record));
}
let gone: Vec<u32> = (0..1_000).filter(|id| id % 3 != 1 || *id >= 900).collect();
let counts = |r: &Records| (r.ids(), r.live(), r.fading());
for ids in [&[1u32, 4, 898][..], &gone[..]] {
for &id in ids {
each.remove(id);
}
all.remove_all(ids.iter().copied());
assert_eq!((all.ids(), all.live()), (each.ids(), each.live()));
assert_eq!(counts(&all), counts(&each));
assert_eq!(all.page_sections(), each.page_sections());
assert_eq!(free_ids(&all), free_ids(&each));
assert_eq!(all.dirty, each.dirty);
@@ -1228,8 +1324,9 @@ mod tests {
assert_eq!((all.ids(), all.live(), all.free.len()), (0, 0, 0));
assert!(all.page_sections().iter().all(|&n| n == 0));
assert!(records.fading() > 0);
records.clear();
assert_eq!((records.ids(), records.live()), (0, 0));
assert_eq!((records.ids(), records.live(), records.fading()), (0, 0, 0));
assert!(records.page_sections().is_empty() && records.dirty.is_empty());
for x in 0..DIRTY_LIMIT as i32 {
records.insert(Record::new((x, 0, 0), 0, 0, 1, 0));
@@ -1360,6 +1457,7 @@ mod tests {
("UNBOUNDED", u64::from(UNBOUNDED)),
("REACH", u64::from(REACH)),
("SKY", u64::from(SKY)),
("FADING", u64::from(FADING)),
] {
assert_eq!(constant(name), value, "{name}");
}
@@ -1784,7 +1882,9 @@ mod tests {
/// reached or not: the pass reads the copy it holds when a frame brings the
/// same graph (once through a bind group made anew), sends a new one after
/// a frame the CPU draws, and makes its copy anew for one too large for its
/// room.
/// room. A fifth of the sections fade, flagged anew before each case
/// (`FADING`, sent as updates or with the whole table): the pass leaves
/// them to the CPU.
#[test]
fn the_gpu_draws_what_the_integer_test_keeps() {
on_each_device(draws_match);
@@ -1792,11 +1892,14 @@ mod tests {
fn draws_match(device: &wgpu::Device, queue: &Queue) {
let mut rng = Rng(0x2545_F491_4F6C_DD1D);
// The flags have their own sequence: the cases are otherwise as before.
let mut flags = Rng(0x9E37_79B9_7F4A_7C15);
let (min_y, height) = (-64, 24);
let mut gpu = GpuCulling::new(device).expect("the pass");
let mut uploads = Uploads::new(device);
let mut held: BTreeMap<u32, Placed> = BTreeMap::new();
let (mut camera, mut compared, mut culled) = (DVec3::ZERO, 0, 0);
// Fading sections the frustum and the graph keep, left to the CPU.
let (mut camera, mut compared, mut culled, mut faded) = (DVec3::ZERO, 0, 0, 0);
let mut words = Vec::new();
// Cave culling's graph while a case has one (its generation, from x and
// z, radius, height and whether its search reached the sky) and its bits.
@@ -1897,7 +2000,8 @@ mod tests {
.records
.insert(Record::new(key, page, start, quads, boxed));
assert!(
held.insert(id, (key, page, start, quads, boxed)).is_none(),
held.insert(id, (key, page, start, quads, boxed, false))
.is_none(),
"id {id} twice"
);
};
@@ -1912,6 +2016,13 @@ mod tests {
place(&mut gpu, &mut held, &mut rng);
}
}
// A fifth of the sections fade: the flags that change go up as
// updates, or with the whole table (cases 4 and 18), and through
// case 7's frame the CPU draws.
for (&id, placed) in held.iter_mut() {
placed.5 = flags.below(5) == 0;
gpu.records.set_fading(id, placed.5);
}
let whole = gpu.records.all_dirty;
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
@@ -1947,10 +2058,12 @@ mod tests {
let mut params = tables(&input, &mut words).expect("the planes fit");
params.pages = gpu.pass_pages;
let mut expected = Vec::new();
for &(key, page, start, quads, boxed) in held.values() {
for &(key, page, start, quads, boxed, fading) in held.values() {
let record = Record::new(key, page, start, quads, boxed);
let kept = keeps(&params, &words, &record);
let kept = keeps(&params, &words, &record.fading(fading));
let reached = reach.is_none_or(|r| r.contains(key));
// In view, but fading: the CPU's.
faded += usize::from(fading && reached && keeps(&params, &words, &record));
if kept && let Some(r) = reach {
graphed += 1;
unreached += usize::from(!reached);
@@ -1965,7 +2078,7 @@ mod tests {
let origin = resident::origin(key, min_y, camera).as_vec3();
let instance = super::super::section_instance(origin.to_array(), 0, 1.0);
expected.push((page, start as u32, quads * 6, instance.map(f32::to_bits)));
} else if in_view_distance((cx, cz), radius, key.0, key.2) {
} else if !fading && in_view_distance((cx, cz), radius, key.0, key.2) {
culled += 1;
}
}
@@ -2012,8 +2125,10 @@ mod tests {
// Not a comparison of empty lists, nor of a test that keeps everything.
// The graphs leave out a real share of what the frustum keeps, and their
// sky band keeps sections above a box in some cases and not in others.
// The flags leave out about a fifth of what they would keep.
assert!(compared > 2_000, "{compared} draws");
assert!(culled > 2_000, "{culled} within the distance left out");
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
assert!(
unreached * 10 > graphed && unreached < graphed,
"{unreached} of {graphed} in view unreached"
@@ -9,7 +9,7 @@
// them as the GPU schedules, and where faces of two sections meet at the same
// depth the later draw keeps the pixel). Nothing here computes with floats: the
// origins are the CPU's bits, copied, and the frustum test adds integers the
// CPU computed.
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -45,7 +45,7 @@ struct Section {
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, its box
// (gpu_culling.rs `rounded_box`), then a word unused here.
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
a: vec4<u32>,
}
@@ -91,6 +91,8 @@ const UNBOUNDED: u32 = 0x80000000u;
// reached the box's top layer (the sky band).
const REACH: u32 = 1u;
const SKY: u32 = 2u;
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
const FADING: u32 = 1u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
@@ -186,7 +188,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
}
runs[id] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !near(s.key.x, s.key.z) || !reached(s.key.xyz) {
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|| !reached(s.key.xyz) {
return;
}
let page = u32(s.key.w);
+24 -2
View File
@@ -42,18 +42,32 @@ impl Output {
}
}
/// `HORIZON_HDR_OUTPUT`'s names, in order of preference.
const NAMES: [(Output, &str); 2] = [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")];
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
NAMES
.into_iter()
.filter(|(_, name)| only.is_none_or(|o| o == *name))
.map(|(output, _)| output)
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
}
/// The HDR output an offscreen renderer (`--offscreen`) offers: it has no display to ask, so
/// only the one `HORIZON_HDR_OUTPUT` names, for HDR screenshots without an HDR display.
pub fn offscreen_output() -> Option<Output> {
named(std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn named(name: Option<&str>) -> Option<Output> {
NAMES
.into_iter()
.find(|(_, n)| name == Some(*n))
.map(|(output, _)| output)
}
struct Targets {
size: [u32; 2],
@@ -637,7 +651,7 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::{Output, pick};
use super::{Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
@@ -687,6 +701,14 @@ mod tests {
assert_eq!(pick(&scrgb, Some("pq")), None);
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
#[test]
fn offscreen_offers_only_the_named_output() {
assert_eq!(named(Some("pq")), Some(Output::Pq));
assert_eq!(named(Some("scrgb")), Some(Output::ScRgb));
assert_eq!(named(Some("hdr10")), None);
assert_eq!(named(None), None);
}
}
#[cfg(test)]
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API @API:world_drawn_motion@).
/// frame to frame (API 371).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
/// The block textures the terrain samples, for drawing blocks with the player's own
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
/// textures (`native_block_sprites` says where a block's are). API 380.
pub block_sprites: BlockSpriteArrays<'a>,
}
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
/// runs, with no hand too. API 364.
#[inline(never)]
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
@@ -26,6 +26,37 @@ impl PackAtlas {
split: u32,
layers: u32,
sprite: u32,
) -> Option<Self> {
Self::with_levels(
device,
encoder,
arrays,
split,
layers,
sprite,
arrays[0].mip_level_count(),
)
}
/// Encoded material channels must select an existing texel, including at distance.
pub fn material(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
) -> Option<Self> {
Self::with_levels(device, encoder, arrays, split, layers, arrays[0].width(), 1)
}
fn with_levels(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
sprite: u32,
levels: u32,
) -> Option<Self> {
let layers = layers.max(1);
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
@@ -35,7 +66,7 @@ impl PackAtlas {
return None;
}
// Levels past a sprite's single pixel would mix neighbours: stop there.
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
let levels = levels.min(sprite.ilog2() + 1);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
@@ -252,6 +283,7 @@ impl PackAtlas {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
}
@@ -58,7 +58,8 @@ impl EntitySpan {
}
}
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
/// `entityColor`.
const SIZE: u32 = 32;
pub(super) struct DrawUniforms {
@@ -73,8 +74,10 @@ impl DrawUniforms {
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
// `blockEntityId` outside block entities, as Iris has it.
// `blockEntityId` outside block entities, as Iris has it; `currentRenderedItemId`
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
slot[12..16].copy_from_slice(&(-1i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
@@ -101,6 +101,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
item_id_declared: false,
}
}
@@ -162,6 +163,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -426,7 +428,9 @@ impl PackEntities {
}),
};
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
// draws them); beams do not.
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
@@ -1032,6 +1036,57 @@ mod gpu_span_tests {
assert_eq!(skin.spans.len(), 1);
}
#[test]
fn end_portal_faces_reach_the_shadow_pass_with_their_block_entity() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
// One block entity: a plain face, then an End portal face (overlay layer 9).
let cpu: Vec<Vertex> = [0, 0, 0, 0, 9, 9, 9, 9]
.into_iter()
.map(|layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
})
.collect();
let spans = [EntitySpan {
start: 0,
id: 99,
stage: render_stage("BLOCK_ENTITIES"),
..Default::default()
}];
let mut rest = Vec::new();
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
);
assert_eq!(
(geometry.draws.iter())
.map(|d| (d.portal, d.shadow))
.collect::<Vec<_>>(),
[(false, true), (true, true)],
"the portal face casts as its block entity does"
);
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
@@ -13,9 +13,14 @@ const HAND_DEPTH: f32 = 0.125;
/// `HzHand`: the hand's projection and its inverse.
const UNIFORM: u64 = 2 * 64;
/// `HzDraw`'s size (pack_draw.rs).
const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
inputs.item_id_declared = true;
inputs.declarations.push_str(
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
);
@@ -45,10 +50,17 @@ pub struct PackHand {
capacity: u32,
/// Quads uploaded this frame.
pub quads: u32,
/// `HzDraw` (set 2) of each run: the arm or one held item, and its `currentRenderedItemId`.
slots: wgpu::Buffer,
pub slot_group: wgpu::BindGroup,
stride: u64,
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
pub runs: Vec<(u32, u32, u32)>,
}
impl PackHand {
pub fn new(device: &wgpu::Device) -> Self {
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -74,6 +86,28 @@ impl PackHand {
});
let capacity = 256;
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
// The arm and the two hands' items: a few runs a frame.
let slots = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand draws"),
size: SLOTS * stride,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let slot_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack hand draws"),
layout: draws,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &slots,
offset: 0,
size: wgpu::BufferSize::new(SLOT),
}),
}],
});
Self {
indices: super::pack_entities::quad_indices(device, capacity),
layout,
@@ -82,6 +116,10 @@ impl PackHand {
group,
capacity,
quads: 0,
slots,
slot_group,
stride,
runs: Vec::new(),
}
}
@@ -116,18 +154,31 @@ impl PackHand {
(buffer, group)
}
/// Uploads this frame's hand and its projection.
/// Uploads this frame's hand, its projection, and the `HzDraw` of each run `items` starts
/// (first vertex, `currentRenderedItemId`; the slots past [`SLOTS`] share the last).
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hand: &[Vertex],
items: &[(u32, i32)],
projection: Mat4,
) {
self.quads = (hand.len() / 4) as u32;
self.runs.clear();
if self.quads == 0 {
return;
}
let mut contents = Vec::new();
for (start, quads, id) in runs(self.quads, items) {
let slot = (self.runs.len() as u64).min(SLOTS - 1);
if slot as usize == self.runs.len() {
contents.extend_from_slice(&draw_slot(id));
contents.resize(((slot + 1) * self.stride) as usize, 0);
}
self.runs.push((start, quads, (slot * self.stride) as u32));
}
queue.write_buffer(&self.slots, 0, &contents);
if self.quads > self.capacity {
self.capacity = self.quads.next_power_of_two();
(self.vertices, self.group) =
@@ -144,10 +195,54 @@ impl PackHand {
}
}
/// The hand's runs a frame keeps slots for.
const SLOTS: u64 = 8;
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
/// entities, and `currentRenderedItemId`.
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
bytes[12..16].copy_from_slice(&id.to_le_bytes());
bytes
}
/// The runs of `quads` quads that `items` starts (first vertex, id), as first quad, quads and
/// id; geometry before the first start, or with no starts at all, reads 0, as the arm does.
fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
let mut starts: Vec<(u32, i32)> = vec![(0, 0)];
for &(start, id) in items {
let q = (start / 4).min(quads);
match starts.last_mut() {
Some(last) if last.0 == q => last.1 = id,
_ => starts.push((q, id)),
}
}
(0..starts.len())
.map(|i| {
let end = starts.get(i + 1).map_or(quads, |s| s.0);
(starts[i].0, end - starts[i].0, starts[i].1)
})
.filter(|r| r.1 > 0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
#[test]
fn runs_split_the_hand_by_item() {
assert_eq!(runs(3, &[]), [(0, 3, 0)]);
assert_eq!(runs(5, &[(0, 11), (8, 65535)]), [(0, 2, 11), (2, 3, 65535)]);
assert_eq!(runs(4, &[(4, 21)]), [(0, 1, 0), (1, 3, 21)]);
// An item that drew nothing gives way to the next.
assert_eq!(runs(2, &[(0, 11), (0, 22)]), [(0, 2, 22)]);
}
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
/// apart by it), and x, y match the plain projection.
#[test]
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after,
alpha_test: None,
item_id_declared: false,
}
}
@@ -0,0 +1,257 @@
//! Original synthetic material transport oracle; no external shader algorithms or assets.
use super::*;
use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn terrain_material_samplers_preserve_surface_and_encoded_bytes() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Material transport 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 root = std::env::temp_dir().join(format!("horizon-material-pack-{}", std::process::id()));
std::fs::create_dir_all(root.join("shaders")).unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.vsh"),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
)
.unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.fsh"),
r#"#version 120
uniform sampler2D gtexture;
uniform sampler2D normals;
uniform sampler2D specular;
void main() {
int x = int(floor(gl_FragCoord.x));
int layer = x - (x / 5) * 5;
// Source coordinate (1.25,1.25): inside a texel, away from both its centre and boundary.
vec2 uv = (vec2(float(layer - (layer / 3) * 3), float(layer / 3)) + 0.3125) / vec2(3.0, 2.0);
if (x < 5) gl_FragData[0] = texture2DLod(specular, uv, 12.0);
else if (x < 10) gl_FragData[0] = texture2DLod(normals, uv, 12.0);
else gl_FragData[0] = texture2DLod(gtexture, uv, 0.0);
}
"#,
)
.unwrap();
let (split, layers) = (2u32, 5u32);
let mut encoder = device.create_command_encoder(&Default::default());
let atlases: Vec<_> = (0..3)
.map(|kind| {
let side = if kind == 0 { 4 } else { 2 };
let levels = if kind == 0 { 3 } else { 1 };
let arrays: Vec<_> = (0..layers.div_ceil(split))
.map(|array| {
let count = (layers - array * split).min(split);
let pixels: Vec<u8> = (0..count)
.flat_map(|l| {
let layer = (array * split + l) as u8;
(0..levels).flat_map(move |level| {
let edge = side >> level;
(0..edge * edge).flat_map(move |pixel| {
if level != 0 {
// Sampling unused source mip levels must fail the oracle.
[211, 212, 213, 214]
} else {
match kind {
0 if layer == 3 => crate::sprites::NO_SPECULAR,
0 => [
17 + layer,
if pixel % 2 == 0 { 0 } else { 240 },
65,
if pixel % 2 == 0 { 0 } else { 255 },
],
1 if layer == 2 => crate::sprites::FLAT_NORMAL,
1 => [128 + layer, 126, 255, 19 + layer],
_ => [40 + layer, 50, 60, 255],
}
}
})
})
})
.collect();
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Original fixture companion array"),
size: wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
)
})
.collect();
super::super::pack_atlas::PackAtlas::material(
&device,
&mut encoder,
&arrays,
split,
layers,
)
.unwrap()
})
.collect();
queue.submit([encoder.finish()]);
let pack = horizon_shaders::Pack::open(&root).unwrap();
let macros = horizon_shaders::Macros::standard();
// Encoded zero alpha is data here and must remain observable in readback.
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", &macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
..Default::default()
}),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
lightmap: atlases[2].view.clone(),
terrain: Some(Geometry {
buffers: &[],
table: None,
depth_write: true,
atlas: None,
material_maps: true,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
};
let mut post = PackPost::new(
&device,
&queue,
&adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
);
let mut pass = post
.terrain
.take()
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
// Request the existing copy usage for the fixture's colour readback.
post.copied |= 1;
// First draw preserves production Geometry -> Pass scope, so a routing regression fails.
for case in 0..3 {
if case == 1 {
pass.material_maps = false;
}
if case == 2 {
pass.material_maps = true;
post.material_maps = None;
}
let maps_expected = case == 0;
pass.groups.clear();
post.resize(&device, [15, 1]);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Initialize fixture depth"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &post.targets.as_ref().unwrap().depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
{
let mut render = post.begin(&mut encoder, &pass);
render.set_pipeline(pass.render());
render.set_bind_group(0, &group, &[]);
if pass.draw {
post.draws.bind(&mut render, "TERRAIN_SOLID");
}
render.draw(0..3, 0..1);
}
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
wgpu::Extent3d {
width: 15,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
for i in 0..15usize {
let layer = (i % 5) as u8;
let expected = if i >= 10 {
[40 + layer, 50, 60, 255]
} else if i >= 5 && (!maps_expected || layer == 2) {
crate::sprites::FLAT_NORMAL
} else if i >= 5 {
[128 + layer, 126, 255, 19 + layer]
} else if !maps_expected || layer == 3 {
crate::sprites::NO_SPECULAR
} else {
[17 + layer, 240, 65, 255]
};
assert_eq!(&data[i * 4..i * 4 + 4], &expected, "case={case}, pixel={i}");
}
eprintln!("Material oracle case={case}: 15 exact RGBA pixels pass");
}
// Fault injection: the same production binding must refuse a filtering sampler.
// If a regression changed both the binding and sampler, the fractional positive
// probes above would instead mix 0.75*240=180 and 0.75*255=191.25, not 240/255.
pass.material_maps = true;
post.material_maps = Some([atlases[1].view.clone(), atlases[0].view.clone()]);
post.nearest_repeat = sampler(&device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat);
pass.groups.clear();
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let _invalid_group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
let error = pollster::block_on(validation.pop())
.expect("production material binding must reject a filtering sampler");
assert!(
error.to_string().to_ascii_lowercase().contains("filtering"),
"{error}"
);
eprintln!("Material oracle negative filtering control: rejected by production binding");
std::fs::remove_dir_all(root).unwrap();
}
+270 -109
View File
@@ -5,12 +5,13 @@
//! Gbuffers programs come later; until then the pack's composites read Horizon's picture.
//!
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
//! written last (OQ-8). A cleared buffer has both textures cleared at the start of the frame:
//! with an odd number of flips a frame, the texture a program writes was "main" the frame before
//! and may still hold what was stored into it after that frame's clear (Iris 1.11.7 blends onto
//! the clear value there, probed with a pack of our own).
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). Every
//! frame starts with A as "main". A cleared buffer has both textures cleared then (Iris 1.11.7
//! reads and blends onto the clear value where a program leaves pixels, probed with a pack of our
//! own). A buffer that is not cleared reads back what was written last (OQ-8): when the last
//! frame ended on B, B is copied to A, and B keeps what was last written into it, which is what
//! a write leaves alone or blends onto there (a model that fits Iris 1.11.7's values in probe
//! packs of our own).
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
@@ -34,6 +35,9 @@ mod world_target_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
#[cfg(test)]
mod forced_flip_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -96,22 +100,19 @@ enum SamplerKind {
impl SamplerKind {
fn of(source: Source, filtering: bool) -> Self {
match source {
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
if !filtering =>
{
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.
Source::Atlas => Self::NearestRepeat,
Source::Atlas | Source::Normals | Source::Specular => Self::NearestRepeat,
Source::Image(_) if filtering => Self::Linear,
Source::Image(_) => Self::Nearest,
Source::Custom { blur, clamp, .. } => match (blur && filtering, clamp) {
@@ -120,12 +121,9 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
| Source::Lightmap => Self::Linear,
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
}
}
@@ -366,6 +364,7 @@ struct Pass {
draw: bool,
/// What `texture` reads instead of the block atlas.
atlas: Option<wgpu::TextureView>,
material_maps: bool,
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
ids: bool,
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
@@ -437,6 +436,7 @@ struct Targets {
pub(super) struct World<'a> {
/// The block atlas and its size.
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
pub material_maps: Option<[wgpu::TextureView; 2]>,
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
@@ -522,8 +522,9 @@ pub struct PackPost {
lines: Option<Pass>,
line_draw: Option<Lines>,
cloud_draw: Option<Clouds>,
/// The pack has `gbuffers_clouds`, even one that writes nothing (a pack drawing its own
/// clouds discards vanilla's), or `clouds=off`: the game's clouds are not drawn.
/// The pack draws the clouds (`gbuffers_clouds` or its fallback), even with a program that
/// writes nothing (a pack drawing its own clouds discards vanilla's), or `clouds=off`: the
/// game's clouds are not drawn.
hides_clouds: bool,
/// The pack has a sky (`gbuffers_skybasic`) or sun and moon (`gbuffers_skytextured`)
/// program: vanilla's stays hidden even when the program writes nothing.
@@ -573,6 +574,7 @@ pub struct PackPost {
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
material_maps: Option<[wgpu::TextureView; 2]>,
lightmap: wgpu::TextureView,
deferred_pre: u16,
composite_pre: u16,
@@ -617,7 +619,9 @@ pub struct PackPost {
nearest: wgpu::Sampler,
repeat: wgpu::Sampler,
nearest_repeat: wgpu::Sampler,
blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// 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)>,
/// The blit also writes depthtex0 (some program reads it) and clears these buffers.
blit_depth: bool,
blit_clears: Vec<usize>,
@@ -939,6 +943,14 @@ fn blend_state(b: horizon_shaders::Blend) -> wgpu::BlendState {
}
}
/// A buffer whose texels are floats (normalized or not), not integers.
fn holds_floats(format: wgpu::TextureFormat) -> bool {
matches!(
format.sample_type(None, None),
Some(wgpu::TextureSampleType::Float { .. })
)
}
fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
Some(wgpu::ColorTargetState {
format,
@@ -1060,6 +1072,8 @@ pub(super) struct Geometry<'a> {
pub depth_write: bool,
/// What `texture` reads instead of the block atlas.
pub atlas: Option<wgpu::TextureView>,
/// This draw uses the block atlas's surface identities.
pub material_maps: bool,
/// `mc_Entity`'s table at set 3 (flat terrain).
pub ids: Option<&'a wgpu::BindGroupLayout>,
/// Back faces are culled, as for the game's own terrain.
@@ -1089,6 +1103,9 @@ struct Resources<'a> {
released: std::cell::Cell<(&'static str, u16)>,
/// 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`).
shadow_linear: u8,
}
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
@@ -1213,6 +1230,7 @@ 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 }
}
@@ -1531,11 +1549,12 @@ fn build_pass(
.iter()
.filter(|f| !f.1)
.fold(0u16, |m, f| m | 1 << f.0);
// Gbuffers programs write in place (6.1.6).
// Gbuffers programs write in place (6.1.6). A forced flip of a buffer the program writes
// cancels the write's own flip, as in Iris 1.11.7 (probed with packs of our own).
let flip = if final_pass || geometry.is_some() {
0
} else {
(written & !kept) | mask(&p.flips)
(written & !kept) ^ mask(&p.flips)
};
let created = checked(device, || {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1624,7 +1643,11 @@ fn build_pass(
.map(|u| u.name.as_str())
.collect();
if !missing.is_empty() {
notes.push(format!("{}: zero for now: {}", p.name, missing.join(" ")));
notes.push(format!(
"{}: shader defaults or zero: {}",
p.name,
missing.join(" ")
));
}
let image_copies = image_copies(&samplers, &images);
Some(Pass {
@@ -1634,7 +1657,7 @@ fn build_pass(
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
block: vec![0; interface.uniform_size as usize],
block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -1652,6 +1675,7 @@ fn build_pass(
mipmaps: p.mipmaps,
draw,
atlas: geometry.and_then(|g| g.atlas.clone()),
material_maps: geometry.is_some_and(|g| g.material_maps),
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -1762,7 +1786,7 @@ fn build_compute(
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
block: vec![0; interface.uniform_size as usize],
block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -1787,6 +1811,7 @@ fn build_compute(
mipmaps: 0,
draw: false,
atlas: None,
material_maps: false,
ids: false,
groups: HashMap::new(),
portal_groups: HashMap::new(),
@@ -1940,6 +1965,13 @@ impl PackPost {
rgb,
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
},
};
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
let in_shadow = p.group.shadow();
@@ -2032,6 +2064,7 @@ impl PackPost {
table: g.table,
depth_write: g.depth_write,
atlas: g.atlas.clone(),
material_maps: g.material_maps,
ids: block_ids.as_ref().map(|i| &i.0),
cull: g.cull,
});
@@ -2059,6 +2092,7 @@ impl PackPost {
table: Some(&geometry.layout),
depth_write: false,
atlas: None,
material_maps: false,
ids: None,
cull: false,
};
@@ -2068,6 +2102,7 @@ impl PackPost {
table: None,
depth_write: false,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
};
@@ -2106,6 +2141,7 @@ impl PackPost {
table: Some(&e.table),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
});
@@ -2117,6 +2153,7 @@ impl PackPost {
table: g.table,
depth_write: false,
atlas: g.atlas.clone(),
material_maps: g.material_maps,
ids: g.ids,
cull: false,
};
@@ -2144,6 +2181,7 @@ impl PackPost {
table: Some(layout),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
});
@@ -2153,16 +2191,20 @@ impl PackPost {
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
});
let hand_geometry = world.hand.then(|| super::pack_hand::PackHand::new(device));
let hand_geometry = world
.hand
.then(|| super::pack_hand::PackHand::new(device, &draws.layout));
let hand = post.hand.zip(hand_geometry).and_then(|(p, geometry)| {
let g = Geometry {
buffers: &entity_buffers,
table: Some(&geometry.layout),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
};
@@ -2187,6 +2229,7 @@ impl PackPost {
table: None,
depth_write: false,
atlas: None,
material_maps: false,
ids: None,
cull: false,
};
@@ -2313,8 +2356,21 @@ impl PackPost {
let depth1 = every_pass()
.flat_map(|p| &p.samplers)
.any(|s| matches!(s.source, Source::ShadowDepth(1, _)));
let t =
super::pack_shadow::ShadowTargets::new(device, &shadow_config, f, colors, depth1);
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,
depth1,
linear,
);
notes.push(format!(
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b}{1}",
t.size,
@@ -2385,7 +2441,13 @@ impl PackPost {
}
});
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut world_target = target(formats[0]);
// The world's colour is floats: an integer colortex0 (`RGBA32UI`...) gets only what the
// pack writes, and is cleared in a pass of its own.
let world_color = holds_floats(formats[0]);
if !world_color {
other_clears.push(0);
}
let mut world_target = world_color.then(|| target(formats[0])).flatten();
if rgb & 1 != 0
&& let Some(t) = &mut world_target
{
@@ -2402,28 +2464,30 @@ impl PackPost {
})
}))
.collect();
let blit = helper(
device,
"vs_full",
if blit_depth {
"fs_blit"
} else {
"fs_blit_color"
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
],
&blit_targets,
// GL's window depth, written for depthtex0 and the terrain's depth test.
blit_depth.then_some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
);
let blit = (world_color || blit_depth || !cleared.is_empty()).then(|| {
helper(
device,
"vs_full",
if blit_depth {
"fs_blit"
} else {
"fs_blit_color"
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
],
&blit_targets,
// GL's window depth, written for depthtex0 and the terrain's depth test.
blit_depth.then_some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
)
});
let copy = helper(
device,
"vs_uv",
@@ -2513,6 +2577,7 @@ impl PackPost {
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
material_maps: world.material_maps,
lightmap: world.lightmap,
deferred_pre: mask(&post.deferred_pre),
composite_pre: mask(&post.composite_pre),
@@ -2724,6 +2789,12 @@ 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,
@@ -2792,8 +2863,16 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals => &self.dummy_normal,
Source::Specular => &self.dummy_black,
Source::Normals => self
.material_maps
.as_ref()
.filter(|_| pass.material_maps)
.map_or(&self.dummy_normal, |maps| &maps[0]),
Source::Specular => self
.material_maps
.as_ref()
.filter(|_| pass.material_maps)
.map_or(&self.dummy_black, |maps| &maps[1]),
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
@@ -3104,42 +3183,17 @@ impl PackPost {
continue;
}
let group = self.bind(device, queue, encoder, pass, size);
// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs
// that wrap it with their own coordinates sample the portal sprite only.
let portal_group = if geometry
.draws
.iter()
.any(|d| d.pack && d.program == program && d.portal)
&& let Some(atlas) = pass.atlas.clone().or_else(|| self.atlas.clone())
&& self.portal_rect[2] > 0.0
{
if self
.portal_texture
.as_ref()
.is_none_or(|(rect, _)| *rect != self.portal_rect)
{
pass.portal_groups.clear();
let view = super::pack_weather::copy_sprite(
device,
encoder,
atlas.texture(),
self.portal_rect,
);
self.portal_texture = Some((self.portal_rect, view));
}
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
// `bind` caches groups by a key without the atlas: the portal groups are kept
// in a cache of their own, swapped in with the texture.
let saved = std::mem::replace(&mut pass.atlas, view);
let groups =
std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
let g = self.bind(device, queue, encoder, pass, size);
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
pass.atlas = saved;
Some(g)
} else {
None
};
let portal_group = self.portal_group(
device,
queue,
encoder,
pass,
size,
geometry
.draws
.iter()
.any(|d| d.pack && d.program == program && d.portal),
);
{
let mut rp = self.begin(encoder, pass);
rp.set_pipeline(pass.render());
@@ -3171,6 +3225,47 @@ impl PackPost {
self.entities = Some(entities);
}
/// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs that
/// wrap it with their own coordinates sample the portal sprite only. `None` when no portal
/// face draws in this pass.
fn portal_group(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
pass: &mut Pass,
size: [u32; 2],
wanted: bool,
) -> Option<wgpu::BindGroup> {
let atlas = pass.atlas.clone().or_else(|| self.atlas.clone())?;
if !wanted || self.portal_rect[2] <= 0.0 {
return None;
}
if self
.portal_texture
.as_ref()
.is_none_or(|(rect, _)| *rect != self.portal_rect)
{
let view = super::pack_weather::copy_sprite(
device,
encoder,
atlas.texture(),
self.portal_rect,
);
self.portal_texture = Some((self.portal_rect, view));
pass.portal_groups.clear();
}
// `bind` caches groups by a key without the atlas: the portal groups are kept in a cache
// of their own, swapped in with the texture.
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
let saved = std::mem::replace(&mut pass.atlas, view);
let groups = std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
let group = self.bind(device, queue, encoder, pass, size);
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
pass.atlas = saved;
Some(group)
}
/// Entities and block entities into the shadow map, each run with the per-draw uniforms
/// of its gbuffers draw (2.4): opaque, so before the `shadowtex1` copy.
fn draw_shadow_entities(
@@ -3195,6 +3290,15 @@ impl PackPost {
continue;
}
let group = self.bind(device, queue, encoder, pass, size);
let portal_group = self.portal_group(
device,
queue,
encoder,
pass,
size,
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
);
let mut on_portal = false;
let mut rp = self.begin(encoder, pass);
rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
@@ -3207,6 +3311,12 @@ impl PackPost {
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
for d in draws {
if let Some(portal) = &portal_group
&& d.portal != on_portal
{
rp.set_bind_group(0, if d.portal { portal } else { &group }, &[]);
on_portal = d.portal;
}
if pass.draw {
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
}
@@ -3359,7 +3469,7 @@ impl PackPost {
size[0] as f32 / size[1].max(1) as f32,
frame.depth_far,
) * unspin;
geometry.prepare(device, queue, frame.hand, projection);
geometry.prepare(device, queue, frame.hand, frame.hand_items, projection);
if geometry.quads > 0 {
let group = self.bind(device, queue, encoder, &mut pass, size);
{
@@ -3372,12 +3482,15 @@ impl PackPost {
rp.set_bind_group(4, g, &[]);
}
rp.set_bind_group(1, &geometry.group, &[]);
if pass.draw {
self.draws.bind(&mut rp, "HAND_SOLID");
}
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
rp.draw_indexed(0..geometry.quads * 6, 0, 0..1);
// The arm and each held item: their own `currentRenderedItemId`.
for &(first, quads, offset) in &geometry.runs {
if pass.draw {
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
}
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
}
}
self.mips_valid &= !pass.written;
}
@@ -3743,6 +3856,10 @@ impl PackPost {
if self.copied & 1 << i != 0 {
attachment |= wgpu::TextureUsages::COPY_SRC;
}
// A buffer that isn't cleared starts each frame with B copied back to A.
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]));
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
@@ -3907,6 +4024,9 @@ 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);
@@ -4128,6 +4248,20 @@ impl PackPost {
&mut self.zeros,
);
let Some(targets) = &self.targets else { return };
// Every frame starts on A. A buffer that isn't cleared and ended the last frame on B has B
// copied back to A, and B keeps what was last written into it: a model that fits Iris 1.11.7's
// values in probe packs of our own (#9009), not a documented Iris rule. colortex0's A is
// overwritten by the world below.
for b in (1..BUFFERS).filter(|&b| self.main & 1 << b != 0 && self.clears[b].is_none()) {
if let Some([a, b_side]) = &targets.color[b] {
encoder.copy_texture_to_texture(
b_side.texture().as_image_copy(),
a.texture().as_image_copy(),
a.texture().size(),
);
}
}
self.main = 0;
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
@@ -4229,14 +4363,15 @@ impl PackPost {
}
// The world into colortex0 and depthtex0, flipped to GL's rows, and the clears.
if self
.blit_group
.as_ref()
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
if let Some(blit) = &self.blit
&& self
.blit_group
.as_ref()
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack world"),
layout: &self.blit.1,
layout: &blit.1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
@@ -4250,7 +4385,7 @@ impl PackPost {
});
self.blit_group = Some(([world.clone(), world_depth.clone()], group));
}
{
if let Some(blit) = &self.blit {
// The blit leaves a three-channel colortex0's alpha at the 1 it starts with.
let world_ops = if self.rgb & 1 != 0 {
wgpu::Operations {
@@ -4260,10 +4395,11 @@ impl PackPost {
} else {
overwrite
};
let mut attachments = vec![attach(
view(self.main, 0, false).expect("colortex0"),
world_ops,
)];
let mut attachments = vec![if holds_floats(self.formats[0]) {
attach(view(self.main, 0, false).expect("colortex0"), world_ops)
} else {
None
}];
for &b in &self.blit_clears {
attachments.push(attach(
view(self.main, b, false).expect("used buffer"),
@@ -4286,7 +4422,7 @@ impl PackPost {
),
..Default::default()
});
pass.set_pipeline(&self.blit.0);
pass.set_pipeline(&blit.0);
pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]);
pass.draw(0..3, 0..1);
}
@@ -4435,7 +4571,14 @@ impl PackPost {
.as_ref()
.map(|t| &t[main(m, i) ^ usize::from(alt)])
};
if !shown {
// An integer colortex0 has no colour to show: the screen is only cleared.
if !shown && !holds_floats(self.formats[0]) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack colortex0"),
color_attachments: &[attach(screen, overwrite)],
..Default::default()
});
} else if !shown {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack colortex0"),
layout: &self.copy.1,
@@ -4587,6 +4730,7 @@ mod tests {
}];
let interface = horizon_shaders::Interface {
uniform_size: 48,
initial_values: vec![0; 48],
uniforms: Vec::new(),
samplers: vec![horizon_shaders::Sampler {
name: "colortex0".into(),
@@ -4616,6 +4760,7 @@ mod tests {
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
};
let reads = |group: Group| {
let (_, samplers, ..) = bindings(
@@ -4665,6 +4810,7 @@ mod tests {
};
let interface = horizon_shaders::Interface {
uniform_size: 48,
initial_values: vec![0; 48],
uniforms: Vec::new(),
samplers: Vec::new(),
outputs: vec![0],
@@ -4694,6 +4840,7 @@ mod tests {
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
};
let mut notes = Vec::new();
let (.., images) = bindings(
@@ -4834,6 +4981,7 @@ mod tests {
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
};
let draws = DrawUniforms::new(&device);
let mut notes = Vec::new();
@@ -4996,5 +5144,18 @@ mod tests {
#[cfg(test)]
mod destination_tests;
#[cfg(test)]
#[path = "pack_material_tests.rs"]
mod material_tests;
#[cfg(test)]
mod rgb_alpha_tests;
#[cfg(test)]
mod trinary_tests;
#[cfg(test)]
mod const_offset_tests;
#[cfg(test)]
mod integer_colortex0_tests;
@@ -0,0 +1,111 @@
//! A buffer that isn't cleared, in passes that leave pixels alone or blend: what is under them.
//! The expected values are Iris 1.11.7's, measured with probe packs of our own (#9009).
use super::*;
/// colortex6 is written three times a frame, the first write leaving the right half alone:
/// there it reads the previous frame's last write, 0.125 (Iris 1.11.7), not the 0.25 written into
/// that texture before. colortex8 is written twice a frame the same way: its right half was never
/// written and reads 0 (Iris 1.11.7). colortex10 is only blended onto (`ONE ONE`), 0.125 a frame:
/// it grows by 0.125 every frame (Iris 1.11.7 adds once per frame).
#[test]
fn a_buffer_that_is_not_cleared_starts_the_frame_with_its_last_write() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let fill = |v: f32| format!("vec4({v},{v},{v},1)");
let programs = [
(
"composite",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:6 */\n\
/*\nconst int colortex6Format=RGBA16F;\nconst int colortex7Format=RGBA16F;\n\
const int colortex8Format=RGBA16F;\nconst int colortex9Format=RGBA16F;\n\
const int colortex10Format=RGBA16F;\nconst bool colortex6Clear=false;\n\
const bool colortex8Clear=false;\nconst bool colortex10Clear=false;\n*/\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite1",
format!(
"#version 120\nuniform sampler2D colortex6;\nvarying vec2 uv;\n/* RENDERTARGETS:7,6 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex6,uv); gl_FragData[1]={}; }}\n",
fill(0.25)
),
),
(
"composite2",
format!(
"#version 120\n/* RENDERTARGETS:6 */\nvoid main() {{ gl_FragData[0]={}; }}\n",
fill(0.125)
),
),
(
"composite3",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:8 */\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite4",
format!(
"#version 120\nuniform sampler2D colortex8;\nvarying vec2 uv;\n/* RENDERTARGETS:9,8 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex8,uv); gl_FragData[1]={}; }}\n",
fill(0.125)
),
),
(
"composite5",
"#version 120\n/* RENDERTARGETS:10 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,0); }\n"
.to_owned(),
),
(
"final",
"#version 120\nuniform sampler2D colortex7;\nuniform sampler2D colortex9;\n\
uniform sampler2D colortex10;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex7,uv).r,texture2D(colortex9,uv).r,texture2D(colortex10,uv).r,1); }\n"
.to_owned(),
),
];
for (program, fragment) in &programs {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add("shaders.properties", "blend.composite5=ONE ONE ONE ONE\n");
let pack = horizon_shaders::Pack::from_zip_bytes(
"clear-false-history",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.build(post);
for frame in 0..4u8 {
let actual = gpu.render(&mut post, [16, 16]);
let sum = 32 * (frame + 1);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(n, p)| {
let expected = match (n % 16 < 8, frame) {
(true, _) => [128, 128, sum, 255],
(false, 0) => [0, 0, sum, 255],
(false, _) => [32, 0, sum, 255],
};
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,70 @@
//! A texel offset given by a `const` local reads the texel that far away, as a literal offset
//! does (GLSL 4.60, 4.3.3 and 8.9.2, as #8462 quotes them). #8462's probe.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
// Each texel of colortex1 holds its own column and row, in eighths.
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1 */\n\
void main() { gl_FragData[0] = vec4(floor(gl_FragCoord.xy) / 8.0, 0.0, 1.0); }\n",
);
add("final.vsh", VERTEX);
// Red and green: what the texel two columns right and one row down holds; blue: this row.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nin vec2 uv;\n\
vec4 f(vec2 p) { const ivec2 offset = ivec2(2, -1); return textureLodOffset(colortex1, p, 0.0, offset); }\n\
void main() { gl_FragColor = vec4(f(uv).rg, floor(gl_FragCoord.y) / 8.0, 1.0); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-const-offset",
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_const_local_offset_reads_the_offset_texel() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
let eighth = |n: u8| (f32::from(n) * 255.0 / 8.0).round() as u8;
let mut checked = 0;
for (n, p) in pixels.iter().enumerate() {
let x = (n % 8) as u8;
// The bottom row (GL's row 0) and the two right columns read past the edge.
if x >= 6 || p[2] == 0 {
continue;
}
let row = (0..8)
.find(|&r| eighth(r).abs_diff(p[2]) <= 1)
.expect("row in blue");
let want = [eighth(x + 2), eighth(row - 1)];
assert!(
p[0].abs_diff(want[0]) <= 1 && p[1].abs_diff(want[1]) <= 1,
"pixel {n} (column {x}, GL row {row}): {p:?}, expected red and green {want:?}"
);
checked += 1;
}
assert_eq!(checked, 6 * 7);
}
@@ -315,19 +315,21 @@ 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 }),
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 }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
shaders(sampler, stage, scene),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: terrain.then_some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
@@ -617,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -635,3 +638,143 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
post_effects: crate::post_chain::Request::none(),
}
}
/// shadowtex0 and shadowtex1 read through a plain sampler a quarter texel past a texel's centre,
/// over a shadow map whose depth rises a sixteenth a texel: red and green are how far each
/// 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]>> {
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);
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 inputs = horizon_shaders::GameInputs {
terrain: Some(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,
}),
..Default::default()
};
let settings = format!("const int shadowMapResolution = 16;\n{nearest}\n");
let files = vec![
("final.vsh".to_string(), VERTEX.to_string()),
(
"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"
),
),
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
(
"shadow.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
),
(
"shadow.fsh".into(),
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
),
];
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,
ids: None,
cull: false,
material_maps: false,
}),
block_ids: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
assert!(post.has_shadow(), "{:?}", post.notes);
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original shadow depth ramp"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
Some(
gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::ShadowSolid {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
}),
)
}
#[test]
fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
// Iris 1.11.7 filters both (probe packs of our own; the PR has the readings).
if let Some(pixels) = shadow_depth_filtering("") {
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex linear");
}
}
#[test]
fn shadowtex_nearest_settings_read_the_nearest_texel() {
for (line, expected) in [
("const bool shadowtex0Nearest = true;", [0, 255, 0, 255]),
("const bool shadowtexNearest = true;", [0, 255, 0, 255]),
("const bool shadow0MinMagNearest = true;", [0, 255, 0, 255]),
("const bool shadowtex1Nearest = true;", [255, 0, 0, 255]),
("const bool shadow1MinMagNearest = true;", [255, 0, 0, 255]),
] {
if let Some(pixels) = shadow_depth_filtering(line) {
all_pixels(&pixels, expected, line);
}
}
}
@@ -208,6 +208,7 @@ impl Fixture {
T::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap,
terrain: None,
block_ids: &[],
@@ -346,8 +347,8 @@ fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
let Some(gpu) = Fixture::new() else { return };
for clear in [true, false] {
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
// Every frame changes which physical image is main. The other image is fully
// seeded red before the partial write, independent of its initial clearing.
// The other image is fully seeded red before the partial write, independent of its
// initial clearing.
for frame in 0..3 {
let actual = gpu.render(&mut post, [16, 16]);
assert!(
@@ -433,6 +434,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -521,3 +523,6 @@ fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
#[path = "clear_false_tests.rs"]
mod clear_false_tests;
@@ -0,0 +1,73 @@
//! `flip.<program>.<buffer> = true` on a buffer the program writes (#9010). The expected values
//! are Iris 1.11.7's, measured with probe packs of our own.
use super::destination_tests::Fixture;
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 0.25 to colortex2 and colortex4 and forces both to flip: the forced flip
/// cancels the write's own, so `composite1` samples colortex2's cleared side (Iris 1.11.7: 0),
/// and `composite2`, blended `ONE ONE`, adds 0.5 onto colortex4's 0.25 (Iris 1.11.7: 0.749).
#[test]
fn a_forced_flip_of_a_written_buffer_leaves_it_unflipped() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
for (program, fragment) in [
(
"composite",
"#version 120\n/* RENDERTARGETS:2,4 */\n\
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=vec4(0.25,0.25,0.25,1); }\n",
),
(
"composite1",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS:3 */\n\
void main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
),
(
"composite2",
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.5,0.5,0.5,0); }\n",
),
(
"final",
"#version 120\nuniform sampler2D colortex3;\nuniform sampler2D colortex4;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex4,uv).r,texture2D(colortex3,uv).r,0,1); }\n",
),
] {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add(
"shaders.properties",
"flip.composite.colortex2=true\nflip.composite.colortex4=true\nblend.composite2=ONE ONE ONE ONE\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"forced-flip-of-a-written-buffer",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.build(post);
for frame in 0..2 {
let actual = gpu.render(&mut post, [16, 16]);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(_, p)| {
p.iter()
.zip([191, 0, 0, 255])
.any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,85 @@
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
//! sampled from it.
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";
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
fn pack(depth: bool, last: bool) -> 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);
// With depthtex0: green where it holds the empty world's far plane (1), red elsewhere.
let value = if depth {
"texelFetch(depthtex0, ivec2(gl_FragCoord.xy), 0).r == 1.0 ? uvec4(0u, 200u, 0u, 255u) : uvec4(200u, 0u, 0u, 255u)"
} else {
"uvec4(0u, 200u, 0u, 255u)"
};
add(
"composite.fsh",
&format!(
"#version 330 compatibility\n/* RENDERTARGETS: 0 */\n/*\nconst int colortex0Format = RGBA32UI;\n*/\n\
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
),
);
if last {
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
);
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-integer-colortex0",
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 + usize::from(last),
"{:?}",
post.notes
);
post
}
#[test]
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
let Some(gpu) = Fixture::new() else { return };
for depth in [false, true] {
let mut post = gpu.build(pack(depth, true));
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
let wrong: Vec<_> = pixels.iter().filter(|p| **p != [0, 200, 0, 255]).collect();
assert!(wrong.is_empty(), "depth {depth}, frame {frame}: {wrong:?}");
}
}
}
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
/// then was not measured. The frame must still run.
#[test]
fn an_integer_colortex0_without_final_still_runs() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack(false, false));
for _ in 0..2 {
gpu.render(&mut post, [8, 8]);
}
}
@@ -0,0 +1,78 @@
//! `min3`, `max3` and `mid3` of GL_AMD_shader_trinary_minmax: the per-component minimum, maximum
//! and median of three values, for float, int and uint vectors (the extension's additions to
//! GLSL 4.30 Section 8.3). An original probe; the expected values are worked out by hand.
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("final.vsh", VERTEX);
// Pixel column x shows case x % 8 in red, green and blue.
add(
"final.fsh",
"#version 330 compatibility\n#extension GL_AMD_shader_trinary_minmax : require\n\
void main() {\n int x = int(gl_FragCoord.x) % 8;\n\
vec3 a = vec3(0.1, 0.9, 0.4), b = vec3(0.6, 0.2, 0.8), c = vec3(0.3, 0.5, 0.0);\n\
ivec3 i = ivec3(-3, 7, 2), j = ivec3(5, -1, 9), k = ivec3(0, 4, -6);\n\
uvec3 u = uvec3(1u, 8u, 3u), v = uvec3(6u, 2u, 7u), w = uvec3(4u, 5u, 0u);\n\
vec3 o = vec3(0.0);\n\
switch (x) {\n\
case 0: o = min3(a, b, c); break;\n\
case 1: o = max3(a, b, c); break;\n\
case 2: o = mid3(a, b, c); break;\n\
case 3: o = vec3(mid3(i, j, k)) / 10.0; break;\n\
case 4: o = vec3(max3(u, v, w)) / 10.0; break;\n\
case 5: o = vec3(min3(i, j, k) + 6) / 10.0; break;\n\
case 6: o = vec3(mid3(u.x, v.x, w.x), mid3(u.y, v.y, w.y), mid3(u.z, v.z, w.z)) / 10.0; break;\n\
case 7: o = vec3(max3(a.x, b.x, c.x), min3(a.yz, b.yz, c.yz)); break;\n\
}\n gl_FragColor = vec4(o, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-trinary",
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 trinary_min_max_and_median_per_component() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
// Tenths, by hand: a = (1, 9, 4), b = (6, 2, 8), c = (3, 5, 0); i = (-3, 7, 2),
// j = (5, -1, 9), k = (0, 4, -6); u = (1, 8, 3), v = (6, 2, 7), w = (4, 5, 0).
let tenths: [[u8; 3]; 8] = [
[1, 2, 0],
[6, 9, 8],
[3, 5, 4],
[0, 4, 2],
[6, 8, 7],
[3, 5, 0],
[4, 5, 3],
[6, 2, 0],
];
let row: Vec<[u8; 3]> = pixels[..8].iter().map(|p| [p[0], p[1], p[2]]).collect();
for (case, (got, want)) in row.iter().zip(tenths).enumerate() {
let want = want.map(|t| (f32::from(t) * 25.5).round() as u8);
assert!(
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
"case {case}: {got:?}, expected {want:?}; row {row:?}"
);
}
}
@@ -215,6 +215,7 @@ fn pack(
T::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap: lightmap.clone(),
terrain: None,
block_ids: &[],
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -35,6 +35,13 @@ 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.
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
/// The clear colour of each buffer cleared every frame.
@@ -77,13 +84,15 @@ fn target(
impl ShadowTargets {
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
/// sampler). 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,
depth1: bool,
linear: u8,
) -> Self {
let size = shadow
.resolution
@@ -105,10 +114,43 @@ impl ShadowTargets {
})
})
});
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];
@@ -214,4 +256,38 @@ 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());
}
}
}
@@ -97,6 +97,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// 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(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -122,6 +123,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
],
after: " gl_Position.z = gl_Position.w;\n".into(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -1388,6 +1388,7 @@ mod initial_history_tests {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
item_id_declared: false,
}
}
@@ -67,6 +67,17 @@ impl Readback {
let Some(texture) = self.texture.clone() else {
return;
};
self.draw_onto(device, encoder, &texture, surface);
}
/// Draws `frame` onto the swapchain image (or offscreen target) `surface`.
pub fn draw_onto(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
surface: &wgpu::Texture,
) {
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
@@ -74,7 +85,7 @@ impl Readback {
format: Some(format),
..Default::default()
});
self.blit(device, encoder, &texture, &target, format);
self.blit(device, encoder, frame, &target, format);
}
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
@@ -92,7 +92,7 @@ impl<T> Resident<T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key, value and bounds.
#[cfg(debug_assertions)]
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
let items = self.items.iter().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
@@ -194,6 +194,12 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Marks or unmarks the section at `key`, if it is resident.
pub fn set_marked(&mut self, key: SectionKey, marked: bool) {
if let Some(&slot) = self.slots.get(&key) {
self.mark_slot(slot, marked);
}
}
/// Marks or unmarks the section in `slot`, in its column's list too.
fn mark_slot(&mut self, slot: u32, marked: bool) {
let was = std::mem::replace(&mut self.marked[slot as usize], marked);
@@ -657,7 +663,8 @@ mod tests {
(!value.is_multiple_of(3)).then(|| [Vec3::splat((value % 5) as f32), Vec3::splat(16.0)])
};
// A deterministic mix of inserts, replacements and removals, marked at
// random: a replacement can mark or unmark its section.
// random: a replacement can mark or unmark its section, and so can a
// mark set alone (`set_marked`), which leaves a key not resident alone.
let mut seed = 0x2545_f491_u32;
for step in 0..4000u32 {
seed ^= seed << 13;
@@ -694,6 +701,12 @@ mod tests {
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key).map(|(value, _)| value));
} else if (seed >> 12).is_multiple_of(5) {
let marked = (seed >> 20).is_multiple_of(2);
r.set_marked(key, marked);
if let Some(held) = model.get_mut(&key) {
held.1 = marked;
}
} else {
let marked = (seed >> 20).is_multiple_of(2);
r.insert_marked(key, step, bounds(step), marked);
@@ -1050,7 +1050,9 @@ impl Item {
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Horizon(H::TranslucentBatching) => {
g.translucent_batching && crate::graphics::translucent_batching_offered()
}
Item::Horizon(H::HdrSmoothGradients) => {
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
}
@@ -3164,13 +3166,13 @@ mod tests {
assert_eq!(page, Some(Page::Advanced));
let mut s = Settings::default();
s.horizon.preset = Preset::Balanced;
assert_eq!(item.switch(&s), Some(true));
let mut ui = GraphicsUi::default();
// Where the device can't, the saved On reads Off, and neither a click nor Enter
// after it does anything: no sound, no change, no save.
crate::graphics::offer_translucent_batching(false);
assert!(item.blocked(&s).is_some());
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert_eq!(item.switch(&s), Some(false));
let r = row(&mut ui, &s, item);
let point = (r[0] + 4.0, r[1] + 4.0);
assert!(press(&mut ui, &mut s, point).is_empty());
@@ -3181,6 +3183,7 @@ mod tests {
// Where it can, a click switches it and keeps the preset.
crate::graphics::offer_translucent_batching(true);
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
assert_eq!(item.switch(&s), Some(true));
assert_eq!(
press(&mut ui, &mut s, point),
[Action::ButtonClick, Action::Changed]
+4 -4
View File
@@ -796,10 +796,10 @@ impl HudAtlas {
let g = self.font.glyph(ch);
g.rect.map(|rect| (rect, g.advance))
}
/// A font glyph's atlas cell (none for blank glyphs such as the space) and advance.
pub fn glyph_cell(&self, ch: char) -> (Option<Rect>, f32) {
let g = self.font.glyph(ch);
(g.rect, g.advance)
/// A font glyph in the default font: its atlas cell (none for blank
/// glyphs such as the space), its box and advance.
pub fn glyph_cell(&self, ch: char) -> crate::font::Glyph {
self.font.glyph(ch)
}
pub fn build(root: &Path) -> Result<Self> {
Self::build_over(root, root)
+61 -13
View File
@@ -875,7 +875,16 @@ struct MeshInput {
}
struct ReducedColumns {
columns: Arc<[Column; 256]>,
levels: [Vec<Column>; 4],
/// The chunk reduced to cells 2, 4, 8 and 16 blocks wide, each built the
/// first time a tile of that width asks for it.
///
/// One chunk is covered by one far tile, so one of the four is what gets
/// read, and a chunk whose detail arrives while full-detail terrain still
/// draws it reads none. Building all four up front cost 405 us of a far
/// terrain thread for every chunk the world sent (`Column::reduced` walks
/// every span of the footprint four times over), and held 85 columns
/// beside the chunk's 256.
levels: [std::sync::OnceLock<Vec<Column>>; 4],
}
impl MeshInput {
/// The column of every cell: the tile's own samples unless the snapshot
@@ -905,25 +914,26 @@ impl MeshInput {
}
impl ReducedColumns {
fn new(columns: Arc<[Column; 256]>) -> Self {
let levels = std::array::from_fn(|level| {
let width = 2 << level;
Self {
columns,
levels: std::array::from_fn(|_| std::sync::OnceLock::new()),
}
}
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
if width == 1 {
return &self.columns[z * 16 + x];
}
let level = width.trailing_zeros() as usize - 1;
let reduced = self.levels[level].get_or_init(|| {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
let columns = &columns;
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
});
Self { columns, levels }
}
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
if width == 1 {
&self.columns[z * 16 + x]
} else {
&self.levels[width.trailing_zeros() as usize - 1]
[(z / width) * (16 / width) + x / width]
}
&reduced[(z / width) * (16 / width) + x / width]
}
}
enum Job {
@@ -3348,6 +3358,44 @@ mod tests {
"a crown over one corner of a wide cell still sits on its trunk"
);
}
/// A chunk is reduced to the one cell width that reads it, when it does.
///
/// Reducing to all four widths up front cost 405 us of a far terrain
/// thread for every chunk the world sent, where at most one is read.
#[test]
fn a_chunk_is_reduced_only_to_the_width_that_reads_it() {
let built = |reduced: &ReducedColumns| {
reduced
.levels
.iter()
.enumerate()
.filter(|(_, level)| level.get().is_some())
.map(|(level, _)| 2usize << level)
.collect::<Vec<_>>()
};
let reduced = ReducedColumns::new(Arc::new(std::array::from_fn(|i| Column {
surface: None,
spans: vec![Span {
min: 0,
max: 4 + (i % 3) as i16,
state: BlockStateId(1),
biome: 0,
light: 240,
}]
.into(),
})));
assert!(built(&reduced).is_empty(), "nothing is reduced unread");
// A tile of full-detail cells reads the chunk's own columns.
reduced.get(3, 5, 1);
assert!(built(&reduced).is_empty());
reduced.get(4, 8, 4);
assert_eq!(built(&reduced), [4]);
// Read again, and the reduction is the one already built.
reduced.get(0, 0, 4);
assert_eq!(built(&reduced), [4]);
reduced.get(0, 0, 16);
assert_eq!(built(&reduced), [4, 16]);
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
#[test]
+5 -2
View File
@@ -1405,7 +1405,8 @@ fn pack_coords(coords: u32) -> u16 {
pub struct Mesher<'a> {
/// CardinalLighting.NETHER: brighter up/down faces.
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours,
/// only the dimension's up-face brightness (`lighting::pack_flat`).
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
pub separate_ao: bool,
@@ -1660,8 +1661,10 @@ impl Mesher<'_> {
None => ao,
};
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = if self.shader_pack_lighting || !q.shade {
let directional = if !q.shade {
1.0
} else if self.shader_pack_lighting {
lighting::pack_flat(self.nether_shading)
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
@@ -189,8 +189,9 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, only the up-face value
/// when a shader pack lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure
/// alpha for separate AO.
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
@@ -202,7 +203,7 @@ pub(super) fn tesselate(
use horizon_assets::model::Direction as D;
let cardinal = |d: D| {
if shader_pack_lighting {
1.0
lighting::pack_flat(nether)
} else {
lighting::cardinal(nether, d)
}
@@ -447,7 +448,10 @@ pub(super) fn tesselate(
Some(layer) if face_state.overlay => (layer, true),
_ => (flow, false),
};
let side = if matches!(d, Direction::North | Direction::South) {
// Vanilla's side is the up value times the side's; a shader pack's has no side factor.
let side = if shader_pack_lighting {
1.0
} else if matches!(d, Direction::North | Direction::South) {
cardinal(D::North)
} else {
cardinal(D::West)
@@ -19,6 +19,12 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
D::West | D::East => 0.6,
}
}
/// A face's brightness when a shader pack lights the faces (`Mesher::shader_pack_lighting`): no
/// directional shading, every face at the dimension's up-face value (1.0, the Nether's 0.9).
/// Our probe pack's Iris 1.11.7 values fit this: 0.9 on every Nether face, 1.0 in the overworld.
pub(super) fn pack_flat(nether: bool) -> f32 {
cardinal(nether, D::Up)
}
fn normal(face: D) -> [i32; 3] {
match face {
D::Down => [0, -1, 0],
@@ -159,6 +159,60 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
/// Run explicitly with prepared assets. With a shader pack lighting the faces, every face of a
/// block and of a lava source takes the dimension's up-face brightness, as Iris 1.11.7 gives our
/// probe pack: 0.9 in the Nether (`CardinalLighting.NETHER`'s up, 229 of 255), 1.0 elsewhere.
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let state = |name| assets.registry.default_state(name).unwrap().0;
let mut chunk = Chunk::empty(0, 0);
chunk.set(4, MIN_Y + 8, 4, state("minecraft:netherrack"));
chunk.set(11, MIN_Y + 8, 11, state("minecraft:lava"));
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let colours = |nether_shading| {
let mut mesher = Mesher {
nether_shading,
shader_pack_lighting: true,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility: visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
// Both blocks' six faces: 2 × 6 quads of 4 vertices.
let all: Vec<_> = mesh.solid.iter().chain(&mesh.translucent).collect();
assert!(all.len() >= 48, "{} vertices", all.len());
let mut colours: Vec<u32> = all.iter().map(|v| v.color & 0xFF_FFFF).collect();
colours.sort_unstable();
colours.dedup();
colours
};
assert_eq!(colours(true), [0xE5_E5E5]);
assert_eq!(colours(false), [0xFF_FFFF]);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
+257 -14
View File
@@ -1344,6 +1344,14 @@ enum WKind {
value: f32,
name: String,
},
/// A float or int option `.option` of the Horizon pack `s.shader_packs[.pack]`: where its
/// value sits between its minimum and maximum.
PackSlider {
label: Text,
value: f32,
pack: usize,
option: usize,
},
Text {
text: Text,
color: u32,
@@ -1372,6 +1380,8 @@ struct Widget {
enum FocusId {
Search,
Slider(Opt),
/// A Horizon pack's option slider: (pack, option).
PackSlider(usize, usize),
Cycle(Opt),
Act(Act),
Lang(String),
@@ -1381,6 +1391,7 @@ enum FocusId {
fn focus_id(i: usize, wd: &Widget) -> FocusId {
match &wd.kind {
WKind::Slider { opt, .. } => FocusId::Slider(*opt),
WKind::PackSlider { pack, option, .. } => FocusId::PackSlider(*pack, *option),
WKind::Button {
act: Act::Cycle(opt),
..
@@ -1394,6 +1405,9 @@ fn focus_id(i: usize, wd: &Widget) -> FocusId {
fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
match (id, &wd.kind) {
(FocusId::Slider(a), WKind::Slider { opt, .. }) => a == opt,
(FocusId::PackSlider(p, o), WKind::PackSlider { pack, option, .. }) => {
(p, o) == (pack, option)
}
(
FocusId::Cycle(a),
WKind::Button {
@@ -1512,6 +1526,9 @@ pub struct OptionsUi {
/// The shader pack option whose slider is held, and the handle's own position: it follows
/// the pointer while the option takes the nearest of its values.
shader_dragging: Option<(String, f32)>,
/// The same for a Horizon pack's option: (pack, option, the handle's position, whether its
/// value changed since the press). The pack saves its values, and reloads, on release.
pack_dragging: Option<(usize, usize, f32, bool)>,
/// Shift is held during the click (it resets a shader pack option to its default).
pub shift: bool,
/// The pressed slider's own position (`AbstractSliderButton.value`) and
@@ -1602,6 +1619,7 @@ impl Default for OptionsUi {
scroll: 0.0,
dragging: None,
shader_dragging: None,
pack_dragging: None,
shift: false,
slider_value: None,
scrollbar_drag_y: None,
@@ -1746,7 +1764,23 @@ fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label =
Text::option_name_value(Text::literal(option.name.clone()), Text::literal(value));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
match pack.slider_position(o) {
Some(value) => Widget {
x: 0.0,
y: 0.0,
w: width,
h: 20.0,
kind: WKind::PackSlider {
label,
value,
pack: i,
option: o,
},
list: false,
row: 0,
},
None => button(0.0, 0.0, width, label, Act::NativePackOption(i, o)),
}
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
@@ -1845,7 +1879,10 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
}
| WKind::PackSlider { pack, option, .. } => {
Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description)
}
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
@@ -2163,6 +2200,18 @@ impl OptionsUi {
}
events
}
/// Steps a Horizon pack's option slider one step forward or backward, and saves it.
fn step_pack_slider(s: &mut Settings, pack: usize, option: usize, forward: bool) -> Vec<Event> {
let stepped = s
.shader_packs
.get_mut(pack)
.is_some_and(|entry| entry.nudge_option(option, !forward));
if stepped {
vec![Event::NativePackOptions(pack)]
} else {
Vec::new()
}
}
/// Open a page whose Done/Escape returns directly to the calling screen.
pub fn open_at(&mut self, in_world: bool, page: Page) {
self.stack.clear();
@@ -3716,6 +3765,34 @@ impl OptionsUi {
dragging,
);
}
WKind::PackSlider {
label,
value,
pack,
option,
} => {
let held = self
.pack_dragging
.filter(|&(p, o, ..)| (p, o) == (*pack, *option));
let dragging = held.is_some();
ui::slider_ext(
batch,
atlas,
wd.x,
wd.y,
wd.w,
label,
held.map_or(*value, |h| h.2),
hover || dragging,
is_focused,
self.can_change_value && is_focused,
true,
dragging,
);
crate::widgets::last_narratable(|n| {
narrate_widget(n, s, wd, hover, list_key, framebuffer);
});
}
WKind::Text {
text,
color,
@@ -4015,6 +4092,16 @@ impl OptionsUi {
*at = ui::slider_value(wd.x, wd.w, x);
options.set_position(name, *at);
}
if let Some((pack, option, at, changed)) = &mut self.pack_dragging
&& let Some(wd) = widgets.iter().find(|wd| {
matches!(wd.kind, WKind::PackSlider { pack: p, option: o, .. }
if (p, o) == (*pack, *option))
})
&& let Some(entry) = s.shader_packs.get_mut(*pack)
{
*at = ui::slider_value(wd.x, wd.w, x);
*changed |= entry.set_slider_position(*option, *at);
}
if let Some(opt) = self.dragging
&& let Some(wd) = widgets
.iter()
@@ -4251,6 +4338,21 @@ impl OptionsUi {
}
self.shader_dragging = Some((name, at));
}
WKind::PackSlider { pack, option, .. } => {
self.can_change_value = true;
let Some(entry) = s.shader_packs.get_mut(pack) else {
break;
};
if self.shift {
// Its default, as a shift-click on a pack's button does; no drag follows.
entry.step_option(option, false, true);
events.extend([Event::ButtonClick, Event::NativePackOptions(pack)]);
break;
}
let at = ui::slider_value(wd.x, wd.w, x);
let changed = entry.set_slider_position(option, at);
self.pack_dragging = Some((pack, option, at, changed));
}
WKind::Slider { opt, .. } => {
self.can_change_value = true;
self.dragging = Some(opt);
@@ -4287,12 +4389,19 @@ impl OptionsUi {
}
// AbstractSliderButton suppresses the press sound and plays once on
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
let pack = self.pack_dragging.take();
let slider =
self.dragging.take().is_some() | self.shader_dragging.take().is_some() | pack.is_some();
let graphics_slider = self.graphics.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
let mut events = vec![Event::ButtonClick, Event::Changed];
// A pack's option is saved, and the pack rebuilt, once per drag.
if let Some((pack, _, _, true)) = pack {
events.push(Event::NativePackOptions(pack));
}
events
} else if split_drop {
vec![Event::ButtonClick]
} else {
@@ -4390,6 +4499,10 @@ impl OptionsUi {
.map_or(150.0, |wd| wd.w);
return self.step_slider_option(s, opt, amount > 0.0, width);
}
Some(FocusId::PackSlider(pack, option)) => {
let (pack, option) = (*pack, *option);
return Self::step_pack_slider(s, pack, option, amount > 0.0);
}
Some(FocusId::Cycle(opt)) => {
let opt = *opt;
let mut events = vec![Event::ButtonClick];
@@ -4855,6 +4968,7 @@ impl OptionsUi {
match wd.kind {
WKind::Button { active: true, .. }
| WKind::Slider { .. }
| WKind::PackSlider { .. }
| WKind::LanguageRow { .. } => {
focusables.push((focus_id(i, wd), [wd.x, wd.y, wd.w, wd.h], wd.list));
}
@@ -4890,7 +5004,7 @@ impl OptionsUi {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = matches!(next_id, FocusId::Slider(_));
self.can_change_value = matches!(next_id, FocusId::Slider(_) | FocusId::PackSlider(..));
if *in_list {
if rect[1] < top {
self.scroll -= (top - rect[1]) + 4.0;
@@ -4915,6 +5029,11 @@ impl OptionsUi {
.map_or(150.0, |wd| wd.w);
return self.step_slider_option(s, opt, forward, width);
}
if let Some(FocusId::PackSlider(pack, option)) = self.focus
&& self.can_change_value
{
return Self::step_pack_slider(s, pack, option, forward);
}
// Otherwise, navigate horizontally using 2D focus stepping
if let Some(fid) = &self.focus {
if let Some(cur_pos) = focusables.iter().position(|(id, ..)| id == fid) {
@@ -4992,7 +5111,7 @@ impl OptionsUi {
// 4. Enter / Space selection keys
if matches!(key, K::Enter | K::NumpadEnter | K::Space) {
match &self.focus {
Some(FocusId::Slider(_)) => {
Some(FocusId::Slider(_) | FocusId::PackSlider(..)) => {
self.can_change_value = !self.can_change_value;
return Vec::new();
}
@@ -5505,11 +5624,9 @@ mod tests {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
kind: horizon_shader_api::OptionKind::Choice {
values: vec!["x".into(), "y".into(), "z".into()],
default: 1,
},
}],
values: vec![1.0],
@@ -5550,10 +5667,10 @@ mod tests {
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
assert_eq!(s.shader_packs[0].values, [2.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
assert_eq!(s.shader_packs[0].values, [0.0]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
@@ -5567,6 +5684,116 @@ mod tests {
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
fn a_horizon_packs_float_and_int_options_are_sliders_saved_on_release() {
use horizon_shader_api::{OptionKind, PackOption};
use winit::keyboard::KeyCode;
let atlas = atlas();
let size = (960.0, 540.0);
let option = |id: &str, kind| PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
};
let pack = crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![
option(
"a",
OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
),
option(
"b",
OptionKind::Int {
default: 2,
min: 0,
max: 4,
},
),
option("c", OptionKind::Bool { default: true }),
],
values: vec![1.0, 2.0, 1.0],
pages: Vec::new(),
};
let mut s = Settings {
shader_packs: vec![pack],
..Settings::default()
};
let mut ui = OptionsUi::default();
ui.show(Page::NativePackOptions(0, 0));
// A slider's track, from its left end to its right: (x of 0, x of 1, y).
let track = |ui: &mut OptionsUi, s: &Settings, want: usize| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
widgets
.into_iter()
.find(|w| matches!(w.kind, WKind::PackSlider { option, .. } if option == want))
.map(|w| (w.x + 4.0, w.x + w.w - 4.0, w.y + w.h / 2.0))
};
assert!(track(&mut ui, &s, 2).is_none(), "a bool stays a button");
let (left, right, y) = track(&mut ui, &s, 0).expect("the float's slider");
let at = |f: f32| left + f * (right - left);
// Pressed a fifth of the way: 0.4 of 2, the nearest step 0.5. Nothing is saved yet.
let events = ui.click(&mut s, &atlas, at(0.2), y, size, [854, 480], false, false);
assert!(!events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values[0], 0.5);
// Dragged past the right end: the maximum.
ui.click(
&mut s,
&atlas,
right + 30.0,
y,
size,
[854, 480],
true,
false,
);
assert_eq!(s.shader_packs[0].values[0], 2.0);
// Released: saved once, which reloads the pack.
let events = ui.release();
let saves = events.iter().filter(|e| **e == Event::NativePackOptions(0));
assert_eq!(saves.count(), 1);
// A press and release that leave the value as it was save nothing.
ui.click(&mut s, &atlas, right, y, size, [854, 480], false, false);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
// Shift-click: the default at once.
ui.shift = true;
let events = ui.click(&mut s, &atlas, at(0.9), y, size, [854, 480], false, false);
ui.shift = false;
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values[0], 1.0);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
// The int takes whole numbers: 0.6 of 0..4 is 2.4, so 2, unchanged; 0.7 is 3.
let (left, right, y) = track(&mut ui, &s, 1).expect("the int's slider");
let at = |f: f32| left + f * (right - left);
ui.click(&mut s, &atlas, at(0.6), y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values[1], 2.0);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
ui.click(&mut s, &atlas, at(0.7), y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values[1], 3.0);
assert!(ui.release().contains(&Event::NativePackOptions(0)));
// From the keyboard: the arrows step a focused slider, saving each step, up to its end.
ui.focus = Some(FocusId::PackSlider(0, 1));
ui.focus_from_keyboard = true;
ui.can_change_value = true;
let events = ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
assert_eq!(events, [Event::NativePackOptions(0)]);
assert_eq!(s.shader_packs[0].values[1], 4.0);
assert!(ui.key(&mut s, &atlas, KeyCode::ArrowRight, None).is_empty());
assert_eq!(s.shader_packs[0].values[1], 4.0);
ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
assert_eq!(s.shader_packs[0].values[1], 3.0);
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
@@ -8039,7 +8266,7 @@ mod tests {
// Also verify Act::Extra on Page::Extras
ui.show(Page::Extras);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
if let Some(extra_btn) = widgets.iter().find(|wd| {
matches!(
wd.kind,
@@ -8050,6 +8277,22 @@ mod tests {
}
)
}) {
// Extras has enough rows to scroll: click the cycle button inside
// the list's viewport, as a player would after scrolling to it.
ui.scroll = (extra_btn.y - top).max(0.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let extra_btn = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
})
.unwrap();
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
assert_eq!(s.extras.cosmetics_others, Others::Shown);
+96 -3
View File
@@ -170,8 +170,17 @@ fn last_pre_minor(current: Format) -> i64 {
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
/// non-negative ints whose second is the minor. None: it does not parse.
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
let non_negative =
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
// `Codec.INT` reads any JSON number through `Number.intValue`: a decimal such as `97.1`
// counts as its whole part (97), as it does for the game.
let non_negative = |v: &serde_json::Value| {
v.as_i64()
.or_else(|| {
v.as_f64()
.filter(|f| f.is_finite())
.map(|f| f.trunc() as i64)
})
.filter(|n| *n >= 0 && *n <= i64::from(i32::MAX))
};
match v {
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
@@ -636,7 +645,7 @@ fn build_overlay_uncached(
}
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
// supplies the file or one above it; vanilla's own sits below every pack.
let hidden: BTreeSet<String> = files
let mut hidden: BTreeSet<String> = files
.iter()
.filter(|(name, _)| !name.ends_with(".mcmeta"))
.filter_map(|(name, (owner, _))| {
@@ -647,6 +656,25 @@ fn build_overlay_uncached(
}
})
.collect();
// A companion from below the winning colour would describe another surface.
// A map-only pack above it is intentional and remains valid.
for (name, (owner, _)) in &files {
let Some(stem) = name.strip_suffix(".png") else {
continue;
};
if stem.ends_with("_n") || stem.ends_with("_s") {
continue;
}
for suffix in ["_n.png", "_s.png"] {
let map = format!("{stem}{suffix}");
if files
.get(&map)
.is_none_or(|(map_owner, _)| map_owner < owner)
{
hidden.insert(map);
}
}
}
for name in &hidden {
files.remove(name);
}
@@ -1257,6 +1285,16 @@ mod tests {
.unwrap_err()
.contains("deprecated")
);
// A decimal reads as its whole part, in a list or alone: `[97.1]` is format 97 (a pack
// that meant `[97, 1]` still loads and counts as compatible).
assert_eq!(
check(r#"{"min_format":[97.1],"max_format":[97.1]}"#),
Ok(((97, 0), (97, i64::from(i32::MAX))))
);
assert_eq!(
check(r#"{"min_format":97.9,"max_format":98.5}"#),
Ok(((97, 0), (98, i64::from(i32::MAX))))
);
assert_eq!(
check(r#"{"min_format":97,"max_format":98}"#),
Ok(((97, 0), (98, i64::from(i32::MAX))))
@@ -1469,6 +1507,61 @@ mod tests {
assert_eq!(merge_kind("assets/minecraft/models/block/stone.json"), None);
}
#[test]
fn companion_maps_follow_the_winning_colour_or_a_higher_map_only_pack() {
let root =
std::env::temp_dir().join(format!("horizon-material-owner-{}", std::process::id()));
let vanilla = root.join("vanilla");
let relative = "assets/minecraft/textures/block";
fs::create_dir_all(vanilla.join(relative)).unwrap();
let names = ["stone.png", "stone_n.png", "stone_s.png"];
for name in names {
fs::write(vanilla.join(relative).join(name), b"base").unwrap();
}
for (pack, files) in [
("low", vec!["stone_n.png", "stone_s.png"]),
("colour", vec!["stone.png"]),
("maps", vec!["stone_n.png"]),
] {
let dir = root.join("packs").join(pack);
fs::create_dir_all(dir.join(relative)).unwrap();
fs::write(
dir.join("pack.mcmeta"),
br#"{"pack":{"description":"fixture","pack_format":97}}"#,
)
.unwrap();
for name in files {
fs::write(dir.join(relative).join(name), pack.as_bytes()).unwrap();
}
}
let all = discover(&vanilla, &root.join("packs"));
let order = |names: &[&str]| {
names
.iter()
.map(|name| {
all.iter()
.find(|p| p.id == format!("file/{name}"))
.unwrap()
.clone()
})
.collect::<Vec<_>>()
};
let out = root.join("out");
build_overlay(&vanilla, &order(&["low", "colour"]), &out).unwrap();
assert_eq!(
fs::read(out.join(relative).join("stone.png")).unwrap(),
b"colour"
);
assert!(!out.join(relative).join("stone_n.png").exists());
assert!(!out.join(relative).join("stone_s.png").exists());
build_overlay(&vanilla, &order(&["low", "colour", "maps"]), &out).unwrap();
assert_eq!(
fs::read(out.join(relative).join("stone_n.png")).unwrap(),
b"maps"
);
assert!(!out.join(relative).join("stone_s.png").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn overlay_layers_packs_over_vanilla() {
// `&` would split an unquoted `cmd /C mklink` line.
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
@@ -13,7 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
// Version 7 stacks blockstate files (every pack's definition, bottom first).
const VERSION: u32 = 7;
const VERSION: u32 = 8;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
@@ -26,6 +26,8 @@ use std::{
pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
mod native;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -212,6 +214,9 @@ enum Light {
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Base,
Controlled {
translucent: bool,
},
Mod {
size: [f32; 2],
alpha: [f32; 2],
@@ -505,6 +510,7 @@ pub struct Particle {
pre_tick: bool,
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
}
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
let w = f64::from(w) / 2.0;
@@ -841,8 +847,37 @@ impl Particle {
pre_tick: false,
frame_ticks: 0,
random: LegacyRandom::new(0),
controller: None,
}
}
/// The normal age/removal test and previous pose, without vanilla motion.
/// Returns false when this tick expires the particle.
pub fn advance_age(&mut self) -> bool {
self.previous = self.position;
self.o_roll = self.roll;
let alive = self.age < self.lifetime;
self.age = self.age.saturating_add(1);
self.removed |= !alive;
alive
}
pub fn remove(&mut self) {
self.removed = true;
}
/// Hide geometry while retaining the particle's lifetime and controller.
pub fn set_visible(&mut self, visible: bool) {
self.no_render = !visible;
}
/// The particle's own random, distinct from the engine and level generators.
pub fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
pub fn set_full_bright(&mut self, full_bright: bool) {
self.light = if full_bright {
Light::FullBright
} else {
Light::World
};
}
/// `Particle(level, x, y, z, xa, ya, za)`: jittered, normalized initial velocity.
fn moving(random: &mut LegacyRandom, ty: ParticleType, p: [f64; 3], v: [f64; 3]) -> Self {
let mut particle = Self::base_moving(random, ty, p, v);
@@ -1420,6 +1455,10 @@ impl ParticleEngine {
atlas: &HashMap<String, Rect>,
translucent: &HashSet<String>,
) {
// Controllers' numeric sprite-set handles belong to this resource
// generation. Discard them rather than selecting another set after reload.
self.particles.retain(|p| p.controller.is_none());
self.pending.retain(|p| p.controller.is_none());
self.atlas = atlas
.iter()
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
@@ -1584,6 +1623,45 @@ impl ParticleEngine {
pub fn push(&mut self, particle: Particle) {
self.pending.push(particle);
}
/// Create a named billboard with custom compiled behavior and no vanilla
/// motion. Like `add`, respects camera distance and the Particles setting.
/// Configure its public fields, then `push` it to the ordinary engine queue.
/// Missing sprite sets return None. The constructor has the ordinary
/// stationary SingleQuadParticle random size/lifetime and no velocity draws.
pub fn controlled<C: ParticleController>(
&mut self,
name: &str,
position: [f64; 3],
translucent: bool,
controller: C,
) -> Option<Particle> {
let minimal = self.calculate_particle_level(false) == 2;
let distance: f64 = position
.iter()
.zip(self.camera)
.map(|(p, c)| (p - c).powi(2))
.sum();
if minimal || !distance.is_finite() || distance > 1024.0 {
return None;
}
let (set, _) = *self.mod_sets.get(name)?;
let mut random = LegacyRandom::new(self.seeds.next_long());
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
particle.random = random;
particle.set = Some(set);
particle.sprite = Sprite::Index(*self.set_sprites.get(set as usize)?.first()?);
particle.behaviour = Behaviour::Controlled { translucent };
particle.controller = Some(Box::new(controller));
Some(particle)
}
/// Choose a frame from this particle's declared sprite set.
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
&& !sprites.is_empty()
{
particle.sprite = Sprite::Index(sprites[frame.min(sprites.len() - 1)]);
}
}
/// Random generator shared by particle constructors and spawners.
pub fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
@@ -3710,6 +3788,20 @@ impl ParticleEngine {
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Controlled { .. } => {
if let Some(mut controller) = p.controller.take() {
let update = controller.tick(p, level);
p.controller = Some(controller);
if let Some(frame) = update.frame {
self.set_sprite_frame(p, frame);
}
for (options, pos, velocity) in update.spawned {
self.add(&options, pos, velocity);
}
} else {
p.remove();
}
}
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
@@ -4627,6 +4719,7 @@ impl ParticleEngine {
Sprite::Index(i) => (
self.rects.get(*i as usize).copied().flatten(),
p.ty.translucent_layer()
|| matches!(p.behaviour, Behaviour::Controlled { translucent: true })
|| matches!(p.behaviour, Behaviour::Mod { alpha, .. } if alpha != [1.0; 2]),
),
Sprite::Named(name) => match self.atlas.get(name) {
@@ -0,0 +1,179 @@
//! Opt-in, compiled particle behavior. The ordinary engine still owns the
//! particle, its sprite set, interpolation, rendering and total count limits.
use super::{Particle, ParticleLevel, ParticleOptions};
use std::fmt::Debug;
/// Requests made by one particle tick, submitted through normal spawn limits.
#[derive(Default)]
pub struct ParticleUpdate {
/// An index in this particle's declared sprite set. Out-of-range is clamped.
pub frame: Option<usize>,
pub spawned: Vec<(ParticleOptions, [f64; 3], [f64; 3])>,
}
/// Owned behavior for a compiled native particle. Cloning a particle clones its
/// controller state. Do not retain world references; inspect `level` each tick.
pub trait ParticleController: Clone + Debug + Send + Sync + 'static {
/// Call `particle.advance_age()` for ordinary lifetime/interpolation, or
/// explicitly maintain those fields for an overridden source tick.
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
}
pub(super) trait Controller: Debug + Send + Sync {
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
fn clone_box(&self) -> Box<dyn Controller>;
}
impl<T: ParticleController> Controller for T {
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
ParticleController::tick(self, particle, level)
}
fn clone_box(&self) -> Box<dyn Controller> {
Box::new(self.clone())
}
}
impl Clone for Box<dyn Controller> {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use horizon_world::{
DATA_VERSION,
collision::CachedCollisionRegistry,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
#[derive(Clone, Debug)]
struct Moving(u32);
impl ParticleController for Moving {
fn tick(&mut self, p: &mut Particle, _: &ParticleLevel<'_>) -> ParticleUpdate {
if p.advance_age() {
self.0 += 1;
p.position[0] += f64::from(self.0);
}
ParticleUpdate {
frame: Some(self.0 as usize),
..Default::default()
}
}
}
fn engine() -> ParticleEngine {
let mut e = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
e.mod_sets.insert("example:orbit".into(), (0, None));
e.set_sprites.push(vec![0, 1]);
e.rects = vec![
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16
});
2
];
e
}
#[test]
fn controlled_particle_keeps_owned_motion_sprite_interpolation_and_expiry() {
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(vec![Tag::String("minecraft:air".into())]),
),
("defaults", Tag::compound([("minecraft:air", Tag::Int(0))])),
]))
.unwrap();
let cached = CachedCollisionRegistry::from_nbt(
&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(vec![Tag::String("minecraft:air".into())]),
),
("shapes", Tag::List(vec![])),
("cached_shapes", Tag::IntArray(vec![-1])),
]),
&registry,
)
.unwrap();
let collision = BlockCollisionRegistry::new(std::sync::Arc::new(cached)).unwrap();
let facts = BlockFacts {
states: vec![],
air: BlockStateId(0),
woods: HashMap::new(),
};
let world = World::new(BlockStateId(0), 0);
let level = ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
};
let mut e = engine();
let mut p = e
.controlled("example:orbit", [0.0; 3], true, Moving(0))
.unwrap();
p.lifetime = 2;
p.rgba[3] = 0.5;
p.velocity = [99.0; 3]; // no vanilla drift may leak into the custom tick
let mut copy = p.clone();
e.tick_particle(&mut p, &level);
e.tick_particle(&mut p, &level);
e.tick_particle(&mut copy, &level);
assert_eq!((p.position, p.previous), ([3.0, 0.0, 0.0], [1.0, 0.0, 0.0]));
assert_eq!(copy.position, [1.0, 0.0, 0.0]); // controller state cloned independently
assert!(matches!(p.sprite, Sprite::Index(1))); // clamped frame
p.size = 0.1;
e.particles.push(p.clone());
let mut vertices = Vec::new();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
));
e.particles[0].set_visible(false);
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
assert_eq!(e.particles[0].age, 2);
e.pending.push(copy);
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(e.pending.is_empty());
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
e.tick_particle(&mut p, &level);
assert!(p.removed);
assert_eq!(p.position, [3.0, 0.0, 0.0]);
}
#[test]
fn controlled_spawn_respects_distance_settings_and_declared_sprite_sets() {
let mut e = engine();
assert!(
e.controlled("example:missing", [0.0; 3], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [33.0, 0.0, 0.0], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [f64::NAN, 0.0, 0.0], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [32.0, 0.0, 0.0], true, Moving(0))
.is_some()
);
e.particle_level = 2;
assert!(
e.controlled("example:orbit", [0.0; 3], true, Moving(0))
.is_none()
);
}
}
@@ -53,6 +53,47 @@ impl Entry {
true
}
/// Where option `i` sits on its slider, 0 to 1: float and int options are sliders.
pub fn slider_position(&self, i: usize) -> Option<f32> {
slider_position(&self.options.get(i)?.kind, *self.values.get(i)?)
}
/// Option `i` at the value nearest the slider position `at`. Whether its value changed.
pub fn set_slider_position(&mut self, i: usize, at: f32) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let Some(new) = slider_value(&o.kind, at) else {
return false;
};
let changed = new != *v;
*v = new;
changed
}
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
/// value changed.
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let dir = if back { -1.0 } else { 1.0 };
let new = match &o.kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let n = ((*v - min) / step).round() + dir;
on_f32((min + n * step).clamp(min, max))
}
OptionKind::Int { min, max, .. } => {
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
}
_ => return false,
};
let changed = new != *v;
*v = new;
changed
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
@@ -209,6 +250,42 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
}
}
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
/// that are buttons.
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
let (min, max) = match kind {
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
_ => return None,
};
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
}
/// The option's value nearest the slider position `at`: a float on its steps from the
/// minimum (the last one the maximum, when the range isn't a whole number of steps), an int
/// whole.
fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
let at = f64::from(at.clamp(0.0, 1.0));
match kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let steps = ((max - min) / step - 1e-6).ceil();
Some(on_f32((min + (at * steps).round() * step).min(max)))
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
Some((min + at * (max - min)).round())
}
_ => None,
}
}
/// `v` as the `f32` the pack's `override` gets: a step of 0.05 is no f32, and 20 of them would
/// otherwise miss a default of 1 by a hair.
fn on_f32(v: f64) -> f64 {
f64::from(v as f32)
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
@@ -324,4 +401,65 @@ mod tests {
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn sliders_take_the_nearest_step_and_stop_at_the_ends() {
let float = |default, max, step| OptionKind::Float {
default,
min: 0.0,
max,
step,
};
let int = OptionKind::Int {
default: 2,
min: 0,
max: 10,
};
let mut entry = Entry {
file: "p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![
option("a", float(1.0, 2.0, 0.05)),
option("b", int),
option("c", OptionKind::Bool { default: true }),
option("d", float(0.0, 1.0, 0.3)),
],
values: vec![1.0, 2.0, 1.0, 0.0],
pages: Vec::new(),
};
assert_eq!(entry.slider_position(0), Some(0.5));
assert_eq!(entry.slider_position(1), Some(0.2));
// A bool (and a choice) stays a button.
assert_eq!(entry.slider_position(2), None);
assert!(!entry.set_slider_position(2, 0.0));
assert_eq!(entry.values[2], 1.0);
// 0.26 of 0..2 is 0.52: the step below.
assert!(entry.set_slider_position(0, 0.26));
assert_eq!(show_value(&entry.options[0].kind, entry.values[0]), "0.50");
// Twenty steps of 0.05 are the default of 1 exactly, so it isn't saved.
assert!(entry.set_slider_position(0, 0.5));
assert_eq!(entry.values[0], 1.0);
assert!(!entry.set_slider_position(0, 0.505));
// 5.7 of 0..10 is 6.
assert!(entry.set_slider_position(1, 0.57));
assert_eq!(entry.values[1], 6.0);
// A range that isn't a whole number of steps still reaches its maximum.
entry.set_slider_position(3, 1.0);
assert_eq!(entry.values[3], 1.0);
entry.set_slider_position(3, 0.6);
assert_eq!(entry.values[3], f64::from(0.6_f32));
// The arrow keys: a step at a time, not round past the ends.
entry.values[0] = 2.0;
assert!(!entry.nudge_option(0, false));
assert_eq!(entry.values[0], 2.0);
assert!(entry.nudge_option(0, true));
assert_eq!(entry.values[0], f64::from(1.95_f32));
entry.values[1] = 0.0;
assert!(!entry.nudge_option(1, true));
assert!(entry.nudge_option(1, false));
assert_eq!(entry.values[1], 1.0);
assert!(!entry.nudge_option(2, false));
}
}
+74 -17
View File
@@ -68,27 +68,48 @@ fn cpu_if_enabled() -> Option<Duration> {
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
let _span = guard(name);
f()
}
/// A span that ends where it is dropped: [`time`] for a step with several ways out (early
/// returns). Nothing is read while spans are off.
#[must_use = "the span ends when the guard is dropped"]
pub struct Guard {
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
_heap: Option<horizon_threads::heap_census::Tag>,
}
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
#[inline]
pub fn guard(name: &'static str) -> Guard {
let _heap = horizon_threads::heap_tag(name);
if !recording_here() {
return f();
return Guard { open: None, _heap };
}
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
let (started, cpu) = (Instant::now(), cpu_if_enabled());
let out = f();
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
out
Guard {
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
_heap,
}
}
impl Drop for Guard {
fn drop(&mut self) {
let Some((name, started, cpu, depth)) = self.open.take() else {
return;
};
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
}
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
@@ -551,6 +572,42 @@ mod tests {
.unwrap();
}
/// A guarded span ends on every way out of its step, an early return's too, and holds the
/// spans that ran inside it until then; the next span outside it is back at its depth.
#[test]
fn a_guarded_span_ends_where_its_step_returns() {
fn step(early: bool) -> u32 {
let _span = super::guard("use_block");
super::time("open_loot", || ());
if early {
return 1;
}
super::time("open_maps", || ());
2
}
std::thread::spawn(|| {
super::record_here(true);
assert_eq!(step(true), 1);
assert_eq!(step(false), 2);
super::time("use_item", || ());
let raw = super::take_raw();
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
assert_eq!(
seen,
[
("open_loot", 1),
("use_block", 0),
("open_loot", 1),
("open_maps", 1),
("use_block", 0),
("use_item", 0)
]
);
})
.join()
.unwrap();
}
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
/// at least half of it; repeated spans (several ticks a frame) add up.
#[test]
+137 -51
View File
@@ -8,6 +8,7 @@ pub struct SpriteAnimation {
pub layer: u32,
frames: Arc<Vec<Vec<u8>>>,
clip: Arc<AnimationClip>,
materials: [Option<Arc<Vec<Vec<u8>>>>; 2],
}
/// What an animated sprite shows: `frame`, mixed `millis` thousandths of the
/// way towards `next` (0 for a single frame).
@@ -18,6 +19,14 @@ pub struct FrameBlend {
pub millis: u32,
}
impl SpriteAnimation {
pub(crate) fn has_materials(&self) -> bool {
self.materials.iter().any(Option::is_some)
}
/// Material categories follow the selected colour frame, without colour interpolation.
pub(crate) fn material_pixels(&self, kind: usize, tick: u64) -> Option<&[u8]> {
let frames = self.materials[kind].as_ref()?;
Some(&frames[self.frame(tick) % frames.len()])
}
pub fn frame(&self, tick: u64) -> usize {
self.clip.sample(tick).frame as usize
}
@@ -88,9 +97,9 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their
/// frames at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<Vec<u8>>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
@@ -126,7 +135,7 @@ pub fn set_texture_budget(mib: u32) {
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// textures. Native material stages and terrain shader packs read them; without a pack they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
@@ -135,7 +144,7 @@ pub fn set_labpbr_maps(on: bool) {
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
/// Budget for either complete normal or specular image set.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
@@ -153,30 +162,12 @@ fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
/// Reduce data maps by selecting whole texels: material IDs and sentinel bytes cannot be averaged.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
(max, resample(&frame, side, max))
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
@@ -289,14 +280,28 @@ impl Sprites {
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for animation in &mut self.animations {
animation.materials = self.pbr[animation.layer as usize].each_ref().map(|map| {
map.as_ref().map(|(from, frames)| {
Arc::new(
frames
.iter()
.map(|frame| resample(frame, *from, pbr_size))
.collect(),
)
})
});
}
for (layer, maps) in std::mem::take(&mut self.pbr).into_iter().enumerate() {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
Some((from, frames)) => {
let initial = self.native[layer].2 % frames.len();
let (from, pixels) =
shrink_map(frames[initial].clone(), from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
@@ -638,8 +643,10 @@ impl Sprites {
let pbr = match name.split_once(':') {
Some(_) if labpbr_maps() => {
let path = crate::atlas_sources::texture_path(root, name, "textures");
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count, animation.as_ref()))
}
None if labpbr_maps() && !name.starts_with("fusion/") => ["_n", "_s"]
.map(|suffix| pbr_map(&root.join(name), suffix, count, animation.as_ref())),
_ => [None, None],
};
let settings = crate::mipmap::Settings {
@@ -691,6 +698,7 @@ impl Sprites {
layer,
frames: frames.clone(),
clip: Arc::new(clip),
materials: [None, None],
});
}
self.mips.push(settings);
@@ -734,32 +742,64 @@ fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
/// Companion frames use the colour image's grid; a single-frame map stays static.
fn pbr_map(
path: &Path,
suffix: &str,
count: u32,
animation: Option<&AnimationMetadata>,
) -> Option<(u32, Vec<Vec<u8>>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
let (width, height, _) = read_rgba(path).ok()?;
let [frame_width, frame_height] = animation.map_or([width as i32, height as i32], |a| {
a.frame_size(width as i32, height as i32)
});
let (frame_width, frame_height) = (
u32::try_from(frame_width).ok()?,
u32::try_from(frame_height).ok()?,
);
if frame_width == 0 || frame_height == 0 {
return None;
}
let (columns, rows) = if count <= 1 || (map_width == frame_width && map_height == frame_height)
{
(1, 1)
} else if u64::from(map_width) * u64::from(height) == u64::from(map_height) * u64::from(width) {
(width / frame_width, height / frame_height)
} else if u64::from(map_width) * u64::from(frame_height)
== u64::from(map_height) * u64::from(frame_width)
{
(1, 1)
} else {
return None;
};
if columns == 0 || rows == 0 || map_width % columns != 0 || map_height % rows != 0 {
return None;
}
let (w, h) = (map_width / columns, map_height / rows);
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
let frames = (0..columns * rows)
.map(|index| {
let (x, y) = (index % columns * w, index / columns * h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset =
(((y + row * h / side) * map_width + x + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
resample(&frame, side, output_side)
})
.collect();
Some((output_side, frames))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -790,6 +830,7 @@ pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> S
layer,
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
frames: Arc::new(frames),
materials: [None, None],
}
}
@@ -815,8 +856,8 @@ mod tests {
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
// Selecting a texel preserves encoded channels, including categorical values.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
@@ -824,14 +865,14 @@ mod tests {
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
assert_eq!(shrink_map(frame, 2, 1), (1, vec![200, 100, 0, 255]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[Some((16, vec![[9, 8, 7, 6].repeat(16 * 16)])), None],
[None, None],
],
..Default::default()
@@ -844,6 +885,50 @@ mod tests {
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn companion_grid_and_static_map_follow_colour_frame_selection() {
let root =
std::env::temp_dir().join(format!("horizon-companion-grid-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let write = |name: &str, width, height, pixels: &[u8]| {
let mut encoder =
png::Encoder::new(File::create(root.join(name)).unwrap(), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(pixels)
.unwrap();
};
write("grid.png", 4, 4, &[200, 200, 200, 255].repeat(16));
let maps: Vec<u8> = (0..16)
.flat_map(|i| {
let cell = (i / 8) * 2 + (i % 4) / 2;
[10 + cell as u8, 230, 65, 255]
})
.collect();
write("grid_s.png", 4, 4, &maps);
write("grid_n.png", 2, 2, &[128, 128, 255, 255].repeat(4));
let metadata =
r#"{"width":2,"height":2,"frametime":2,"interpolate":true,"frames":[3,1,2,0]}"#;
let animation = AnimationMetadata::from_json(metadata).unwrap();
let (side, frames) = pbr_map(&root.join("grid.png"), "_s", 4, Some(&animation)).unwrap();
assert_eq!(side, 2);
assert_eq!(
frames.iter().map(|f| f[0]).collect::<Vec<_>>(),
[10, 11, 12, 13]
);
let (_, normals) = pbr_map(&root.join("grid.png"), "_n", 4, Some(&animation)).unwrap();
assert_eq!(normals.len(), 1);
let mut sprite = test_frames(0, vec![vec![0; 16]; 4], metadata);
sprite.materials = [Some(Arc::new(normals)), Some(Arc::new(frames))];
for (tick, expected) in [(0, 13), (1, 13), (2, 11), (4, 12), (6, 10)] {
assert_eq!(sprite.material_pixels(1, tick).unwrap()[0], expected);
assert_eq!(&sprite.material_pixels(0, tick).unwrap()[..4], &FLAT_NORMAL);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
@@ -1284,6 +1369,7 @@ mod tests {
fn animation_boundaries_and_repeated_frames_do_not_depend_on_render_rate() {
let animation = SpriteAnimation {
layer: 0,
materials: [None, None],
frames: Arc::new(vec![vec![], vec![], vec![]]),
clip: Arc::new(AnimationMetadata::from_json(r#"{"frames":[{"index":2,"time":8},{"index":1,"time":3},{"index":1,"time":2},0]}"#).unwrap().clip(3).unwrap().unwrap()),
};
@@ -0,0 +1,191 @@
//! Original synthetic rendering controls for ordinary uniform initial values.
use horizon_client_render::gfx::pack_uniforms::Value;
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
use wgpu::util::DeviceExt;
fn source(code: &str) -> Preprocessed {
Preprocessed {
code: code.into(),
files: vec!["original-render.glsl".into()],
lines: code
.lines()
.enumerate()
.map(|(i, _)| Origin {
file: 0,
line: i as u32 + 1,
})
.collect(),
version: Some((430, None)),
..Default::default()
}
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn authored_defaults_render_and_runtime_values_override_including_zero() {
let program = translate_composite(
&source("void main() { gl_Position = ftransform(); }"),
&source("uniform float gain = 0.375; uniform mat3 basis = mat3(2.0); uniform mat4 transform = mat4(1.0); uniform vec3 tint = vec3(0.125,0.25,0.5); uniform float points[2] = float[](0.0625,0.125); uniform int mode = 3; uniform bool enabled = true; out vec4 color; void main() { color = vec4(gain * basis[0][0] * transform[2][2], tint.g + points[1], float(mode), enabled ? 1.0 : 0.0); }"),
Target::Buffers,
).unwrap();
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("GPU adapter required; missing adapter is not a pass");
eprintln!("Uniform defaults GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let vertex = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.vertex)),
});
let fragment = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment)),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let target = 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::Rgba32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
for (name, zero_buffer, overrides, expected) in [
("authored defaults", false, vec![], [0.75, 0.375, 3.0, 1.0]),
(
"runtime precedence",
false,
vec![
("gain", Value::F([0.125; 16])),
("transform", Value::F([0.5; 16])),
("mode", Value::I([7, 0, 0])),
("enabled", Value::I([0; 3])),
],
[0.125, 0.375, 7.0, 0.0],
),
(
"runtime explicit zero",
false,
vec![("gain", Value::F([0.; 16])), ("mode", Value::I([0; 3]))],
[0.0, 0.375, 0.0, 1.0],
),
("zero upload negative control", true, vec![], [0.; 4]),
] {
let mut bytes = if zero_buffer {
vec![0; program.interface.uniform_size as usize]
} else {
program.interface.initial_values.clone()
};
for (name, value) in overrides {
let uniform = program
.interface
.uniforms
.iter()
.find(|u| u.name == name)
.unwrap();
value.write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(name),
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..4, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
{
let mapped = readback.slice(..).get_mapped_range().unwrap();
let actual: &[f32] = bytemuck::cast_slice(&mapped[..16]);
eprintln!("{name}: {actual:?}");
assert_eq!(actual, expected, "{name}");
}
readback.unmap();
}
}
+224 -34
View File
@@ -61,6 +61,9 @@ pub struct Tree {
/// those naming none in `recipes_any`.
by_recipe: HashMap<String, Vec<(usize, usize)>>,
recipes_any: Vec<(usize, usize)>,
/// The triggers with a criterion that may test what the player looks at
/// (`index_looking_at`).
looking_at_triggers: BTreeSet<String>,
}
fn frame(v: &Value) -> Frame {
match v.as_str() {
@@ -141,6 +144,7 @@ impl Tree {
}
}
tree.index_recipes();
tree.index_looking_at();
tree
}
/// Index the `recipe_unlocked` criteria by recipe: a grant that unlocks
@@ -159,13 +163,30 @@ impl Tree {
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
Some(r) => self
.by_recipe
.entry(r.to_owned())
.entry(full(r).into_owned())
.or_default()
.push((ai, ci)),
None => self.recipes_any.push((ai, ci)),
}
}
}
/// Index the triggers whose criteria name `looking_at`, or a
/// `minecraft:reference` to a predicate file that might: the text is
/// searched, nothing is read.
pub fn index_looking_at(&mut self) {
let reference = ["\"minecraft:reference\"", "\"reference\""];
self.looking_at_triggers = (self.list.iter().flat_map(|a| &a.criteria))
.filter(|c| {
let json = c.conditions.json();
json.contains("\"looking_at\"") || reference.iter().any(|r| json.contains(r))
})
.map(|c| c.trigger.clone())
.collect();
}
/// Whether a criterion of `trigger` may test what the player looks at.
pub fn may_look(&self, trigger: &str) -> bool {
self.looking_at_triggers.contains(trigger)
}
/// The `recipe_unlocked` criteria that could pass for `recipe`, in tree
/// order (the others name another recipe).
pub fn recipe_criteria(&self, recipe: &str) -> Vec<(usize, usize)> {
@@ -198,8 +219,8 @@ impl InventoryIndex {
fn ids<'t>(v: &Value, tag: &impl Fn(&str) -> Vec<&'t str>, out: &mut Vec<String>) {
match v {
Value::String(s) => match s.strip_prefix('#') {
Some(t) => out.extend(tag(t).into_iter().map(str::to_owned)),
None => out.push(s.clone()),
Some(t) => out.extend(tag(&full(t)).into_iter().map(str::to_owned)),
None => out.push(full(s).into_owned()),
},
Value::Array(a) => a.iter().for_each(|x| ids(x, tag, out)),
_ => {}
@@ -253,7 +274,9 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
.iter()
.map(|(name, c)| Criterion {
name: name.clone(),
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
trigger: c["trigger"]
.as_str()
.map_or(String::new(), |t| full(t).into_owned()),
conditions: LazyJson::new(&c["conditions"]),
})
.collect();
@@ -500,13 +523,21 @@ fn range_f(v: &Value, x: f64) -> bool {
pub fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// `Identifier.parse`: an id written without a namespace is a `minecraft:` one.
pub fn full(id: &str) -> std::borrow::Cow<'_, str> {
if id.contains(':') {
id.into()
} else {
format!("minecraft:{id}").into()
}
}
/// A registry id, `#tag`, or list matched against one id.
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
match v {
Value::Null => true,
Value::String(s) => match s.strip_prefix('#') {
Some(tag) => w.in_tag(registry, tag, id),
None => s == id,
Some(tag) => w.in_tag(registry, &full(tag), id),
None => full(s) == id,
},
Value::Array(a) => a.iter().any(|x| id_matches(x, registry, id, w)),
_ => false,
@@ -538,12 +569,14 @@ pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
let test = |pred: &Value, e: &(String, i32, i32)| {
pred.as_object().is_some_and(|m| {
m.get(&e.0).is_some_and(|want| {
range_i(&want["amplifier"], i64::from(e.1))
&& range_i(&want["duration"], i64::from(e.2))
&& want["ambient"].as_bool().is_none_or(|b| !b)
&& want["visible"].as_bool().is_none_or(|b| b)
})
m.iter()
.find(|(k, _)| full(k) == e.0)
.is_some_and(|(_, want)| {
range_i(&want["amplifier"], i64::from(e.1))
&& range_i(&want["duration"], i64::from(e.2))
&& want["ambient"].as_bool().is_none_or(|b| !b)
&& want["visible"].as_bool().is_none_or(|b| b)
})
})
};
c["contains"]
@@ -654,7 +687,7 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
return false;
}
if let Some(d) = v["dimension"].as_str()
&& d != w.dimension()
&& full(d) != w.dimension()
{
return false;
}
@@ -699,13 +732,6 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
/// `DataComponentExactPredicate.test` on an entity: every expected
/// component equals what the entity reports (absent ones never match).
fn components(expected: &Value, have: &[(String, String)]) -> bool {
let full = |id: &str| {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
};
expected.as_object().is_some_and(|o| {
o.iter().all(|(id, want)| {
let id = full(id);
@@ -737,14 +763,15 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
return false;
};
for (key, p) in o {
let ok = match key.as_str() {
"minecraft:entity_type" | "type" => id_matches(p, "entity_type", &e.kind, w),
"minecraft:location" | "location" => location(p, e.position, w),
// `EntityPredicate.CODEC`: the keys are sub-predicate type ids.
let ok = match &*full(key) {
"minecraft:entity_type" => id_matches(p, "entity_type", &e.kind, w),
"minecraft:location" => location(p, e.position, w),
"minecraft:stepping_on" => {
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
}
"minecraft:distance" | "distance" => distance(p, e.position, origin),
"minecraft:flags" | "flags" => {
"minecraft:distance" => distance(p, e.position, origin),
"minecraft:flags" => {
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
f("is_on_fire", e.on_fire)
&& f("is_sneaking", e.sneaking)
@@ -754,7 +781,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
&& f("is_baby", e.baby)
&& f("is_on_ground", e.on_ground)
}
"minecraft:equipment" | "equipment" => {
"minecraft:equipment" => {
let slots = [
"mainhand", "offhand", "head", "chest", "legs", "feet", "body",
];
@@ -765,19 +792,19 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
.is_some_and(|it| item(&p[*s], it, w))
})
}
"minecraft:vehicle" | "vehicle" => e
"minecraft:vehicle" => e
.vehicle
.as_ref()
.is_some_and(|veh| entity(p, veh, w, origin)),
// PassengerPredicate.matches: any passenger matches the nested predicate.
"minecraft:passenger" | "passenger" => e
"minecraft:passenger" => e
.passengers
.iter()
.any(|passenger| entity(p, passenger, w, origin)),
"minecraft:effects" | "effects" => p.as_object().is_some_and(|m| {
"minecraft:effects" => p.as_object().is_some_and(|m| {
m.iter().all(|(id, want)| {
e.effects.iter().any(|(eid, amp, dur)| {
eid == id
*eid == full(id)
&& range_i(&want["amplifier"], i64::from(*amp))
&& range_i(&want["duration"], i64::from(*dur))
})
@@ -899,9 +926,8 @@ pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]
return true;
}
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
let tag = t["id"].as_str().unwrap_or("");
let tag = tag.strip_prefix('#').unwrap_or(tag);
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
// `TagPredicate`: a `HolderSet`, like `id_matches`.
id_matches(&t["id"], "damage_type", &d.kind, w) == t["expected"].as_bool().unwrap_or(true)
});
let direct_ok = ty["direct_entity"].is_null()
|| d.direct
@@ -970,7 +996,7 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
.or_else(|| o.get("type"))
.and_then(Value::as_str)
.unwrap_or("");
match ty {
match &*full(ty) {
"minecraft:entity_properties" => ctx
.this
.is_some_and(|e| entity(&v["predicate"], e, w, ctx.origin)),
@@ -1702,6 +1728,170 @@ mod tests {
};
assert!(!conditions(&stone, &absent, &Flat));
}
/// Data pack ids, tags and registry keys written without a namespace are
/// `minecraft:` ones (`Identifier.parse`), wherever a predicate reads one.
#[test]
fn bare_ids_and_tags_mean_minecraft() {
let w = Flat;
let player = EntityView {
kind: "minecraft:player".into(),
position: [0.5, 64.0, 0.5],
effects: vec![("minecraft:speed".into(), 0, 100)],
..Default::default()
};
let stone = ItemView {
id: "minecraft:cobblestone".into(),
count: 1,
..Default::default()
};
let ctx = Entities {
this: Some(&player),
origin: player.position,
tool: None,
block_pos: None,
};
let arrow = DamageView {
kind: "minecraft:arrow".into(),
..Default::default()
};
let o = [0.0; 3];
let j = |s: &str| serde_json::from_str::<Value>(s).unwrap();
let cases = [
(
"item id",
id_matches(&j(r#""cobblestone""#), "item", &stone.id, &w),
),
(
"item tag",
id_matches(&j(r##""#stone_tool_materials""##), "item", &stone.id, &w),
),
(
"item list",
id_matches(&j(r#"["dirt", "cobblestone"]"#), "item", &stone.id, &w),
),
(
"item predicate",
item(&j(r#"{"items": "cobblestone"}"#), &stone, &w),
),
(
"dimension",
location(&j(r#"{"dimension": "overworld"}"#), player.position, &w),
),
(
"biome",
location(&j(r#"{"biomes": "plains"}"#), player.position, &w),
),
(
"entity type",
entity(&j(r#"{"minecraft:entity_type": "player"}"#), &player, &w, o),
),
(
"sub-predicate key",
entity(&j(r#"{"entity_type": "minecraft:player"}"#), &player, &w, o),
),
(
"effect key",
entity(
&j(r#"{"minecraft:effects": {"speed": {}}}"#),
&player,
&w,
o,
),
),
(
"damage tag",
damage_source(
&j(r##"{"tags": [{"id": "#is_projectile", "expected": true}]}"##),
&arrow,
&w,
o,
),
),
(
"condition type",
conditions(
&j(r#"{"type": "entity_properties", "entity": "this",
"predicate": {"minecraft:entity_type": "minecraft:player"}}"#),
&ctx,
&w,
),
),
// `TagPredicate.id` is a holder set: one damage type is that type.
(
"damage type",
damage_source(&j(r#"{"tags": [{"id": "arrow"}]}"#), &arrow, &w, o),
),
// Near misses stay misses.
(
"other item",
!id_matches(&j(r#"["dirt", "stone"]"#), "item", &stone.id, &w),
),
(
"other dimension",
!location(&j(r#"{"dimension": "the_nether"}"#), player.position, &w),
),
(
"other effect",
!entity(&j(r#"{"effects": {"slowness": {}}}"#), &player, &w, o),
),
(
"other damage type",
!damage_source(&j(r#"{"tags": [{"id": "fireball"}]}"#), &arrow, &w, o),
),
(
"the type key is no sub-predicate",
!entity(&j(r#"{"type": "player"}"#), &player, &w, o),
),
];
let failed: Vec<&str> = cases.iter().filter(|c| !c.1).map(|c| c.0).collect();
assert!(
failed.is_empty(),
"bare ids not read as minecraft: {failed:?}"
);
}
/// A bare trigger, item or recipe id reaches the indexes as `minecraft:`.
#[test]
fn bare_ids_reach_the_trigger_and_item_indexes() {
let a = parse(
"test:a",
&serde_json::json!({"criteria": {
"items": {"trigger": "inventory_changed",
"conditions": {"items": [{"items": ["cobblestone", "#planks"]}]}},
"recipe": {"trigger": "recipe_unlocked", "conditions": {"recipes": "stick"}}
}}),
)
.unwrap();
let mut tree = Tree::default();
for (ci, c) in a.criteria.iter().enumerate() {
tree.by_trigger
.entry(c.trigger.clone())
.or_default()
.push((0, ci));
}
tree.list.push(a);
tree.index_recipes();
let index = InventoryIndex::build(&tree, |tag| match tag {
"minecraft:planks" => vec!["minecraft:oak_planks"],
_ => Vec::new(),
});
let mut failed = Vec::new();
if !tree.by_trigger.contains_key("minecraft:inventory_changed") {
failed.push("trigger");
}
if index.candidates(["minecraft:cobblestone"]).is_empty() {
failed.push("item index id");
}
if index.candidates(["minecraft:oak_planks"]).is_empty() {
failed.push("item index tag");
}
if tree.recipe_criteria("minecraft:stick").is_empty() {
failed.push("recipe index");
}
assert!(
failed.is_empty(),
"bare ids not read as minecraft: {failed:?}"
);
}
#[test]
fn predicates_match_tags_ranges_and_locations() {
let w = Flat;
+1 -1
View File
@@ -41,7 +41,7 @@ pub fn native_ingredient(
/// stack being matched, `accept` already true (the load-time list named
/// the item). Set `accept` to false to refuse the stack — its components
/// are the usual reason. The hook may be called many times a craft; the
/// answer must be the same for the same stack. API @API:ingredient_test@.
/// answer must be the same for the same stack. API 377.
#[inline(never)]
pub fn native_ingredient_test(
kind: &str,
+92
View File
@@ -941,6 +941,13 @@ pub fn merge_with_neighbours(
}
}
}
/// `ExperienceOrbRenderer.submit`'s billboard before its 0.3 scale, in the
/// order of its four `vertex` calls (bottom left, bottom right, top right, top
/// left). The quad is a whole block tall about the orb's centre and hangs a
/// quarter of it below.
pub const ORB_QUAD: [[f32; 2]; 4] = [[-0.5, -0.25], [0.5, -0.25], [0.5, 0.75], [-0.5, 0.75]];
/// `poseStack.scale(0.3F, 0.3F, 0.3F)`.
pub const ORB_QUAD_SCALE: f32 = 0.3;
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
#[derive(Clone, Debug)]
pub struct ExperienceOrb {
@@ -1048,6 +1055,20 @@ impl ExperienceOrb {
_ => 0,
}
}
/// `Entity.getLightProbePosition`: the block an entity is lit from is the
/// one its eye is in. An orb's box is half a block, so its eye is
/// `0.5 * 0.85` above its feet (`EntityDimensions.defaultEyeHeight`).
pub fn light_probe_y(&self) -> f64 {
self.position.y + 0.5 * 0.85
}
/// `ExperienceOrbRenderer.getBlockLightLevel` over `EntityRenderer`'s
/// packing: the level's block light raised by 7 and clamped, the sky
/// light unchanged. `light` is the level's packed light.
pub fn render_light(&self, light: i32) -> u32 {
let block = ((light & 15) + 7).clamp(0, 15);
let sky = light >> 4;
(u32::try_from(sky).unwrap_or(0) * 16) | ((u32::try_from(block).unwrap_or(15) * 16) << 8)
}
pub fn tick(
&mut self,
world: &World,
@@ -3617,4 +3638,75 @@ mod item_destroyed_tests {
);
}
}
#[cfg(test)]
mod render_light_tests {
use super::*;
fn orb(y: f64) -> ExperienceOrb {
ExperienceOrb {
join: Default::default(),
no_gravity: false,
id: 1,
position: Vec3::new(0.5, y, 0.5),
previous: Vec3::new(0.5, y, 0.5),
velocity: Vec3::new(0.0, 0.0, 0.0),
value: 3,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
}
}
/// `ExperienceOrbRenderer.getBlockLightLevel`: the level's block light
/// plus 7, clamped to 15; the sky light is the level's own. The orb used
/// to be drawn at 10 in both nibbles, so an orb in a dark cave was washed
/// out and one in daylight at block light 5 was too dim.
#[test]
fn an_orb_is_seven_brighter_than_the_block_it_sits_in() {
let light = |sky: i32, block: i32| (sky << 4) | block;
// Sky 15, block 0 (daylight): sky stays 15, block goes to 7.
assert_eq!(
orb(64.0).render_light(light(15, 0)),
(15 * 16) | ((7 * 16) << 8)
);
// A dark cave: sky 0, block 0 -> block 7, and no invented sky light.
assert_eq!(orb(20.0).render_light(light(0, 0)), (7 * 16) << 8);
// Torchlight 9 -> 15, the clamp, not 16.
assert_eq!(orb(20.0).render_light(light(0, 9)), (15 * 16) << 8);
assert_eq!(
orb(20.0).render_light(light(15, 15)),
(15 * 16) | ((15 * 16) << 8)
);
}
/// `Entity.getLightProbePosition` is the eye position: an orb's box is
/// half a block, so it reads the light of the cell 0.425 above its feet.
#[test]
fn an_orb_is_lit_from_the_block_its_eye_is_in() {
assert_eq!(orb(64.6).light_probe_y().floor(), 65.0);
assert_eq!(orb(64.6).position.y.floor(), 64.0);
assert!((orb(64.0).light_probe_y() - 64.425).abs() < 1e-9);
}
/// `ExperienceOrbRenderer.submit`'s quad: a block tall about the orb's
/// centre, scaled by 0.3, so it spans -0.075 to +0.225 above the 0.1 the
/// pose is translated by. Horizon drew a symmetric 0.3 square, a quarter
/// of the sprite too low.
#[test]
fn the_orb_sprite_hangs_a_quarter_below_its_centre() {
let off = ORB_QUAD.map(|[x, y]| [x * ORB_QUAD_SCALE, 0.1 + y * ORB_QUAD_SCALE]);
let want = [
[-0.15, 0.1 - 0.075],
[0.15, 0.1 - 0.075],
[0.15, 0.1 + 0.225],
[-0.15, 0.1 + 0.225],
];
for (got, want) in off.into_iter().zip(want) {
assert!((got[0] - want[0]).abs() < 1e-6, "{got:?} vs {want:?}");
assert!((got[1] - want[1]).abs() < 1e-6, "{got:?} vs {want:?}");
}
}
}
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
+36 -14
View File
@@ -68,8 +68,9 @@ impl FeatureWorld for ChunkWorld<'_> {
impl FlatTerrain {
/// The overworld generator of `world_gen_settings` (`data.dimensions`), if it
/// is flat. Its layers, as FlatLevelSource places them, and of the features
/// its settings decorate the world with, `void_start_platform`; the other
/// features and the lakes are not placed yet.
/// its settings decorate the world with, `void_start_platform`. A world that
/// places other features or lakes runs on the pipeline instead
/// ([`Self::pipeline_settings`]).
pub fn from_settings(
settings: &Tag,
registry: &BlockRegistry,
@@ -150,12 +151,13 @@ impl FlatTerrain {
void_platform,
})
}
/// The settings of a superflat overworld that lets structure sets generate
/// (`FlatLevelSource.createState`): its layers, biome and `structure_overrides`
/// (no key means every set; `[]` none). `None` for anything else, a world with no
/// structure set to generate, the Void preset's biome (its platform is a feature
/// this generator places) or one that is not superflat.
pub fn structure_settings(
/// The settings of a superflat overworld that runs on the structure and feature
/// pipeline (`FlatLevelSource`): one that lets structure sets generate
/// (`createState`: no `structure_overrides` key means every set, `[]` none) or
/// that decorates (`features` or `lakes`, `adjustGenerationSettings`). `None` for a
/// world with neither, the Void preset's biome (its platform is a feature this
/// generator places) or one that is not superflat.
pub fn pipeline_settings(
settings: &Tag,
) -> Option<horizon_world::worldgen::generator::FlatSettings> {
let generator = settings
@@ -187,13 +189,17 @@ impl FlatTerrain {
Some(Tag::String(set)) => Some(vec![set.to_string_lossy()]),
_ => None,
};
if structure_overrides.as_ref().is_some_and(Vec::is_empty) {
let flag = |key| crate::save::number(s.get(key)).is_some_and(|v| v != 0);
let decorates = flag("features") || flag("lakes");
if structure_overrides.as_ref().is_some_and(Vec::is_empty) && !decorates {
return None;
}
Some(horizon_world::worldgen::generator::FlatSettings {
layers,
biome,
structure_overrides,
features: flag("features"),
lakes: flag("lakes"),
})
}
/// FlatLevelSource.getSpawnHeight: the top of the layers.
@@ -556,10 +562,10 @@ mod tests {
]))
};
// No key: every set. A list: those. An empty list: none. The void and a noise world: none.
let all = FlatTerrain::structure_settings(&flat(vec![], "minecraft:plains")).unwrap();
let all = FlatTerrain::pipeline_settings(&flat(vec![], "minecraft:plains")).unwrap();
assert_eq!(all.structure_overrides, None);
assert_eq!(all.layers, [("minecraft:stone".to_owned(), 3)]);
let some = FlatTerrain::structure_settings(&flat(
let some = FlatTerrain::pipeline_settings(&flat(
vec![(
"structure_overrides",
Tag::List(vec![Tag::String("minecraft:villages".into())]),
@@ -573,15 +579,31 @@ mod tests {
);
assert_eq!(some.biome, "minecraft:desert");
assert!(
FlatTerrain::structure_settings(&flat(
FlatTerrain::pipeline_settings(&flat(
vec![("structure_overrides", Tag::List(vec![]))],
"minecraft:plains"
))
.is_none()
);
assert!(FlatTerrain::structure_settings(&flat(vec![], "minecraft:the_void")).is_none());
// No structure set, but features or lakes to place: the pipeline, with no set.
for key in ["features", "lakes"] {
let decorated = FlatTerrain::pipeline_settings(&flat(
vec![
("structure_overrides", Tag::List(vec![])),
(key, Tag::Byte(1)),
],
"minecraft:plains",
))
.unwrap();
assert_eq!(decorated.structure_overrides, Some(vec![]), "{key}");
assert_eq!(
(decorated.features, decorated.lakes),
(key == "features", key == "lakes")
);
}
assert!(FlatTerrain::pipeline_settings(&flat(vec![], "minecraft:the_void")).is_none());
assert!(
FlatTerrain::structure_settings(&world(Tag::compound([(
FlatTerrain::pipeline_settings(&world(Tag::compound([(
"type",
Tag::String("minecraft:noise".into())
)])))
+143 -40
View File
@@ -233,6 +233,7 @@ mod native_panel_recipes;
mod native_projectiles;
mod native_quick_move;
mod native_recipes;
mod native_reload_listeners;
mod native_server;
mod native_shaders;
mod native_ticks;
@@ -421,6 +422,7 @@ pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
@@ -2797,6 +2799,9 @@ struct Game<'a> {
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
locator_camera: Option<([f64; 3], f32, f32, f32)>,
native_camera_frame: Option<horizon_client::mod_api::NativeCamera>,
/// The yaw and pitch a native mod's camera drew the last frame from (`native_camera_pose`),
/// none when it drew from vanilla's.
posed_rotation: Option<(f32, f32)>,
/// `LocalPlayer.postEffects`: the chains the server has the screen run
/// (`ClientboundPostEffectsPacket`), in order.
post_effects: Vec<String>,
@@ -5774,21 +5779,25 @@ impl Game<'_> {
// ShulkerBoxBlock.canOpen: the lid has no room; nothing opens.
} else if let Some(entity) = BlockEntity::for_block(&name) {
self.world.block_entities.get_or_insert_with(key, || entity);
self.unpack_opened_loot(key, &name);
horizon_client::spans::time("open_loot", || self.unpack_opened_loot(key, &name));
let centre = [
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
];
self.resolve_container_maps(key, centre);
horizon_client::spans::time("open_maps", || {
self.resolve_container_maps(key, centre);
});
self.container = Some(key);
let first = self.container_opener_event(key, true);
self.post_block_game_event(
"minecraft:container_open",
[key.0, key.1, key.2],
Some(horizon_client::mobs::Who::Player),
None,
);
if first {
self.post_block_game_event(
"minecraft:container_open",
[key.0, key.1, key.2],
Some(horizon_client::mobs::Who::Player),
None,
);
}
let stat = match &*name {
"minecraft:chest" => "open_chest",
"minecraft:trapped_chest" => "trigger_trapped_chest",
@@ -6428,9 +6437,13 @@ impl Game<'_> {
let mut anchor_passed = false;
// Native mods, where ServerPlayerGameMode.useItemOn starts: a
// canceled click does nothing (a guest's block is sent back).
// A slow tick's line names the phase of a use that took it (#8669): the mods' hook,
// the block's own use, then the item's.
if let Some(hit) = block_hit {
let mut canceled = false;
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
horizon_client::spans::time("use_mods", || {
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
});
if canceled {
// Nothing was used, so nothing consumed the action.
self.use_failed = true;
@@ -6438,6 +6451,7 @@ impl Game<'_> {
}
}
// MultiPlayerGameMode.useItemOn: blocks react first.
let block_phase = horizon_client::spans::guard("use_block");
if !(sneaking && hands_full)
&& let Some(hit) = block_hit
{
@@ -6620,6 +6634,8 @@ impl Game<'_> {
}
}
}
drop(block_phase);
let _item_phase = horizon_client::spans::guard("use_item");
let Some(stack) = self.held().cloned() else {
return Ok(false);
};
@@ -9463,13 +9479,13 @@ impl Game<'_> {
// A cart's or chest boat's items go back to it.
self.close_cart_container(key);
let last = self.container_opener_event(key, false);
self.post_block_game_event(
"minecraft:container_close",
[key.0, key.1, key.2],
Some(horizon_client::mobs::Who::Player),
None,
);
if last {
self.post_block_game_event(
"minecraft:container_close",
[key.0, key.1, key.2],
Some(horizon_client::mobs::Who::Player),
None,
);
self.container_edge_sound(key, false);
}
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
@@ -10855,7 +10871,11 @@ impl Game<'_> {
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
camera_rotation(self.camera_type, self.yaw, self.pitch)
match self.posed_rotation {
// The frame's camera, already turned round in front view.
Some((yaw, pitch)) => camera_rotation(perspective::CameraType::FirstPerson, yaw, pitch),
None => camera_rotation(self.camera_type, self.yaw, self.pitch),
}
}
/// The entity whose geometry starts at vertex `start` (0: none), for frame
/// generation to follow its own motion. Each run lasts until the next mark.
@@ -11304,20 +11324,26 @@ impl Game<'_> {
let red = ((t.sin() + 1.0) * 0.5 * 255.0) as u32;
let blue = (((t + 4.188_79).sin() + 1.0) * 0.1 * 255.0) as u32;
let color = 0x8000_0000 | blue << 16 | 255 << 8 | red;
// `ExperienceOrbRenderer.getBlockLightLevel` (the block light
// plus 7, clamped) at the block the orb's eye is in.
let light = self.world.light(
o.position.x.floor() as i32,
o.position.y.floor() as i32,
o.light_probe_y().floor() as i32,
o.position.z.floor() as i32,
);
let packed = (u32::from(light >> 4).max(10) * 16)
| ((u32::from(light & 15).max(10) * 16) << 8);
let packed = o.render_light(i32::from(light));
let c = p.as_vec3();
let s = 0.15;
for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.iter()
.zip([[u0, v0 + dv], [u0 + du, v0 + dv], [u0 + du, v0], [u0, v0]])
{
let v = c + right * (k.0 * s) + up * (k.1 * s);
// `ExperienceOrbRenderer.submit`: the quad runs from
// (-0.5, -0.25) to (0.5, 0.75) and is scaled by 0.3, so the
// sprite hangs a quarter of its height below the orb's centre.
let s = horizon_client::entities::ORB_QUAD_SCALE;
for (k, uv) in horizon_client::entities::ORB_QUAD.iter().zip([
[u0, v0 + dv],
[u0 + du, v0 + dv],
[u0 + du, v0],
[u0, v0],
]) {
let v = c + right * (k[0] * s) + up * (k[1] * s);
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
@@ -12291,8 +12317,9 @@ impl Game<'_> {
&self.options.resources,
&pack_files(&self.world_tables),
);
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
// its chunks and players are.
// The mods' own data folders, then TagsUpdatedEvent (SERVER_DATA_LOAD):
// the world's data is in, before its chunks and players are.
self.native_reload_listeners();
self.native_server_data_loaded(false);
self.sync_recipe_content();
self.bosses = Default::default();
@@ -12868,11 +12895,14 @@ impl Game<'_> {
});
}
} else if !first && !self.res.facts().is_air(state) && f.fluid == Fluid::None {
return Some(Hit {
pos: b,
face,
point: p,
});
// `ClipContext.Block.OUTLINE`: the ray stops at the block's
// outline, not its cell; it passes through the empty part
// of a torch, a fence or a flower to the water behind.
if let Some(hit) =
outline_stop(origin, dir, self.reach(), b, face, || self.outline_boxes(b))
{
return Some(hit);
}
}
last = b;
first = false;
@@ -13991,9 +14021,12 @@ impl Game<'_> {
horizon_client::mobs::render_model(arm, m, &pose, [r.u0, r.v0, r.u1, r.v1], light, 0, out);
}
/// FirstPersonHandsAndItemsRenderer: the right arm or the held items of
/// both hands in view space, converted to camera-relative world space.
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
/// both hands in view space, converted to camera-relative world space,
/// and where the arm and each item start, with the `currentRenderedItemId`
/// a shader pack reads for them.
fn hand_geometry(&mut self, alpha: f64) -> (Vec<Vertex>, Vec<(u32, i32)>) {
let mut out = Vec::new();
let mut runs = Vec::new();
// GameRenderer.renderItemInHand: F1 hides the hand with the HUD.
if self.spectator()
|| self.ui.hide_gui
@@ -14005,7 +14038,7 @@ impl Game<'_> {
.is_none_or(|p| p.health.dead_or_dying())
|| self.screen == Screen::Dead
{
return out;
return (out, runs);
}
let a = alpha as f32;
// FirstPersonHandsAndItemsRenderer: the swing goes to the hand that
@@ -14097,20 +14130,24 @@ impl Game<'_> {
// renderPlayerArm
let m = to_world
* horizon_client::first_person::player_arm_pose(left, inverse_height, attack);
runs.push((out.len() as u32, 0));
self.first_person_arm(left, m, packed, &mut out);
continue;
};
let item_id = self.rendered_item_id(&stack.item);
if let Some(id) = maps::map_id(&stack) {
use horizon_client::first_person::{MapHold, map_arm_poses, one_handed_map_pose};
let hold = MapHold::of(hand, off_hand_empty);
// renderOneHandedMap and renderTwoHandedMap: the arms under
// the map, unless the player is invisible.
if !self.own_invisible() {
runs.push((out.len() as u32, 0));
for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack)
{
self.first_person_arm(arm, to_world * pose, packed, &mut out);
}
}
runs.push((out.len() as u32, item_id));
match hold {
MapHold::TwoHanded => self.two_handed_map_geometry(
to_world,
@@ -14221,6 +14258,7 @@ impl Game<'_> {
horizon_client::cit::Hand::Main
};
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand);
runs.push((out.len() as u32, item_id));
let light = self.world.light(
eye[0].floor() as i32,
eye[1].floor() as i32,
@@ -14239,7 +14277,7 @@ impl Game<'_> {
for vertex in &mut out {
vertex.layer &= !horizon_client::gfx::OIT_ENTITY;
}
out
(out, runs)
}
/// The renderer for the window, with the current block sprites, GUI
/// atlas and world border texture.
@@ -14288,6 +14326,10 @@ impl Game<'_> {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)?;
renderer.screenshot = screenshot;
renderer.screenshot_result = screenshot_result;
@@ -14325,6 +14367,10 @@ impl Game<'_> {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)
}
fn apply_settings(&mut self) {
@@ -15650,7 +15696,18 @@ impl Game<'_> {
// Camera.getCameraEntityPartialTicks: the view, the hand and the
// player's own model stay smooth while the level is frozen.
let camera_alpha = self.clock.camera_fraction();
let view = self.camera_view(camera_alpha as f32);
let vanilla = self.camera_view(camera_alpha as f32);
let view = vanilla.map(|view| {
if self.shows_panorama() {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
}
});
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
self.posed_rotation = view
.filter(|posed| Some(*posed) != vanilla)
.map(|(_, yaw, pitch)| (yaw, pitch));
let eye = view.map(|_| {
(
glam::DVec3::from_array(self.view.eye),
@@ -15963,8 +16020,8 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
let mut hand = if title || self.scoping() {
Vec::new()
let (mut hand, hand_items) = if title || self.scoping() {
Default::default()
} else {
self.hand_geometry(camera_alpha)
};
@@ -16121,6 +16178,7 @@ impl Game<'_> {
},
stars: stars.filter(|_| self.settings.horizon.sky || title),
hand: &hand,
hand_items: &hand_items,
hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()),
screen: &screen,
pack: pack_inputs,
@@ -19090,6 +19148,7 @@ impl<'a> Game<'a> {
waypoints: Default::default(),
locator_camera: None,
native_camera_frame: None,
posed_rotation: None,
post_effects: Vec::new(),
spectated_post_effect: None,
experience_display: Default::default(),
@@ -19724,6 +19783,7 @@ impl Game<'_> {
&overlay,
));
self.adv.reset_tree();
self.native_reload_listeners();
self.native_server_data_loaded(true);
}
@@ -20082,7 +20142,7 @@ fn load_resources(
// server thread's (horizon_threads::jobs), sized before any world opens.
horizon_threads::jobs::configure(threads);
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_material_pack(settings));
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
// published at once so the title screen can appear while the world bundle
// loads; the synchronous launch instead carries it in `Loaded`.
@@ -20538,6 +20598,7 @@ fn build_renderer(
// offered (`Renderer::new`).
wide_frame: bool,
shader_pack: bool,
filtering: (i32, i32),
) -> Result<Renderer> {
let assets = &pack.render;
let atlas = &gui.atlas;
@@ -20585,6 +20646,8 @@ fn build_renderer(
renderer
.hud
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
pack.item_renderer
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
@@ -20731,6 +20794,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -21227,6 +21291,10 @@ impl Loading {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)
})?;
log::info!(
@@ -21689,6 +21757,23 @@ fn container_key_clones(button: MouseButton, action: KeyAction) -> bool {
button == MouseButton::Middle && action != KeyAction::Release
}
/// Where a ray entering `pos` first meets the block's outline boxes, if it
/// does: a hollow block (torch, fence, flower) lets the ray through.
fn outline_stop(
origin: [f64; 3],
dir: [f64; 3],
reach: f64,
pos: [i32; 3],
face: [i32; 3],
boxes: impl FnOnce() -> Vec<[f64; 6]>,
) -> Option<Hit> {
let (t, face) = interaction_shapes::entry(origin, dir, &boxes(), reach, face)?;
Some(Hit {
pos,
face,
point: std::array::from_fn(|i| origin[i] + dir[i] * t),
})
}
/// `VoxelShape.clip` of a fluid source's shape for a ray that starts in the
/// source's own block `cell`: the face and point it hits, in `pick_fluid`'s
/// `Hit` terms. `FlowingFluid.getShape` is the whole block under the same
@@ -21926,6 +22011,24 @@ mod tests {
use super::*;
use horizon_client::container_screen::{Button, Clicks, Release, Target};
/// `ClipContext.Block.OUTLINE` for the SOURCE_ONLY ray (#8180): a ray
/// through the empty part of a torch's cell is not stopped by it.
#[test]
fn the_fluid_ray_passes_through_the_hollow_of_a_torch() {
// A torch: a 2x10x2 pillar in the middle of its cell, at (0, 0, 0).
let torch = || vec![[0.4375, 0.0, 0.4375, 0.5625, 0.625, 0.5625]];
let dir = [0.0, 0.0, 1.0];
// Beside the pillar: no hit, the ray goes on to the water behind.
assert!(outline_stop([0.1, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
// Above the pillar (a torch is 10/16 tall): no hit either.
assert!(outline_stop([0.5, 0.8, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
// Through the pillar: stopped on its front face, not at the cell's edge.
let hit = outline_stop([0.5, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch)
.expect("the pillar stops the ray");
assert_eq!(hit.face, [0, 0, -1]);
assert!((hit.point[2] - 0.4375).abs() < 1e-9);
}
#[test]
fn a_death_drops_the_off_hand_before_the_armour() {
let stack = |item: &str| Some(ItemStack::new(item, 1));
+103 -19
View File
@@ -440,6 +440,16 @@ impl Game<'_> {
)
}
pub(super) fn player_entity(&self) -> EntityView {
self.player_entity_looking(true)
}
/// [`Self::player_entity`] for `trigger`'s criteria: what the player
/// looks at only when one of them may ask (a ray against every entity
/// each tick otherwise).
fn player_entity_for(&self, trigger: &str) -> EntityView {
self.player_entity_looking(self.adv.tree.may_look(trigger))
}
/// The player as predicates see it, with what it looks at when `looking`.
fn player_entity_looking(&self, looking: bool) -> EntityView {
let Some(p) = self.player.as_ref() else {
return EntityView::default();
};
@@ -486,6 +496,11 @@ impl Game<'_> {
})
.collect(),
gamemode: Some(self.mode.name().to_owned()),
// Vanilla looks for it inside PlayerPredicate.matches whatever the
// trigger; skipped for a tick or location whose criteria can't ask.
looking_at: looking
.then(|| self.looking_at_entity().map(Box::new))
.flatten(),
..Default::default()
}
}
@@ -695,7 +710,9 @@ impl Game<'_> {
for r in &allowed {
let list = tree.recipe_criteria(r);
self.trigger_criteria(&tree, &list, |c, _| {
c["recipes"].as_str() == Some(r.as_str())
c["recipes"]
.as_str()
.is_some_and(|id| adv::full(id) == r.as_str())
});
}
}
@@ -900,7 +917,7 @@ impl Game<'_> {
}
self.check_inventory_trigger();
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
let me = self.player_entity();
let me = self.player_entity_for("minecraft:tick");
self.trigger("minecraft:tick", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -934,7 +951,7 @@ impl Game<'_> {
}
self.tick_marks();
if self.ticks.is_multiple_of(20) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:location");
self.trigger("minecraft:location", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1215,14 +1232,12 @@ impl Game<'_> {
&& adv::range_i(&c["delta"], damage - new_damage)
});
}
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
/// what the player looks at (`PlayerPredicate.lookingAt`).
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use.
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
if self.remote.is_some() || self.no_player_here() {
return;
}
let mut me = self.player_entity();
me.looking_at = self.looking_at_entity().map(Box::new);
let me = self.player_entity();
let it = item_view(stack);
self.trigger("minecraft:using_item", move |c, w| {
let pctx = adv::Entities {
@@ -1234,24 +1249,60 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
});
}
/// The mob a 100 block ray from the eyes meets first, if no block is in
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
/// The entity a 100 block ray from the eyes meets first, if no block is
/// in the way (`ProjectileUtil.getEntityHitResult` over every entity but
/// the player that is not a spectator, then `hasLineOfSight`).
fn looking_at_entity(&self) -> Option<EntityView> {
use super::commands::Target;
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
use horizon_core::geometry::Aabb;
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let (m, _) = self
.mobs
.iter()
.filter(|m| m.alive())
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
.min_by(|a, b| a.1.total_cmp(&b.1))?;
let mut best: Option<(Target, f64)> = None;
let mut offer = |t: Option<Target>, b: Aabb| {
if let Some(t) = t
&& let Some(d) = super::ray_box(eye, dir, 100.0, b)
&& best.is_none_or(|(_, bd)| d < bd)
{
best = Some((t, d));
}
};
for_each_entity!(entity_lists_of!(self), e => {
if !e.is_removed() {
offer(e.selector_target(), e.bounds());
}
});
let aabb = |(lo, hi): ([f64; 3], [f64; 3])| {
Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
)
};
for f in self.item_frames.iter() {
offer(Some(Target::Frame(f.id)), aabb(f.bounds()));
}
for p in self.paintings.iter() {
offer(Some(Target::Painting(p.id)), aabb(p.bounds()));
}
for c in self.crystals.iter() {
offer(Some(Target::Crystal(c.id)), c.bounds());
}
let (t, _) = best?;
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
let at = self.target_position(t)?;
let from = Vec3::new(eye[0], eye[1], eye[2]);
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
let to = Vec3::new(at[0], at[1] + self.target_eye_height(t), at[2]);
let world = self.mob_world_base(&[]);
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
clear.then(|| self.mob_entity(m))
if !clear {
return None;
}
Some(match t {
Target::Mob(id) => self.mob_entity(self.mobs.iter().find(|m| m.id == id)?),
Target::Boat(id) => self.boat_entity(self.boats.iter().find(|b| b.id == id)?),
other => self.entity_view_of(other),
})
}
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
pub(super) fn trigger_player_hurt_entity(
@@ -1778,8 +1829,8 @@ impl Game<'_> {
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim, at: [f64; 3]) {
let (from_id, to_id) = (from.id().to_owned(), to.id().to_owned());
self.trigger("minecraft:changed_dimension", move |c, _| {
c["from"].as_str().is_none_or(|f| f == from_id)
&& c["to"].as_str().is_none_or(|t| t == to_id)
c["from"].as_str().is_none_or(|f| adv::full(f) == from_id)
&& c["to"].as_str().is_none_or(|t| adv::full(t) == to_id)
});
if from == Dim::Overworld && to == Dim::Nether {
self.adv.marks.nether = Some(at);
@@ -2141,6 +2192,39 @@ pub(super) fn text_of(v: &Value) -> Text {
mod tests {
use super::*;
#[test]
fn looking_at_triggers_are_the_ones_that_ask() {
let criterion = |trigger: &str, conditions: serde_json::Value| adv::Criterion {
name: "c".into(),
trigger: trigger.into(),
conditions: horizon_world::lazy_json::LazyJson::new(&conditions),
};
let looking = serde_json::json!({"player": {"type": "minecraft:entity_properties",
"entity": "this", "predicate": {"minecraft:type_specific/player":
{"looking_at": {"minecraft:entity_type": "minecraft:oak_boat"}}}}});
let reference = serde_json::json!({"player": [{"condition": "minecraft:reference",
"name": "pack:looks"}]});
let plain = serde_json::json!({"player": {"type": "minecraft:entity_properties",
"entity": "this", "predicate": {"minecraft:flags": {"is_sneaking": true}}}});
let mut tree = Tree::default();
tree.list.push(adv::Advancement {
id: "pack:a".into(),
parent: None,
display: None,
criteria: vec![
criterion("minecraft:using_item", looking),
criterion("minecraft:location", reference),
criterion("minecraft:tick", plain),
],
requirements: Vec::new(),
rewards: Default::default(),
});
tree.index_looking_at();
assert!(tree.may_look("minecraft:using_item"));
assert!(tree.may_look("minecraft:location"));
assert!(!tree.may_look("minecraft:tick"));
}
#[test]
fn toast_labels_are_yellow_or_pink() {
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
@@ -18,6 +18,12 @@ pub(super) const BOARDING_COOLDOWN: i32 = 60;
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
/// `Entity.canRide` for a mob species: `Warden` and `WitherBoss` override it
/// to return false, so no vehicle ever takes them aboard.
pub(super) fn species_can_ride(species: Species) -> bool {
!matches!(species, Species::Warden | Species::Wither)
}
/// `Entity.isVehicle`: a player or a mob rides it.
fn carries(m: &Mob) -> bool {
m.rider.is_some() || m.extra.rider.is_some()
@@ -251,7 +257,7 @@ impl Game<'_> {
// boarding cooldown runs (Entity.canRide) or the boat
// is under water, or when a mod cancels it
// (EntityMountEvent).
let ready = m.extra.boarding_cooldown <= 0;
let ready = m.extra.boarding_cooldown <= 0 && species_can_ride(m.species());
if room
&& ready
&& self.native_mob_may_ride(mob, id, true)
@@ -447,6 +453,13 @@ mod tests {
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
}
#[test]
fn warden_and_wither_never_board() {
assert!(!species_can_ride(Species::Warden));
assert!(!species_can_ride(Species::Wither));
assert!(species_can_ride(Species::Cow));
}
#[test]
fn boats_push_by_height() {
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
+176 -6
View File
@@ -82,6 +82,9 @@ pub(super) struct Boat {
/// left, right, up, down
pub input: [bool; 4],
pub on_ground: bool,
/// `Entity.stuckSpeedMultiplier`: set where the boat sits in powder snow
/// (`PowderSnowBlock.entityInside`), spent by the next move.
stuck: Vec3,
/// `ChestBoat`: a chest with 27 slots in the back seat.
pub chest: bool,
pub items: Vec<Option<ItemStack>>,
@@ -105,6 +108,39 @@ const BUBBLE_TIME: i32 = 60;
/// for a down and an up column.
pub(super) const BUBBLE_DOWN_EVENT: i8 = 71;
pub(super) const BUBBLE_UP_EVENT: i8 = 72;
/// What `PowderSnowBlock.entityInside` passes to `makeStuckInBlock`.
fn powder_snow_stuck() -> Vec3 {
Vec3::new(f64::from(0.9_f32), 1.5, f64::from(0.9_f32))
}
/// The powder snow cells a box reaches into (deflated by 1e-5, as `Entity.checkInsideBlocks`
/// does), in x, y, z order.
fn powder_snow_cells(bounds: Aabb, is_snow: impl Fn([i32; 3]) -> bool) -> Vec<[i32; 3]> {
let e = f64::from(1.0e-5_f32);
let bounds = bounds.deflate(Vec3::new(e, e, e));
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let mut cells = Vec::new();
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
if is_snow([x, y, z]) {
cells.push([x, y, z]);
}
}
}
}
cells
}
/// The snowflake `PowderSnowBlock.entityInside` sends for an entity whose known movement is
/// `known`: none unless it moved horizontally and a coin falls so, then `Mth.randomBetween(-1, 1)`
/// over 12 sideways and 0.05 up.
fn snowflake_velocity(known: Vec3, random: &mut impl RandomSource) -> Option<[f64; 3]> {
if (known.x == 0.0 && known.z == 0.0) || !random.next_boolean() {
return None;
}
let xa = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
let za = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
Some([f64::from(xa), f64::from(0.05_f32), f64::from(za)])
}
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
@@ -152,6 +188,7 @@ impl Boat {
paddling: [false; 2],
input: [false; 4],
on_ground: false,
stuck: Vec3::ZERO,
chest: false,
items: Vec::new(),
leash: None,
@@ -168,12 +205,27 @@ impl Boat {
self
}
pub fn bounds(&self) -> Aabb {
let (w, p) = (WIDTH / 2.0, self.position);
Self::bounds_at(self.position)
}
/// The box of a boat whose position is `p`.
fn bounds_at(p: Vec3) -> Aabb {
let w = WIDTH / 2.0;
Aabb::new(
Vec3::new(p.x - w, p.y, p.z - w),
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
)
}
/// The start of `Entity.move`: a boat made stuck since its last move goes its
/// velocity times the multiplier, and its velocity and the multiplier are spent.
fn take_movement(&mut self) -> Vec3 {
if self.stuck.length_squared() <= 1.0e-7 {
return self.velocity;
}
let movement = self.velocity.multiply(self.stuck);
self.stuck = Vec3::ZERO;
self.velocity = Vec3::ZERO;
movement
}
/// `isUnderWater`; it stands in for `canAddPassenger`'s
/// `isEyeInFluid(WATER)` too.
pub fn under_water(&self) -> bool {
@@ -687,23 +739,29 @@ impl Game<'_> {
entities: &obstacles,
border: Some((self.border.geometry(), [b.position.x, b.position.z])),
};
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
let movement = b.take_movement();
if let Ok(moved) = scene.resolve(b.bounds(), movement, 0.0, b.on_ground) {
b.position = Vec3::new(
b.position.x + moved.x,
b.position.y + moved.y,
b.position.z + moved.z,
);
b.on_ground = b.velocity.y < 0.0 && moved.y > b.velocity.y;
if (moved.x - b.velocity.x).abs() > 1.0e-7 {
b.on_ground = movement.y < 0.0 && moved.y > movement.y;
if (moved.x - movement.x).abs() > 1.0e-7 {
b.velocity.x = 0.0;
}
if (moved.z - b.velocity.z).abs() > 1.0e-7 {
if (moved.z - movement.z).abs() > 1.0e-7 {
b.velocity.z = 0.0;
}
if (moved.y - b.velocity.y).abs() > 1.0e-7 {
if (moved.y - movement.y).abs() > 1.0e-7 {
b.velocity.y = 0.0;
}
}
// AbstractBoat.tick's applyEffectsFromBlocks: powder snow makes the boat stuck,
// which slows its next move (`PowderSnowBlock.entityInside`).
if !self.boat_powder_snow_cells(&b).is_empty() {
b.stuck = powder_snow_stuck();
}
// Entity.move's last step: soul sand and honey slow the boat too.
let factor = crate::motion::block_speed_factor_at(
&self.world,
@@ -825,6 +883,9 @@ impl Game<'_> {
if Some(id) != self.riding && self.boat_lava_tick(i) {
return;
}
if self.remote.is_none() {
self.boat_powder_snow_snowflakes(i);
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&id) {
@@ -1019,6 +1080,38 @@ impl Game<'_> {
self.boats[i].tick_bubble_wobble();
}
}
/// `PowderSnowBlock.entityInside` for a boat: the powder snow cells its box, and the box it
/// had before its move, reach into (`Entity.checkInsideBlocks` along the move).
fn boat_powder_snow_cells(&self, b: &Boat) -> Vec<[i32; 3]> {
let mut cells = powder_snow_cells(b.bounds(), |p| self.is_powder_snow(p));
if b.previous != b.position {
for p in powder_snow_cells(Boat::bounds_at(b.previous), |p| self.is_powder_snow(p)) {
if !cells.contains(&p) {
cells.push(p);
}
}
}
cells
}
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles`: a boat that moved
/// horizontally in powder snow throws snowflakes the players around see. `AbstractBoat.tick`
/// calls `applyEffectsFromBlocks` twice.
fn boat_powder_snow_snowflakes(&mut self, i: usize) {
let b = &self.boats[i];
let cells = self.boat_powder_snow_cells(b);
if cells.is_empty() {
return;
}
let known = b.position.subtract(b.previous);
let (x, z) = (b.position.x, b.position.z);
for _ in 0..2 {
for cell in &cells {
if let Some(velocity) = snowflake_velocity(known, &mut self.world.random) {
self.lan_snowflake([x, f64::from(cell[1] + 1), z], velocity);
}
}
}
}
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
/// damage, so lava breaks a boat on its first tick inside, and the fire
@@ -1642,5 +1735,82 @@ mod tests {
assert_eq!(b.bubble_angle, 0.0);
assert_eq!(b.bubble_angle(0.5), 0.0);
}
/// #8156: the powder snow cells a boat's box reaches into, its box deflated by 1e-5.
#[test]
fn a_boat_reaches_the_powder_snow_cells_its_box_touches() {
let b = Boat::new(1, "oak".into(), Vec3::new(10.5, 64.0, 10.5), 0.0);
let snow = |cells: &'static [[i32; 3]]| move |p: [i32; 3]| cells.contains(&p);
// Inside, a corner cell, and a cell the box only ends against.
assert_eq!(
powder_snow_cells(b.bounds(), snow(&[[10, 64, 10]])),
vec![[10, 64, 10]]
);
assert_eq!(
powder_snow_cells(b.bounds(), snow(&[[9, 64, 11], [12, 64, 10], [10, 65, 10]])),
vec![[9, 64, 11]],
"the cell beyond the edge and the one above the box are not reached"
);
let edge = Boat::new(1, "oak".into(), Vec3::new(11.3125, 64.0, 10.5), 0.0);
assert!(powder_snow_cells(edge.bounds(), snow(&[[12, 64, 10]])).is_empty());
// x, y, z order.
let all = powder_snow_cells(b.bounds(), |_| true);
assert_eq!(all.len(), 9);
assert_eq!((all[0], all[8]), ([9, 64, 9], [11, 64, 11]));
}
/// `Entity.move`: a boat made stuck goes its velocity times the multiplier once, and
/// loses its velocity; one that is not goes as it did.
#[test]
fn a_stuck_boat_moves_a_part_of_its_velocity_once() {
let mut b = Boat::new(1, "oak".into(), Vec3::ZERO, 0.0);
b.velocity = Vec3::new(1.0, 0.5, 2.0);
assert_eq!(b.take_movement(), Vec3::new(1.0, 0.5, 2.0));
assert_eq!(b.velocity, Vec3::new(1.0, 0.5, 2.0));
b.stuck = powder_snow_stuck();
let moved = b.take_movement();
let slow = f64::from(0.9_f32);
assert_eq!(moved, Vec3::new(slow, 0.75, 2.0 * slow));
assert_eq!((b.velocity, b.stuck), (Vec3::ZERO, Vec3::ZERO));
b.velocity = Vec3::new(0.0, 0.1, 1.0);
assert_eq!(b.take_movement(), Vec3::new(0.0, 0.1, 1.0), "spent");
}
/// `PowderSnowBlock.entityInside`'s snowflake: only for an entity that moved sideways, and
/// then only when a coin falls so.
#[test]
fn a_boat_moving_sideways_in_powder_snow_throws_snowflakes_now_and_then() {
use horizon_core::random::LegacyRandom;
// Still or only rising: no coin is drawn.
let mut random = LegacyRandom::new(7);
let mut untouched = LegacyRandom::new(7);
assert_eq!(
snowflake_velocity(Vec3::new(0.0, 0.4, 0.0), &mut random),
None
);
assert_eq!(random.next_int(), untouched.next_int());
let known = Vec3::new(0.1, 0.0, 0.0);
// One stream, as the server's: about half the calls draw a snowflake. (Seeds a step
// apart would not do, their first coin is nearly always the same.)
let mut stream = LegacyRandom::new(42);
let flakes: Vec<[f64; 3]> = (0..200)
.filter_map(|_| snowflake_velocity(known, &mut stream))
.collect();
assert!(
(60..=140).contains(&flakes.len()),
"about half the seeds: {}",
flakes.len()
);
let limit = f64::from(0.083_333_336_f32);
for [x, y, z] in flakes {
assert!(x.abs() <= limit && z.abs() <= limit);
assert_eq!(y, f64::from(0.05_f32));
}
// Either horizontal axis is enough.
assert!(
(0..64)
.any(|_| { snowflake_velocity(Vec3::new(0.0, 0.0, -0.1), &mut stream).is_some() })
);
}
}
horizon_client::impl_joined!(Boat);
+189 -27
View File
@@ -450,6 +450,51 @@ fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
out
}
/// `SuggestionProviders.SUMMONABLE_ENTITIES`: Entity types available for
/// `/summon` autocomplete, filtering out types that cannot be summoned.
pub(super) fn summonable_entities(
entity_types: &[String],
mob_types: impl IntoIterator<Item = impl AsRef<str>>,
) -> Vec<String> {
let mut summonable: Vec<String> = if !entity_types.is_empty() {
entity_types
.iter()
.filter(|id| !matches!(id.as_str(), "minecraft:player" | "minecraft:fishing_bobber"))
.cloned()
.collect()
} else {
let mut list: Vec<String> = mob_types
.into_iter()
.map(|k| horizon_client::command::args::normalize(k.as_ref()))
.filter(|id| !matches!(id.as_str(), "minecraft:player" | "minecraft:fishing_bobber"))
.collect();
list.extend(
[
"minecraft:item",
"minecraft:experience_orb",
"minecraft:falling_block",
"minecraft:arrow",
"minecraft:lightning_bolt",
"minecraft:minecart",
"minecraft:chest_minecart",
"minecraft:hopper_minecart",
"minecraft:furnace_minecart",
"minecraft:tnt_minecart",
"minecraft:wind_charge",
"minecraft:breeze_wind_charge",
]
.map(String::from),
);
list
};
// content mods' thrown types.
let thrown = horizon_client::projectiles::thrown_types().iter();
summonable.extend(thrown.map(|t| t.id.to_owned()));
summonable.sort();
summonable.dedup();
summonable
}
impl Game<'_> {
/// The caches `build_registries` and `join_server` read (`resource_ids`,
/// `block_properties`, `command_export`, `builtin_registries`), filled on a helper
@@ -572,33 +617,7 @@ impl Game<'_> {
.collect();
reg.block_tags.sort();
reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry));
let mut summonable: Vec<String> = self
.res
.mob_types()
.keys()
.map(|k| horizon_client::command::args::normalize(k))
.collect();
summonable.extend(
[
"minecraft:item",
"minecraft:experience_orb",
"minecraft:falling_block",
"minecraft:arrow",
"minecraft:lightning_bolt",
"minecraft:minecart",
"minecraft:chest_minecart",
"minecraft:hopper_minecart",
"minecraft:furnace_minecart",
"minecraft:tnt_minecart",
]
.map(String::from),
);
// content mods' thrown types.
let thrown = horizon_client::projectiles::thrown_types().iter();
summonable.extend(thrown.map(|t| t.id.to_owned()));
summonable.sort();
summonable.dedup();
reg.summonable = summonable;
reg.summonable = summonable_entities(&ids.entity_types, self.res.mob_types().keys());
let mut types = ids.entity_types.clone();
if types.is_empty() {
types = reg.summonable.clone();
@@ -1736,4 +1755,147 @@ mod tests {
);
assert_eq!(shared.nodes.len(), vanilla.nodes.len());
}
/// When `entity_types` is empty (no `entity-types.json`), fallback includes
/// standard mobs, non-mob entities, and `wind_charge` / `breeze_wind_charge`.
#[test]
fn summonable_entities_empty_fallback_includes_mobs_and_non_mobs() {
let mobs = ["cow", "zombie", "skeleton"];
let summonable = summonable_entities(&[], mobs);
assert!(summonable.contains(&"minecraft:cow".to_string()));
assert!(summonable.contains(&"minecraft:zombie".to_string()));
assert!(summonable.contains(&"minecraft:skeleton".to_string()));
assert!(summonable.contains(&"minecraft:item".to_string()));
assert!(summonable.contains(&"minecraft:arrow".to_string()));
assert!(summonable.contains(&"minecraft:lightning_bolt".to_string()));
assert!(summonable.contains(&"minecraft:minecart".to_string()));
assert!(summonable.contains(&"minecraft:wind_charge".to_string()));
assert!(summonable.contains(&"minecraft:breeze_wind_charge".to_string()));
}
/// Non-summonables `minecraft:player` and `minecraft:fishing_bobber` are
/// never included in `reg.summonable`.
#[test]
fn summonable_entities_never_includes_player_or_fishing_bobber() {
let all_types = vec![
"minecraft:cow".to_string(),
"minecraft:player".to_string(),
"minecraft:fishing_bobber".to_string(),
"minecraft:wind_charge".to_string(),
];
let summonable = summonable_entities(&all_types, &[] as &[&str]);
assert!(!summonable.contains(&"minecraft:player".to_string()));
assert!(!summonable.contains(&"minecraft:fishing_bobber".to_string()));
assert!(summonable.contains(&"minecraft:cow".to_string()));
assert!(summonable.contains(&"minecraft:wind_charge".to_string()));
let fallback = summonable_entities(&[], ["player", "fishing_bobber", "zombie"]);
assert!(!fallback.contains(&"minecraft:player".to_string()));
assert!(!fallback.contains(&"minecraft:fishing_bobber".to_string()));
assert!(fallback.contains(&"minecraft:zombie".to_string()));
}
/// `minecraft:wind_charge` and `minecraft:breeze_wind_charge` are in
/// `reg.summonable`, and `/summon wind` or `/summon minecraft:wind_charg`
/// suggest them via `Suggests::SummonableEntities` / `resource_matches`.
#[test]
fn summonable_entities_suggests_wind_charge_variants() {
let all_types = vec![
"minecraft:wind_charge".to_string(),
"minecraft:breeze_wind_charge".to_string(),
"minecraft:zombie".to_string(),
];
let summonable = summonable_entities(&all_types, &[] as &[&str]);
assert!(summonable.contains(&"minecraft:wind_charge".to_string()));
assert!(summonable.contains(&"minecraft:breeze_wind_charge".to_string()));
assert!(horizon_client::command::args::resource_matches(
"wind",
"minecraft:wind_charge"
));
assert!(horizon_client::command::args::resource_matches(
"wind",
"minecraft:breeze_wind_charge"
));
assert!(horizon_client::command::args::resource_matches(
"minecraft:wind_charg",
"minecraft:wind_charge"
));
assert!(!horizon_client::command::args::resource_matches(
"minecraft:wind_charg",
"minecraft:breeze_wind_charge"
));
let reg = Registries {
permission: 2,
summonable,
..Default::default()
};
let vanilla = horizon_client::command::vanilla::game_dispatcher();
let parse = vanilla.parse("summon wind", &reg);
let (_range, suggestions) = vanilla.client_suggestions(&parse, "summon wind".len(), &reg);
let texts: Vec<String> = suggestions.into_iter().map(|s| s.text).collect();
assert!(texts.contains(&"minecraft:wind_charge".to_string()));
assert!(texts.contains(&"minecraft:breeze_wind_charge".to_string()));
let parse = vanilla.parse("summon minecraft:wind_charg", &reg);
let (_range, suggestions) =
vanilla.client_suggestions(&parse, "summon minecraft:wind_charg".len(), &reg);
let texts: Vec<String> = suggestions.into_iter().map(|s| s.text).collect();
assert_eq!(texts, vec!["minecraft:wind_charge".to_string()]);
}
/// `Registries::fill_from_export` and `fill_from_exported` populate
/// `self.summonable` and `self.entity_types` when present in export JSON,
/// and sort/dedup properly.
#[test]
fn export_propagation_populates_and_dedups_summonable_and_entity_types() {
let export_json = serde_json::json!({
"summonable": [
"minecraft:zombie",
"minecraft:arrow",
"minecraft:zombie",
"minecraft:wind_charge"
],
"entity_types": [
"minecraft:zombie",
"minecraft:player",
"minecraft:arrow",
"minecraft:zombie"
]
});
let mut reg = Registries::default();
reg.fill_from_export(&export_json);
assert_eq!(
reg.summonable,
vec![
"minecraft:arrow".to_string(),
"minecraft:wind_charge".to_string(),
"minecraft:zombie".to_string(),
]
);
assert_eq!(
reg.entity_types,
vec![
"minecraft:arrow".to_string(),
"minecraft:player".to_string(),
"minecraft:zombie".to_string(),
]
);
let mut dest = Registries::default();
dest.fill_from_exported(&reg);
assert_eq!(dest.summonable, reg.summonable);
assert_eq!(dest.entity_types, reg.entity_types);
let mut prefilled = Registries {
summonable: vec!["minecraft:pig".to_string()],
entity_types: vec!["minecraft:pig".to_string()],
..Default::default()
};
prefilled.fill_from_exported(&reg);
assert_eq!(prefilled.summonable, vec!["minecraft:pig".to_string()]);
assert_eq!(prefilled.entity_types, vec!["minecraft:pig".to_string()]);
}
}
@@ -810,7 +810,7 @@ impl Game<'_> {
/// so a killed entity and a later summon of the same UUID can both be
/// in it; the one the level still holds is the entity.
pub(in crate::game) fn target_with_uuid(&self, uuid: [i32; 4]) -> Option<Target> {
if Some(uuid) == self.level.player_uuid {
if is_player_in_place(uuid, self.lan_acting_uuid, self.level.player_uuid) {
return Some(Target::Player);
}
self.persist.uuids.iter().find_map(|(&id, found)| {
@@ -1148,3 +1148,43 @@ impl Game<'_> {
|| (entries.is_empty() && self.res.mob_data().biome_has(tag, biome))
}
}
/// Whether `uuid` is the player in place (`Target::Player`): during a guest's turn that is the guest, not the
/// level's own player (the world owner), whom `Level.getEntity(UUID)` finds only while they are in the level.
fn is_player_in_place(
uuid: [i32; 4],
acting: Option<u128>,
level_player: Option<[i32; 4]>,
) -> bool {
match acting {
Some(guest) => horizon_client::lan::playerdata::ints_uuid(uuid) == guest,
None => Some(uuid) == level_player,
}
}
#[cfg(test)]
mod tests {
use super::is_player_in_place;
use horizon_client::lan::playerdata::ints_uuid;
const OWNER: [i32; 4] = [0, 0, 0, 12];
const GUEST: [i32; 4] = [0, 0, 0, 13];
/// A owns the world, B is a guest: the sequence of one command each way.
#[test]
fn the_owners_uuid_is_not_the_guest_in_place() {
// A's own command, or the single-player one: no guest in place, the level's player is.
assert!(is_player_in_place(OWNER, None, Some(OWNER)));
assert!(!is_player_in_place(GUEST, None, Some(OWNER)));
// B's turn: only B is in place, the owner's UUID names someone else.
let b = Some(ints_uuid(GUEST));
assert!(is_player_in_place(GUEST, b, Some(OWNER)));
assert!(!is_player_in_place(OWNER, b, Some(OWNER)));
// A's own turn as a guest of an integrated server.
let a = Some(ints_uuid(OWNER));
assert!(is_player_in_place(OWNER, a, Some(OWNER)));
assert!(!is_player_in_place(GUEST, a, Some(OWNER)));
// No level player at all (a dedicated server): nobody is in place without a guest.
assert!(!is_player_in_place(OWNER, None, None));
}
}
@@ -1303,6 +1303,7 @@ impl Game<'_> {
"culled_sections": r.culled_sections(),
"gpu_culling": r.gpu_culling(),
"gpu_frame": r.gpu_frame(),
"gpu_fading": r.gpu_fading(),
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
"arena_bytes": arena_bytes,
+69 -55
View File
@@ -17,13 +17,28 @@ use std::time::{Duration, Instant};
/// An item as the renderer takes it.
struct Baked {
item: Arc<Item>,
model: Arc<Part>,
/// A model per bone it rides (a shirt's sleeves are on the arms).
models: Vec<(horizon_cosmetics::Bone, Arc<Part>)>,
/// Its texture's sprite (a 64×64 skin slot of the atlas).
location: String,
/// The frame it was last marked drawn in (its slot is kept).
marked: Cell<u32>,
}
impl Baked {
fn new(item: &Arc<Item>, frame: u32) -> Self {
Self {
item: item.clone(),
models: item
.roots()
.map(|(bone, root)| (bone, Arc::new(part(root))))
.collect(),
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
marked: Cell::new(frame.wrapping_sub(1)),
}
}
}
#[derive(Default)]
pub(super) struct Cosmetics {
client: Option<horizon_cosmetics::Cosmetics>,
@@ -127,6 +142,12 @@ fn covered(m: &Mob, slot: Slot) -> bool {
Slot::Head => m.extra.head.is_some(),
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
Slot::Shoulder => false,
// A chestplate, not an elytra.
Slot::Torso => m
.extra
.chest
.as_deref()
.is_some_and(|c| c != "minecraft:elytra"),
Slot::NameTag => true,
}
}
@@ -198,12 +219,7 @@ impl Game<'_> {
let mut baked = self.cosmetics.baked.borrow_mut();
let entry = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked {
item: item.clone(),
model: Arc::new(part(&item.root)),
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
marked: Cell::new(frame.wrapping_sub(1)),
});
.or_insert_with(|| Baked::new(item, frame));
let Some(slot) = self.gui.atlas.sprites.get(&entry.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
return None;
@@ -274,8 +290,9 @@ impl Game<'_> {
let ticks = m.age as f32 + alpha;
let frame = self.cosmetics.frame.get();
let mut bounds = None;
// Items on one bone (a back item and a shoulder pet both ride the body) share its matrix.
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 3] = [None; 3];
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
// matrix: the head, the body and the two arms at most.
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 4] = [None; 4];
// "Name tags only": other players' styles beside their names, nothing on their bodies.
let bodies = own || self.settings.extras.cosmetics_others == Others::Shown;
for item in worn.iter() {
@@ -285,12 +302,7 @@ impl Game<'_> {
let mut baked = self.cosmetics.baked.borrow_mut();
let b = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked {
item: item.clone(),
model: Arc::new(part(&item.root)),
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
marked: Cell::new(frame.wrapping_sub(1)),
});
.or_insert_with(|| Baked::new(item, frame));
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
continue;
@@ -298,21 +310,6 @@ impl Game<'_> {
if b.marked.replace(frame) != frame {
self.skin_downloads.borrow_mut().drawn(&b.location);
}
let known = bones.iter().flatten().find(|(b, _)| *b == item.bone);
let bone = match known {
Some(&(_, matrix)) => matrix,
None => {
let Some(matrix) =
render::part_matrix(body, base, body_poses, item.bone.part(), 0.0)
else {
continue;
};
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
*free = Some((item.bone, matrix));
}
matrix
}
};
// The texture fills the top-left of its 64×64 slot.
let (w, h) = (
item.texture.width as f32 / 64.0,
@@ -324,34 +321,51 @@ impl Game<'_> {
rect.u0 + (rect.u1 - rect.u0) * w,
rect.v0 + (rect.v1 - rect.v0) * h,
];
poses.reset(&b.model);
item.animate(ticks, |name, pose| *poses.get(name) = pose);
if gpu {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light,
overlay: 0,
for (bone_of, model) in &b.models {
let known = bones.iter().flatten().find(|(b, _)| b == bone_of);
let bone = match known {
Some(&(_, matrix)) => matrix,
None => {
let Some(matrix) =
render::part_matrix(body, base, body_poses, bone_of.part(), 0.0)
else {
continue;
};
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
*free = Some((*bone_of, matrix));
}
matrix
}
};
if skin.draw(&b.model, bone, poses, paint) {
bounds = skin.bounds;
continue;
poses.reset(model);
item.animate(ticks, |name, pose| *poses.get(name) = pose);
if gpu {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light,
overlay: 0,
};
if skin.draw(model, bone, poses, paint) {
bounds = skin.bounds;
continue;
}
}
horizon_client::mobs::render_model(
model,
bone,
&|p: &Part| poses.pose(p),
uv,
light,
0,
out,
);
}
horizon_client::mobs::render_model(
&b.model,
bone,
&|p: &Part| poses.pose(p),
uv,
light,
0,
out,
);
}
bounds
}
+67 -6
View File
@@ -311,7 +311,7 @@ impl Game<'_> {
swimming: p.swimming,
look: view_vector(self.yaw, self.pitch),
main_hand: self.held().map(|s| s.item.clone()),
off_hand: None,
off_hand: self.offhand.as_ref().map(|s| s.item.clone()),
head: self.armor[0].as_ref().map(|s| s.item.clone()),
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
invisible: self.potions.effects.get("invisibility").is_some(),
@@ -1321,7 +1321,7 @@ impl Game<'_> {
self.for_player(who, |g| {
// CatalystListener: trigger(player, killedEntity, damageSource).
let view = g.mob_entity(m);
let blow = m.last_damage.map(|(d, _)| g.damage_view(d));
let blow = g.last_blow(m);
g.trigger_killed_as(
"minecraft:kill_mob_near_sculk_catalyst",
&view,
@@ -1413,9 +1413,24 @@ impl Game<'_> {
self.award_custom_stat("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
let blow = self.last_blow(m);
self.trigger_killed(&view, blow.as_ref());
}
/// The damage source a mob died to (`die`'s), its direct entity included when the mob
/// recorded one ([`Mob::last_direct`]).
fn last_blow(&self, m: &Mob) -> Option<horizon_client::advancements::DamageView> {
let (d, _) = m.last_damage?;
let mut view = self.damage_view(d);
if let Some(kind) = m.last_direct {
view.direct = Some(horizon_client::advancements::EntityView {
kind: kind.into(),
position: d.from.unwrap_or_default(),
..Default::default()
});
view.is_direct = false;
}
Some(view)
}
/// A mob's damage source as advancement predicates see it: its type, the
/// causing entity, and the projectile when one dealt it.
pub(super) fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
@@ -4132,6 +4147,22 @@ impl Game<'_> {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
}
/// `LivingEntity.hurtServer` for a snowball, egg or pearl (`kind`) that hurt the mob `id`,
/// 0 damage included: the hit is its owner's, and a player's fires PLAYER_HURT_ENTITY.
fn thrown_hurt_mob(&mut self, p: &Projectile, id: u32, kind: &str, amount: f32) {
self.player_hurt_mob(p, id);
if p.owner == Some(Who::Player)
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
let victim = self.mob_entity(m);
let direct = horizon_client::advancements::EntityView {
kind: kind.into(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.player_hit(&victim, "minecraft:thrown", Some(direct), amount);
}
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
/// as the host's when they have left.
fn guest_projectile_hit(&mut self, conn: u32, p: &Projectile, hit: Hit) -> Result<()> {
@@ -4853,7 +4884,9 @@ impl Game<'_> {
} else {
0.0
};
m.damage_projectile(amount, source, aim, [p.velocity.x, p.velocity.z]);
if m.damage_projectile(amount, source, aim, [p.velocity.x, p.velocity.z]) {
self.thrown_hurt_mob(p, id, "minecraft:snowball", amount);
}
} else if let Hit::Entity {
who: Who::Mob(id), ..
} = hit
@@ -4912,7 +4945,9 @@ impl Game<'_> {
} = hit
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
if m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]) {
self.thrown_hurt_mob(p, id, "minecraft:egg", 0.0);
}
} else if let Hit::Entity {
who: Who::Mob(id), ..
} = hit
@@ -4944,7 +4979,9 @@ impl Game<'_> {
} = hit
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
if m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]) {
self.thrown_hurt_mob(p, id, "minecraft:ender_pearl", 0.0);
}
} else if let Hit::Entity {
who: Who::Mob(id), ..
} = hit
@@ -5415,6 +5452,11 @@ impl Game<'_> {
self.audio
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
self.trigger_item("minecraft:filled_bucket", &filled);
// PlayerInteractTrigger: the water bucket as it was, and the mob picked up.
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
let species = bucket.trim_end_matches("_bucket").to_owned();
let id = self.mobs[index].id as i32;
// Leashable.dropLeash, then discard.
@@ -7372,6 +7414,7 @@ pub(super) fn melee(kind: horizon_client::mobs::DamageKind) -> bool {
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool) -> &'static str {
use horizon_client::mobs::DamageKind as K;
match kind {
K::Named(named) => named.name,
K::PlayerAttack => "minecraft:player_attack",
K::MaceSmash => "minecraft:mace_smash",
K::Spear => "minecraft:spear",
@@ -7744,6 +7787,24 @@ mod breeding_tests {
mod damage_type_tests {
use super::*;
#[test]
fn named_damage_keeps_its_registry_id_with_or_without_an_attacker() {
let named = horizon_client::mobs::NamedDamage::new(
"example:fan_fire",
horizon_client::mobs::NamedDamage::IS_FIRE,
);
for attacker in [None, Some(Who::Mob(7)), Some(Who::Player)] {
assert_eq!(
source_damage_type(Damage {
kind: DamageKind::Named(named),
attacker,
from: None,
}),
"example:fan_fire"
);
}
}
/// `DamageSources.explosion`: a blast with a causing entity is
/// `player_explosion` whoever caused it, so an exploding creeper's too.
#[test]
@@ -187,6 +187,42 @@ impl Game<'_> {
self.set_default_game_mode(state.game_mode);
return json!({ "value": GAME_MODES[state.game_mode as usize] });
}
"serversettings/entity_broadcast_range" => {
return json!({
"value": self
.lan
.as_ref()
.map_or(100, |host| host.hub.entity_range_percent)
});
}
"serversettings/entity_broadcast_range/set" => {
let Some(percentage) = body["value"]
.as_i64()
.and_then(|value| i32::try_from(value).ok())
else {
return json!({
"error": "entity broadcast range percentage must be an integer"
});
};
let current = self
.lan
.as_ref()
.map_or(100, |host| host.hub.entity_range_percent);
// MinecraftServerSettingsServiceImpl logs the value asked for; the
// DedicatedServerProperties deserializer clamps what is applied and saved.
rpc(format!(
"Update entity broadcast range percentage from {current}% to {percentage}%"
));
let percentage = percentage.clamp(10, 1000);
self.set_entity_broadcast_range_percentage(percentage);
self.store_property("entity-broadcast-range-percentage", percentage.to_string());
return json!({
"value": self
.lan
.as_ref()
.map_or(100, |host| host.hub.entity_range_percent)
});
}
"serversettings/simulation_distance" => {
return json!({
"value": self
@@ -76,6 +76,48 @@ impl Game<'_> {
}
}
/// Each level's loaded chunks and entities (players and block entities apart), the level in place
/// first: what the Prometheus text reports by dimension.
fn status_levels(&self) -> Value {
let mut levels = vec![json!({
"dimension": self.dim.id(),
"chunks": self.world.chunks.len(),
"entities": self.entity_count(),
})];
for dim in self.levels.parked_dims() {
if let (Some(world), Some(entities)) = (
self.levels.parked_world(dim),
self.levels.parked_entity_count(dim),
) {
levels.push(json!({
"dimension": dim.id(),
"chunks": world.chunks.len(),
"entities": entities,
}));
}
}
Value::Array(levels)
}
/// The entities of the level in place: mobs, items, orbs, projectiles, vehicles, falling blocks,
/// hangings, armor stands, cushions, tridents, rockets and primed TNT.
fn entity_count(&self) -> usize {
self.mobs.iter().filter(|m| !m.removed).count()
+ self.dropped.len()
+ self.orbs.len()
+ self.creatures.projectiles.len()
+ self.carts.len()
+ self.boats.len()
+ self.falling.len()
+ self.paintings.len()
+ self.item_frames.len()
+ self.armor_stands.len()
+ self.cushions.len()
+ self.tridents.len()
+ self.rockets.len()
+ self.tnts.len()
}
fn server_status(&self, state: &ServerState) -> Value {
let millis = |nanos: u64| nanos as f64 / 1e6;
let samples = self.clock.sorted_samples();
@@ -113,6 +155,7 @@ impl Game<'_> {
"players": players,
"max_players": max_players,
"chunks": self.world.chunks.len(),
"levels": self.status_levels(),
"paused": state.paused,
})
}
@@ -191,15 +191,36 @@ impl Game<'_> {
}) {
return;
}
let original = amount;
let amount = if self.armor[0].is_some() {
amount * 0.75
} else {
amount
};
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(kind)
{
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
let hurt = match (self.player.as_mut(), self.motion.health.damage.id(kind)) {
(Some(p), Some(id)) => p
.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false),
_ => false,
};
if hurt {
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, the falling block
// (`DamageSources.fallingBlock(this)` and the like) being both the
// causing and the direct entity.
let block = horizon_client::advancements::EntityView {
kind: "minecraft:falling_block".into(),
position: [position.x, position.y, position.z],
..Default::default()
};
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
kind: kind.to_owned(),
direct: Some(block.clone()),
is_direct: true,
source: Some(block),
original,
actual: amount,
blocked: false,
});
}
}
}
+11 -6
View File
@@ -482,13 +482,16 @@ impl Game<'_> {
/// ItemFrameRenderer: the frame model, then the item half size on its face.
pub(super) fn frame_geometry(&mut self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
let sprites = &self.gui.atlas.sprites;
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
// Each with its frame's type: a pack tells a glow frame and what it holds apart by its
// `entityId` (`glow_item_frame` in entity.properties), the item drawn within its frame.
let mut maps: Vec<(i32, glam::Mat4, u32, &'static str)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8, &'static str)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
for f in &self.item_frames {
let entity_start = out.len();
self.mark_entity(entity_start, f.kind());
let map = f.item.as_ref().and_then(super::maps::map_id);
// EntityRenderer.shouldRender: its box (a block wide with a map),
// measured from the box's centre.
@@ -614,7 +617,7 @@ impl Game<'_> {
* glam::DMat4::from_rotation_z(std::f64::consts::PI)
* glam::DMat4::from_scale(glam::DVec3::splat(0.0078125))
* glam::DMat4::from_translation(glam::DVec3::new(-64.0, -64.0, -1.0));
maps.push((id, m.as_mat4(), item_light));
maps.push((id, m.as_mat4(), item_light, f.kind()));
continue;
}
let Some(stack) = f.item.clone() else {
@@ -627,14 +630,15 @@ impl Game<'_> {
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z((f64::from(f.rotation) * 45.0).to_radians())
* glam::DMat4::from_scale(glam::DVec3::splat(0.5));
items.push((stack, f.id, m.as_mat4(), item_light));
items.push((stack, f.id, m.as_mat4(), item_light, f.kind()));
}
// `extractRenderState`: FIXED, seeded by the frame entity.
let skinning = self.skinning();
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
for (stack, id, m, light) in items {
for (stack, id, m, light, kind) in items {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
let state = self.resolve_item(
&stack,
@@ -652,8 +656,9 @@ impl Game<'_> {
}
self.cull_skinned(out, entity_start, skinned);
}
for (id, m, light) in maps {
for (id, m, light, kind) in maps {
let entity_start = out.len();
self.mark_entity(entity_start, kind);
self.map_vertices(id, true, m, light, out);
self.cull_entity_vertices(out, entity_start);
}
+1 -1
View File
@@ -57,7 +57,7 @@ pub(super) struct PackStack {
impl PackStack {
/// The enabled packs in `enabled` (the selected) order, later overriding
/// earlier: `PackRepository.openAllSelected` keeps the selected order.
fn enabled_packs(&self) -> impl Iterator<Item = &DataPack> {
pub(super) fn enabled_packs(&self) -> impl Iterator<Item = &DataPack> {
self.enabled
.iter()
.filter_map(|id| self.packs.iter().find(|p| &p.id == id))
+65 -6
View File
@@ -78,7 +78,7 @@ pub(super) fn reload_sync_data(
// a `/reload` that dropped them must read its own registries again.
let packs = !overlay.files.is_empty() || kept.has_pack_entries();
let sync = SyncData::load(root, overlay).map(|mut sync| {
sync.keep_registries(root, kept);
sync.keep_registries(root, kept, overlay);
sync.features.clone_from(&kept.features);
Arc::new(sync)
});
@@ -1708,6 +1708,13 @@ impl Game<'_> {
}
}
/// Update the live entity tracking range percentage.
pub(super) fn set_entity_broadcast_range_percentage(&mut self, percentage: i32) {
if let Some(host) = self.lan.as_mut() {
host.hub.entity_range_percent = percentage;
}
}
/// Update the live movement check's flight policy.
pub(super) fn set_allow_flight(&mut self, allow: bool) {
if let Some(host) = self.lan.as_mut() {
@@ -1862,7 +1869,8 @@ impl Game<'_> {
for event in events {
if timed {
note_event(&host, previous.take());
previous = Some((Instant::now(), event_kind(&event)));
let detail = self.event_detail(&host, &event);
previous = Some((Instant::now(), event_kind(&event), detail));
}
match event {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
@@ -3086,22 +3094,73 @@ fn event_kind(event: &HostEvent) -> EventKind {
}
}
/// Notes how long the event that started at `started` took (`spans::note`).
fn note_event(host: &LanHost, started: Option<(Instant, EventKind)>) {
let Some((started, (kind, conn, name))) = started else {
/// Notes how long the event that started at `started` took (`spans::note`), with what it
/// was about when [`Game::event_detail`] said.
fn note_event(host: &LanHost, started: Option<(Instant, EventKind, Option<UseDetail>)>) {
let Some((started, (kind, conn, name), detail)) = started else {
return;
};
horizon_client::spans::note(started.elapsed().as_secs_f32() * 1000.0, || {
let who = name
.or_else(|| conn.and_then(|c| host.hub.guest(c)).map(|g| g.name.clone()))
.or_else(|| conn.map(|c| format!("connection {c}")));
match who {
let line = match who {
Some(who) => format!("{kind} of {who}"),
None => kind.to_owned(),
};
match detail {
Some(d) => format!("{line} ({d})"),
None => line,
}
});
}
/// What a use packet was about, taken before it ran (the use may consume the item or change
/// the block): the item in the hand it names and, on a block, that block, where, and the face.
struct UseDetail {
item: Option<Arc<str>>,
block: Option<(Arc<str>, [i32; 3], i32)>,
}
impl std::fmt::Display for UseDetail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.item.as_deref().unwrap_or("empty hand"))?;
if let Some((block, [x, y, z], face)) = &self.block {
let face = ["down", "up", "north", "south", "west", "east"]
.get(*face as usize)
.copied()
.unwrap_or("?");
write!(f, " on {block} at {x} {y} {z} {face}")?;
}
Ok(())
}
}
impl Game<'_> {
/// For a slow tick's note (#8669): the item a `UseItemOn` or `UseItem` uses and the block
/// it aims at. None for other events.
fn event_detail(&self, host: &LanHost, event: &HostEvent) -> Option<UseDetail> {
let HostEvent::Packet(conn, packet) = event else {
return None;
};
let (hand, block) = match &**packet {
play::Serverbound::UseItemOn(p) => (p.hand, Some((p.pos, p.face))),
play::Serverbound::UseItem(p) => (p.hand, None),
_ => return None,
};
let guest = host.hub.guest(*conn)?;
let held = if hand == 1 {
guest.offhand.as_ref()
} else {
guest.inventory.get(guest.selected).and_then(Option::as_ref)
};
Some(UseDetail {
item: held.map(|s| Arc::clone(&s.item)),
block: block.map(|(pos, face)| (self.block_name(pos), pos, face)),
})
}
}
#[cfg(test)]
mod tests {
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags, mob_freeze_meta};
+26 -3
View File
@@ -1199,6 +1199,7 @@ impl Game<'_> {
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name;
let block = name.to_string();
let (menu, slots) = if &**name == "minecraft:ender_chest" {
// EnderChestBlock.useWithoutItem: the player's own inventory.
("minecraft:generic_9x3", Slots::Ender)
@@ -1353,8 +1354,28 @@ impl Game<'_> {
// lid (a vehicle keeps its own `Container`, an ender chest has none).
let counts_openers = matches!(slots, Slots::Block(_) | Slots::Double(_, _));
let hidden = self.native_hidden_slots(pos, items.len());
// A mod menu type's block tells the client where it is, with what its
// mod writes (`openMenu` with an extra-data writer); a stock menu
// sends nothing more.
let extra = match slots {
Slots::Block(k) if self.mod_menu_at(k).is_some() => {
let mut data = None;
self.native_menu_extra_data(uuid, menu, &block, [k.0, k.1, k.2], &mut data);
let mut data = data.unwrap_or_default();
if data.len() > horizon_protocol::menu_extra::MAX_DATA {
log::warn!(
"Horizon: {block}'s menu extra data ({} bytes) is past {} bytes; sent without it",
data.len(),
horizon_protocol::menu_extra::MAX_DATA
);
data.clear();
}
Some(data)
}
_ => None,
};
host.hub
.open_container(conn, menu, title, pos, slots, items);
.open_container(conn, menu, title, pos, slots, items, extra);
if !hidden.is_empty() {
host.hub.hide_menu_slots(conn, hidden);
}
@@ -1418,7 +1439,7 @@ impl Game<'_> {
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
host.hub
.open_container(conn, menu, title, pos, slots, items);
.open_container(conn, menu, title, pos, slots, items, None);
true
}
@@ -2096,7 +2117,9 @@ impl Game<'_> {
let stack = g
.with_hand(hand, |g| g.held().cloned())
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
g.trigger_any_block_use(p.pos, &stack);
horizon_client::spans::time("use_advancements", || {
g.trigger_any_block_use(p.pos, &stack);
});
}
g.forced_hit = None;
g.guest_use = None;
@@ -135,6 +135,15 @@ pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], veloc
}
}
impl Game<'_> {
/// A snowflake from a vehicle (`PowderSnowBlock.entityInside`), to every player around.
pub(super) fn lan_snowflake(&self, at: [f64; 3], velocity: [f64; 3]) {
if let Some(host) = self.lan.as_ref() {
send_snowflake_particle(host, at, velocity);
}
}
}
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles` of one
/// snowflake (`count` 1, no spread, `RandomizationType.ALTERNATIVE`).
pub(super) fn send_snowflake_particle(
+21
View File
@@ -149,6 +149,27 @@ impl Levels {
self.parked.get_mut(&dim)
}
/// The entities of a parked level, counted as `Game::entity_count` counts the one in place.
pub(super) fn parked_entity_count(&self, dim: Dim) -> Option<usize> {
let l = self.parked.get(&dim)?;
Some(
l.mobs.iter().filter(|m| !m.removed).count()
+ l.dropped.len()
+ l.orbs.len()
+ l.projectiles.len()
+ l.carts.len()
+ l.boats.len()
+ l.falling.len()
+ l.paintings.len()
+ l.item_frames.len()
+ l.armor_stands.len()
+ l.cushions.len()
+ l.tridents.len()
+ l.rockets.len()
+ l.tnts.len(),
)
}
/// The mobs of a parked level.
pub(super) fn parked_mobs(&self, dim: Dim) -> Option<&[Mob]> {
self.parked.get(&dim).map(|level| level.mobs.as_slice())
+9 -2
View File
@@ -252,9 +252,10 @@ fn pickup_instead_of_push(species: Species, is_passenger: bool, cart_is_vehicle:
!cart_is_vehicle && !is_passenger && species != Species::IronGolem
}
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden never rides.
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden or the
/// wither never rides (`canRide` false).
fn can_ride_cart(species: Species, boarding_cooldown: i32) -> bool {
boarding_cooldown <= 0 && species != Species::Warden
boarding_cooldown <= 0 && !matches!(species, Species::Warden | Species::Wither)
}
/// `Entity.positionRider` on an `AbstractMinecart`: passenger attachment less
@@ -3039,6 +3040,12 @@ mod tests {
assert!(!can_ride_cart(Species::Warden, 0));
}
/// `WitherBoss.canRide` returns false, so a moving cart cannot scoop the boss up.
#[test]
fn a_wither_never_rides_a_cart() {
assert!(!can_ride_cart(Species::Wither, 0));
}
#[test]
fn a_cart_seat_matches_the_player_offset() {
let at = Vec3::new(1.0, 10.0, 2.0);
@@ -10,13 +10,13 @@ impl Game<'_> {
/// layer, as `WorldDrawn::block_sprites` cuts them), where it lies on the side, how its texture runs across it,
/// its tint source and whether it is cut out, translucent or animated; its particle sprite; whether it is a plain
/// cube. None for a block the game does not know. The model is the state's first random variant. API
/// @API:native_block_sprites@.
/// 380.
pub(super) fn native_block_sprites(&self, blocks: &[&str]) -> Vec<Option<BlockSprites>> {
let lookup = self.native_sprite_lookup();
blocks.iter().map(|block| lookup.block(block)).collect()
}
/// For a client mod: each texture's sprite by name (`minecraft:block/stone`, `block/stone`), none for one no
/// model uses. API @API:native_block_sprites@.
/// model uses. API 380.
pub(super) fn native_texture_sprites(&self, textures: &[&str]) -> Vec<Option<SpriteRef>> {
let lookup = self.native_sprite_lookup();
textures
@@ -25,7 +25,7 @@ impl Game<'_> {
.collect()
}
/// For a client mod: changes each time the resources load (a resource pack turned on or off can move every
/// sprite): ask `native_block_sprites` again then. API @API:native_block_sprites@.
/// sprite): ask `native_block_sprites` again then. API 380.
pub(super) fn native_block_sprites_version(&self) -> u64 {
self.pack.context.render.generation
}
@@ -12,7 +12,7 @@ const MAX_TAGS: usize = 1024;
impl Game<'_> {
/// The entity's data as `/data get entity` shows it
/// (`Entity.saveWithoutId`): its compound without `id`. An entity with no
/// UUID yet gets the one it will be saved with. API @API:entity_data@.
/// UUID yet gets the one it will be saved with. API 386.
pub(super) fn native_entity_nbt(&mut self, entity: EntityRef) -> Option<Compound> {
let t = self.native_entity_target(entity)?;
match self.entity_nbt(t)? {
@@ -28,7 +28,7 @@ impl Game<'_> {
/// changed. False for a player (vanilla refuses them too), a kind Horizon
/// can't reload yet (only mobs, dropped items, armor stands and item
/// frames), data nested too deep, or data that changes nothing. API
/// @API:entity_data@.
/// 386.
pub(super) fn native_entity_merge_nbt(&mut self, entity: EntityRef, nbt: Compound) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
@@ -44,12 +44,12 @@ impl Game<'_> {
merged != old && self.apply_entity_nbt(t, merged).is_ok()
}
/// The entity's tags (`Entity.getTags`), as `/tag <entity> list` shows
/// them. API @API:entity_data@.
/// them. API 386.
pub(super) fn native_entity_tags(&self, entity: EntityRef) -> Option<Vec<String>> {
Some(self.tags_of(self.native_entity_target(entity)?))
}
/// Adds tag `tag` (`Entity.addTag`): false when it has it already, holds
/// 1024, its kind can't store tags yet, or the handle is stale. API @API:entity_data@.
/// 1024, its kind can't store tags yet, or the handle is stale. API 386.
pub(super) fn native_entity_add_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
@@ -64,7 +64,7 @@ impl Game<'_> {
self.set_entity_tags(t, &tags)
}
/// Removes tag `tag` (`Entity.removeTag`): false when it didn't have it.
/// API @API:entity_data@.
/// API 386.
pub(super) fn native_entity_remove_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
@@ -75,7 +75,7 @@ impl Game<'_> {
tags.len() != before && self.set_entity_tags(t, &tags)
}
/// The entity's name as plain text (`Entity.getName`): its custom name,
/// a player's name, else its type's name. API @API:entity_data@.
/// a player's name, else its type's name. API 386.
pub(super) fn native_entity_name(&self, entity: EntityRef) -> Option<String> {
Some(self.target_name(self.native_entity_target(entity)?).plain())
}
@@ -30,7 +30,7 @@ impl Game<'_> {
/// The entity's velocity in blocks a tick (`Entity.getDeltaMovement`). A
/// player's is the move its client last reported (`getKnownMovement`); an
/// entity Horizon doesn't simulate (a display, a painting) has none, so
/// zero. API @API:entity_motion@.
/// zero. API 379.
pub(super) fn native_entity_velocity(&self, entity: EntityRef) -> Option<[f64; 3]> {
let t = self.native_entity_target(entity)?;
if let Some(g) = self.native_target_guest(t) {
@@ -47,7 +47,7 @@ impl Game<'_> {
/// Sets the entity's velocity (`Entity.setDeltaMovement`); clients see it
/// as they see the entity move. False for a player (its client moves
/// it), an entity Horizon doesn't simulate, or a value that isn't finite.
/// API @API:entity_motion@.
/// API 379.
pub(super) fn native_entity_set_velocity(
&mut self,
entity: EntityRef,
@@ -67,7 +67,7 @@ impl Game<'_> {
true
}
/// Whether the entity stands on a block (`Entity.onGround`). API
/// @API:entity_motion@.
/// 379.
pub(super) fn native_entity_on_ground(&self, entity: EntityRef) -> Option<bool> {
let t = self.native_entity_target(entity)?;
if let Some(g) = self.native_target_guest(t) {
@@ -80,7 +80,7 @@ impl Game<'_> {
}
/// The entity's box (`Entity.getBoundingBox`): its lowest and highest
/// corner. None for the kinds whose box Horizon doesn't keep (displays,
/// markers, End crystals, the dragon). API @API:entity_motion@.
/// markers, End crystals, the dragon). API 379.
pub(super) fn native_entity_bounding_box(&self, entity: EntityRef) -> Option<[[f64; 3]; 2]> {
let t = self.native_entity_target(entity)?;
if let Some(g) = self.native_target_guest(t) {
@@ -97,25 +97,25 @@ impl Game<'_> {
Some([lo, hi])
}
/// The height of the entity's eyes above its feet (`Entity.getEyeHeight`).
/// API @API:entity_motion@.
/// API 379.
pub(super) fn native_entity_eye_height(&self, entity: EntityRef) -> Option<f64> {
Some(self.target_eye_height(self.native_entity_target(entity)?))
}
/// Where the entity's eyes are (`Entity.getEyePosition`). API
/// @API:entity_motion@.
/// 379.
pub(super) fn native_entity_eye_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
let [x, y, z] = self.native_entity_position(entity)?;
Some([x, y + self.native_entity_eye_height(entity)?, z])
}
/// The unit vector the entity looks along (`Entity.getViewVector`, from
/// its pitch and yaw). API @API:entity_motion@.
/// its pitch and yaw). API 379.
pub(super) fn native_entity_view_vector(&self, entity: EntityRef) -> Option<[f64; 3]> {
let [yaw, pitch] = self.native_entity_rotation(entity)?;
let v = horizon_world::view_direction::view_vector(pitch, yaw);
Some([v.x, v.y, v.z])
}
/// The square of the distance from the entity's feet to `pos`
/// (`Entity.distanceToSqr`). API @API:entity_motion@.
/// (`Entity.distanceToSqr`). API 379.
pub(super) fn native_entity_distance_to_sqr(
&self,
entity: EntityRef,
@@ -37,14 +37,14 @@ impl Game<'_> {
/// The entity `id` (the id events carry) as a handle for the `entity_*`
/// methods: None on a client, with no world open, or for no such entity
/// in the level in place or among the server's players. API
/// @API:entity_handles@.
/// 373.
pub(super) fn native_entity(&self, id: u32) -> Option<EntityRef> {
let entity = EntityRef::new(self.native_lifecycle.epoch()?, id);
self.native_entity_target(entity).map(|_| entity)
}
/// The level `dimension` (`minecraft:the_nether`) as a handle: None on a
/// client, with no world open, or for a dimension the server hasn't
/// loaded. API @API:entity_handles@.
/// loaded. API 373.
pub(super) fn native_level(&self, dimension: &str) -> Option<LevelRef> {
if self.remote.is_some() {
return None;
@@ -56,29 +56,29 @@ impl Game<'_> {
Some(LevelRef::new(epoch, dim.id()))
}
/// The entity's position (`Entity.position`): its feet, the middle of
/// its box for a painting or an item frame. API @API:entity_handles@.
/// its box for a painting or an item frame. API 373.
pub(super) fn native_entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.target_position(self.native_entity_target(entity)?)
}
/// The block the entity's position is in (`Entity.blockPosition`). API
/// @API:entity_handles@.
/// 373.
pub(super) fn native_entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
self.native_entity_position(entity)
.map(|p| p.map(|v| v.floor() as i32))
}
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
/// for the kinds that don't turn. API @API:entity_handles@.
/// for the kinds that don't turn. API 373.
pub(super) fn native_entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
Some(self.target_rotation(self.native_entity_target(entity)?))
}
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
/// @API:entity_handles@.
/// 373.
pub(super) fn native_entity_kind(&self, entity: EntityRef) -> Option<String> {
Some(self.target_type(self.native_entity_target(entity)?))
}
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
/// entity that has none yet gets it now, as a new vanilla entity does.
/// API @API:entity_handles@.
/// API 373.
pub(super) fn native_entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
let words = match self.native_entity_target(entity)? {
Target::Player => return self.command_guest(self.lan_acting?).map(|g| g.uuid),
@@ -87,7 +87,7 @@ impl Game<'_> {
};
Some(uuid_from_words(words))
}
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
/// The level the entity is in (`Entity.level`). API 373.
pub(super) fn native_entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
let dim: Dim = self.target_level(self.native_entity_target(entity)?);
Some(LevelRef::new(entity.epoch(), dim.id()))
+284 -26
View File
@@ -6,6 +6,91 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 399 adds a player's chat message before it is sent (`native_player_chat`).
//!
//! API 398 adds the player's network entity id on `NativeServerPlayer` (`entity_id`).
//!
//! API 397 adds mod config files, loaded and reloaded (`native_register_configs`, `native_config_loading`,
//! `native_config_reloading`, `native_config_unloading`; `native_configs.rs`).
//!
//! API 396 adds a slime or magma cube splitting on death, cancellable (`native_mob_split`).
//!
//! API 395 adds two animals breeding and a baby about to spawn, cancellable (`native_baby_entity_spawn`).
//!
//! API 394 adds experience points a server player is about to gain, changeable or cancelled (`native_player_xp_change`).
//!
//! API 393 adds the problem that stops a player sleeping in a bed, changeable (`native_can_player_sleep`).
//!
//! API 392 adds a living entity about to die, cancellable (`native_living_death`).
//!
//! API 391 adds a mob about to convert into another, cancellable (`native_living_conversion_pre`).
//!
//! API 390 adds a mob converted into another (`native_living_conversion_post`).
//!
//! API 389 adds A village siege found a spawn position, cancellable (`native_village_siege`).
//!
//! API 388 adds Possible natural spawn entries, mutable and cancellable (`native_potential_spawns`).
//!
//! API 387 adds A player tunes a note block (`native_note_block_change`).
//!
//! API 386 adds an entity's data (read and merge, as `/data` does), tags and name through an `EntityRef`.
//!
//! API 385 adds per-stack conditional item model properties (`native_item_model_property`).
//!
//! API 384 adds a host-aware player-weapon damage hook (`native_living_incoming_damage_v2`), leaving v1 compatible.
//!
//! API 383 adds living_knockback_v2: raw knockback with the attacker's entity id and held items, including armor-stand sweeps.
//!
//! API 382 adds living entity handles (`LivingRef`, `native_living`) and a living entity's equipment and hand items, read and set.
//!
//! API 381 adds a level's blocks and time through a `LevelRef`: block state, block, property, whether a chunk is loaded, height, game time and day time.
//!
//! API 380 adds block textures for client mods: `native_block_sprites` and `native_texture_sprites` say where blocks' and textures' sprites are in the block sprite arrays, and `WorldDrawn::block_sprites` hands a mod the arrays.
//!
//! API 379 adds an entity's velocity (and setting it), ground, box, eye height and position, view vector and distance through an `EntityRef`.
//!
//! API 378 adds validated category declarations for custom recipe types (`native_recipe_book_categories`, `native_recipe_book_category`); display and unlock integration follows separately.
//!
//! API 377 adds a mod's own ingredient kind matching a concrete stack
//! (`native_ingredient_test`, which the ingredient's test calls with the kind,
//! its JSON, the stack and the verdict — the load-time list already named
//! the item).
//!
//! API 376 adds The ammunition a bow or crossbow shooter finds (`native_living_get_projectile`).
//!
//! API 375 adds A player is about to nock a bow (`native_arrow_nock`).
//!
//! API 374 adds Explosion velocity replacement (`native_explosion_knockback`).
//!
//! API 373 adds entity and level handles (`EntityRef`, `LevelRef`, `native_entity`, `native_level`) and an entity's position, block position, rotation, type, UUID and level.
//!
//! API 372 adds a name tag hook (native_render_name_tag).
//!
//! API 371 adds `WorldDrawn::motion`: which mob, player or dropped item each run of the frame's entity quads is.
//!
//! API 370 adds a mod item holding items (`native_item_container`), and the item container helpers in
//! `native_item_container.rs`: `native_item_container_slots`, `native_item_container_insert`, `native_item_container_extract`.
//!
//! API 369 adds an additive first-person hand and item pose (`native_render_hand_pose`).
//!
//! API 368 adds tooltip box colours (`native_tooltip_color`).
//!
//! API 367 adds the screen_opening screen lifecycle observation (`native_screen_opening`) at most once per frame.
//!
//! API 366 adds the screen_init_post screen lifecycle observation (`native_screen_init_post`) at most once per frame.
//!
//! API 365 adds the screen_closing screen lifecycle observation (`native_screen_closing`) at most once per frame.
//!
//! API 364 adds the render hook point `native_hand_drawn(hand)`: the first-person hand is drawn (`HandDrawn`).
//!
//! API 363 adds reading the world camera and block collision boxes (`native_camera`, `native_block_collision`).
//!
//! API 362 adds a per-tick player FOV modifier hook (`native_compute_fov_modifier`).
//!
//! API 361 adds a cancellable item frame item render hook (`native_render_item_in_frame`).
//!
//! API 360 adds Local player movement input before application (`native_movement_input`).
//!
//! API 359 adds a living entity jumping (`native_living_jump`).
//!
//! API 358 adds a living entity healing, with a changeable amount and cancellation (`native_living_heal`).
@@ -880,6 +965,7 @@ use super::{
native_configs::{NativeConfig, NativeConfigFile},
native_mob_draw::{NativeMobRender, NativePlayerRender},
native_panel_recipes::RecipePage,
native_reload_listeners::NativeReloadListener,
};
use horizon_assets::item_model::StackView;
use horizon_client::mod_api::{
@@ -1048,7 +1134,7 @@ pub(super) use horizon_world::level::flags;
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 359;
pub(crate) const NATIVE_HOOKS_API: u32 = 399;
impl GameLevel<'_> {
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232.
@@ -1278,7 +1364,7 @@ impl GameLevel<'_> {
/// client prediction. `context` gives its dimension, position, instrument
/// and old note; `new_note` starts as vanilla's cycled note. Set it to 0
/// through 24; an out-of-range value keeps vanilla's. Cancel to leave the
/// note unchanged and prevent playback. API @API:native_note_block_change@.
/// note unchanged and prevent playback. API 387.
#[inline(always)]
pub(super) fn native_note_block_change(
&self,
@@ -1759,6 +1845,27 @@ pub struct NativeShake {
pub offset: [f32; 3],
}
/// A mod's camera (`native_camera_pose`): each value that isn't finite keeps vanilla's from `view`.
fn posed_camera(
view: (glam::DVec3, f32, f32),
position: [f64; 3],
yaw: f32,
pitch: f32,
) -> (glam::DVec3, f32, f32) {
let position = std::array::from_fn(|i| {
if position[i].is_finite() {
position[i]
} else {
view.0[i]
}
});
(
glam::DVec3::from_array(position),
if yaw.is_finite() { yaw } else { view.1 },
if pitch.is_finite() { pitch } else { view.2 },
)
}
/// The largest turn a shake gives, in degrees, on each axis.
const SHAKE_MAX_DEGREES: f32 = 10.0;
/// The largest offset a shake gives, in blocks, along each axis.
@@ -1826,17 +1933,28 @@ fn shake_pose(shake: NativeShake) -> glam::Mat4 {
}
/// The mod screen open on this client: the block it is for, the key its
/// slots are under, the window id of its menu, and the menu's data slots as
/// the server sent them.
/// slots are under, the window id of its menu, the menu's data slots as
/// the server sent them, and what the server sent before the menu opened
/// (`horizon:menu_extra`), when it did.
pub(super) struct OpenNativeScreen {
key: Pos,
window: i32,
block: String,
screen: NativeScreen,
data: Vec<i32>,
extra: Option<horizon_protocol::menu_extra::Message>,
}
impl OpenNativeScreen {
/// The block the menu is of: where the server said, else the clicked
/// block its slots are under (a server without `horizon:menu_extra`).
fn pos(&self) -> Option<[i32; 3]> {
match &self.extra {
Some(extra) => Some(extra.pos),
None if self.key.0 != i32::MIN => Some([self.key.0, self.key.1, self.key.2]),
None => None,
}
}
/// Data slot `id` of window `window`'s menu: false for another window's
/// (an old or replaced one), which this screen leaves alone.
fn set_data(&mut self, window: i32, id: i16, value: i16) -> bool {
@@ -2194,6 +2312,32 @@ impl Game<'_> {
pub(super) fn native_server_data_loaded(&mut self, reload: bool) {
mod_api::dispatch_server_data_loaded(self, mod_api::mods(), reload);
}
/// The folders of JSON files native mods read from the data
/// (`AddServerReloadListenersEvent`): push yours, each a listener id and a
/// folder under `data/<namespace>/`. Asked at the start of every data load
/// of a server: a world opening, `/reload`. A folder that leaves
/// `data/<namespace>/` is skipped. API @API:server_reload_listeners@.
pub(super) fn native_add_server_reload_listeners(
&mut self,
listeners: &mut Vec<NativeReloadListener>,
) {
mod_api::dispatch_add_server_reload_listeners(self, mod_api::mods(), listeners);
}
/// A listener's files were read (`SimpleJsonResourceReloadListener`'s
/// `apply`): `files` holds `<namespace>:<path>` (the file's path under
/// the folder, without `.json`) and its JSON, sorted by id, from the
/// resource root (vanilla's and content mods' data) and the world's enabled
/// data packs; for each id the top pack's file, and a file that isn't JSON
/// is logged and left out. Every mod hears every listener, so check
/// `listener.id`. Fired on each data load before `native_server_data_loaded`.
/// Server-side. API @API:server_reload_listeners@.
pub(super) fn native_server_reload_listener_apply(
&mut self,
listener: &NativeReloadListener,
files: &[(String, serde_json::Value)],
) {
mod_api::dispatch_server_reload_listener_apply(self, mod_api::mods(), listener, files);
}
/// The server is assembling its data tables from the world's data packs
/// (`ReloadableServerResources`): a world is opening, or `/reload` was
/// run, and the recipes, tags, loot and advancements are built after
@@ -2253,7 +2397,7 @@ impl Game<'_> {
/// (`Common` and `Server`) and by a server before each world it opens
/// (`Server`); a game reads only its own kinds, so push them all every
/// time. A file outside `config/`, or one already registered, is skipped.
/// API @API:mod_configs@.
/// API 397.
pub(super) fn native_register_configs(&mut self, configs: &mut Vec<NativeConfig>) {
mod_api::dispatch_register_configs(self, mod_api::mods(), configs);
}
@@ -2261,7 +2405,7 @@ impl Game<'_> {
/// says whose, which file and its text, which the mod parses (TOML by
/// convention). Every mod hears every config, so check `mod_id`. Fired
/// where the config's kind loads (`native_register_configs`), a `Server`
/// one before `native_server_about_to_start`. API @API:mod_configs@.
/// one before `native_server_about_to_start`. API 397.
pub(super) fn native_config_loading(&mut self, config: &NativeConfigFile) {
mod_api::dispatch_config_loading(self, mod_api::mods(), config);
}
@@ -2269,14 +2413,14 @@ impl Game<'_> {
/// its new text. The game looks at the files' modification times once a
/// second, on the window and on the server's tick; a file saved with the
/// same text, removed or unreadable fires nothing and keeps its last
/// text. API @API:mod_configs@.
/// text. API 397.
pub(super) fn native_config_reloading(&mut self, config: &NativeConfigFile) {
mod_api::dispatch_config_reloading(self, mod_api::mods(), config);
}
/// A `Server` config is dropped as its server stops
/// (`ModConfigEvent.Unloading`), after `native_server_stopping`; the next
/// world reads it again. `Client` and `Common` configs stay until the
/// game quits. API @API:mod_configs@.
/// game quits. API 397.
pub(super) fn native_config_unloading(&mut self, config: &NativeConfigFile) {
mod_api::dispatch_config_unloading(self, mod_api::mods(), config);
}
@@ -2500,7 +2644,7 @@ impl Game<'_> {
mod_api::dispatch_commands(self, mod_api::mods(), commands);
}
/// Player FOV scale, after vanilla effects and before zoom and easing.
/// Invalid values or values outside 0.01..=10 revert to base. API @API:compute_fov_modifier@.
/// Invalid values or values outside 0.01..=10 revert to base. API 362.
pub(super) fn native_compute_fov_modifier(
&self,
player: &horizon_client::camera::FovPlayer,
@@ -2562,7 +2706,7 @@ impl Game<'_> {
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
mod_api::dispatch_camera_shake(self, mod_api::mods(), partial, shake);
}
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
/// Name tag visibility and optional replacement text. API 372.
pub(super) fn native_render_name_tag(
&self,
entity_id: u32,
@@ -2608,7 +2752,7 @@ impl Game<'_> {
/// Additional first-person hand and item pose, including empty arms and maps.
/// Add offsets in blocks and pitch, yaw and roll in degrees; multiply scale.
/// API @API:render_hand_pose@.
/// API 369.
pub(super) fn native_render_hand_pose(
&self,
hand: horizon_client::first_person::Hand,
@@ -2660,6 +2804,21 @@ impl Game<'_> {
);
}
/// The camera this frame is drawn from, `partial` ticks past the last tick, after vanilla
/// placed it (`Camera.setup`): its position, and its yaw and pitch in degrees. Set them to
/// draw the world from anywhere: a cinematic or replay camera. Only the picture, the sound's
/// listener and the billboards facing the camera follow it; the player, where they aim, the
/// first-person hand and which chunks load stay the player's. Values that aren't finite keep
/// vanilla's. Not called on the title screen's panorama. API @API:camera_pose@.
pub(super) fn native_camera_pose(
&self,
partial: f32,
position: &mut [f64; 3],
yaw: &mut f32,
pitch: &mut f32,
) {
mod_api::dispatch_camera_pose(self, mod_api::mods(), partial, position, yaw, pitch);
}
/// Final scaled third-person camera distance, before visual-shape collision clipping.
/// `entity` includes its living scale/distance and optional mount scale/distance.
/// Only finite requests in 0..=1024 blocks replace the vanilla distance.
@@ -2923,7 +3082,7 @@ impl Game<'_> {
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
/// API 370.
pub(super) fn native_item_container(&self, item: &str, slots: &mut Option<u32>) {
mod_api::dispatch_item_container(self, mod_api::mods(), item, slots);
}
@@ -4269,7 +4428,7 @@ impl Game<'_> {
/// without a message) to refuse. The spawn point is set before it fires,
/// as in vanilla, only when vanilla found no problem. After it,
/// `native_player_bed_enter` still fires and may cancel. Server-side.
/// API @API:can_player_sleep@.
/// API 393.
pub(super) fn native_can_player_sleep(
&mut self,
player: u128,
@@ -4417,7 +4576,7 @@ impl Game<'_> {
/// an advancement reward gives nothing for a negative amount. Experience
/// bottles and furnaces make orbs, so they come through the orb path.
/// `native_player_exp_change` is separate and still fires first for an orb
/// pickup. Server-side. API @API:player_xp_change@.
/// pickup. Server-side. API 394.
pub(super) fn native_player_xp_change(
&mut self,
player: u128,
@@ -4638,6 +4797,23 @@ impl Game<'_> {
) {
mod_api::dispatch_inventory_block_start(self, mod_api::mods(), player, name, pos, cancel);
}
/// Block `block` at `pos` opens its mod menu type `menu` for server
/// player `player`: set `data` to hand the menu's screen bytes of your
/// own (a machine's tier, mode or recipes), up to 32 KiB, which the
/// client reads with `native_screen_extra_data` while the screen is open
/// (NeoForge's `openMenu` with an extra-data writer). The first mod to
/// set it wins, so answer only for your blocks. The player's state is not
/// swapped in. @API:menu_extra_data@.
pub(super) fn native_menu_extra_data(
&mut self,
player: u128,
menu: &str,
block: &str,
pos: [i32; 3],
data: &mut Option<Vec<u8>>,
) {
mod_api::dispatch_menu_extra_data(self, mod_api::mods(), player, menu, block, pos, data);
}
/// A sculk catalyst at `pos` has begun blooming after taking experience.
/// The catalyst block state has changed. Server-side.
@@ -5216,7 +5392,7 @@ impl Game<'_> {
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
/// or the player is in creative. Set `cancel` to fail the use.
/// @API:arrow_nock@.
/// 375.
pub(super) fn native_arrow_nock(
&mut self,
player: u128,
@@ -5243,7 +5419,7 @@ impl Game<'_> {
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
/// load of a pillager or piglin. `projectile` is the vanilla
/// ammunition; replace it to choose another. A replaced stack is not taken
/// from the inventory. @API:living_get_projectile@.
/// from the inventory. 376.
pub(super) fn native_living_get_projectile(
&mut self,
player: Option<u128>,
@@ -5499,7 +5675,7 @@ impl Game<'_> {
/// size of the children (half of it) and `count` how many vanilla drew
/// (2 to 4). A mod may change `count` (0 spawns none, at most 64) or set
/// `cancel` to spawn no children. Only for a parent above size 1; the
/// split's random draw is made first. On the server. API @API:mob_split@.
/// split's random draw is made first. On the server. API 396.
pub(super) fn native_mob_split(
&mut self,
parent: u32,
@@ -5531,7 +5707,7 @@ impl Game<'_> {
/// statistic, no advancement and no heart particles, as NeoForge
/// returns before `finalizeSpawnChildFromBreeding`. Not fired for
/// turtles, frogs and sniffers (an egg, no baby), villagers or
/// armadillos' own paths. On the server. API @API:baby_entity_spawn@.
/// armadillos' own paths. On the server. API 395.
pub(super) fn native_baby_entity_spawn(
&mut self,
parent: u32,
@@ -5567,7 +5743,7 @@ impl Game<'_> {
/// Possible natural spawns at a server position (NeoForge's
/// `LevelEvent.PotentialSpawns`, `NaturalSpawner.mobsAt`). Mods may add or
/// remove entries, or cancel to make the list empty.
/// @API:native_potential_spawns@.
/// 388.
pub(super) fn native_potential_spawns(
&self,
dimension: &str,
@@ -5624,7 +5800,7 @@ impl Game<'_> {
/// A village siege found a spawn position on the server. Set `canceled`
/// to stop the siege. There is one siege per dimension.
/// @API:native_village_siege@.
/// 389.
pub(super) fn native_village_siege(
&mut self,
dimension: &str,
@@ -5715,6 +5891,39 @@ impl Game<'_> {
mod_api::dispatch_broadcast_message(self, mod_api::mods(), line, cancel);
}
/// NeoForge's `ClientChatReceivedEvent`: a chat line from the server is
/// about to show, once Chat Delay lets it through. `system` is a system
/// message (`ClientChatReceivedEvent.System`), shown in the action bar
/// when `overlay` is set; otherwise a player or disguised message in its
/// chat type `chat_type` (`getBoundChatType`, its registry name; `None`
/// for a system message or an inline chat type). `sender` is the player's
/// UUID, 0 (`Util.NIL_UUID`) for system and disguised messages. `message`
/// starts as the decorated line; replace it and the chat shows the new
/// one, while the narrator still says the original. Set `cancel` and it
/// doesn't show: a delayed message lets the next one through. Lines the
/// client adds itself and validation errors are not told. Client-side.
/// API @API:client_chat_received@.
pub(super) fn native_client_chat_received(
&mut self,
system: bool,
overlay: bool,
sender: u128,
chat_type: Option<&str>,
message: &mut Text,
cancel: &mut bool,
) {
mod_api::dispatch_client_chat_received(
self,
mod_api::mods(),
system,
overlay,
sender,
chat_type,
message,
cancel,
);
}
/// The dedicated server's whitelist changed to `enabled`. 305.
pub(super) fn native_whitelist_state_update(&mut self, enabled: bool) {
mod_api::dispatch_whitelist_state_update(self, mod_api::mods(), enabled);
@@ -5758,7 +5967,7 @@ impl Game<'_> {
/// mob that dealt it, 0 for none or a player. Set `cancel` to keep it alive:
/// Horizon decides death by health, so it stays at 1 health. The hook
/// fires once per death attempt. Armor stands, boats and other non-mob
/// entities are not told. API @API:living_death@.
/// entities are not told. API 392.
pub(super) fn native_living_death(
&mut self,
entity: u32,
@@ -5785,7 +5994,7 @@ impl Game<'_> {
/// lightning. `outcome` is the new type. Set `cancel` to keep the mob as
/// it is; a drowning zombie starts its timers again. Not told: a
/// mooshroom a player shears, a zombie villager's cure, a villager turned
/// by a zombie. API @API:living_conversion_pre@.
/// by a zombie. API 391.
pub(super) fn native_living_conversion_pre(
&mut self,
entity: u32,
@@ -5807,7 +6016,7 @@ impl Game<'_> {
/// `LivingConversionEvent.Post`): `entity` is gone, `converted` took its
/// place. Fires with `native_entity_transform`, after the new mob joined
/// the level, for the conversions `native_living_conversion_pre` tells.
/// API @API:living_conversion_post@.
/// API 390.
pub(super) fn native_living_conversion_post(
&mut self,
entity: u32,
@@ -6531,7 +6740,7 @@ impl Game<'_> {
/// what is added to the entity's velocity; a player's client is sent the
/// same. Wind bursts too, except dragons and end crystals. End crystals
/// are also omitted from ordinary blasts: Horizon removes them without a
/// velocity to change. Server-side. @API:explosion_knockback@.
/// velocity to change. Server-side. 374.
pub(super) fn native_explosion_knockback(
&mut self,
dimension: &str,
@@ -7435,7 +7644,7 @@ impl Game<'_> {
/// DamageSource position. The acting player's network id is `causing`
/// (also the direct entity). Weapon enchantments include their levels.
/// Runs after the victim's invulnerability check (an invulnerable or dead victim fires nothing) and before v1; cancellation refuses
/// delivery. Other damage routes still only fire v1. API @API:living_incoming_damage_v2@.
/// delivery. Other damage routes still only fire v1. API 384.
pub(super) fn native_living_incoming_damage_v2(
&mut self,
entity: i32,
@@ -7518,7 +7727,7 @@ impl Game<'_> {
/// include the guest whose action runs. Includes mob melee and armor-stand sweeps, not
/// direct velocity pushes (explosions, Punch arrows, hoglin throws). Non-finite changes
/// are restored before resistance. Server-side, without self: damage borrows the victim.
/// API @API:living_knockback_v2@.
/// API 383.
pub(super) fn native_living_knockback_v2(
entity: i32,
kind: &str,
@@ -8719,6 +8928,19 @@ impl Game<'_> {
pub(super) fn native_screen_title(&self) -> Option<String> {
self.remote_menu_title().map(|t| t.plain())
}
/// The block of the mod screen that is open (`native_screen`), as the
/// server named it when the block opened its menu type; None when no mod
/// screen is open. @API:menu_extra_data@.
pub(super) fn native_screen_pos(&self) -> Option<[i32; 3]> {
self.shown_native_screen()?.pos()
}
/// What the block's mod wrote for the mod screen that is open
/// (`native_menu_extra_data` on the server), empty when it wrote nothing;
/// None when no mod screen is open. @API:menu_extra_data@.
pub(super) fn native_screen_extra_data(&self) -> Option<&[u8]> {
let screen = self.shown_native_screen()?;
Some(screen.extra.as_ref().map_or(&[], |e| e.data.as_slice()))
}
/// A data file of the game's resources, content mods' included
/// (`data/mymod/machines.json`), when there is one: what a mod's content
/// says, read at runtime. Only relative paths of plain names. API 20.
@@ -9436,12 +9658,14 @@ impl Game<'_> {
}
/// A menu opened on this client with its slots under `key`, on block
/// `target`: a mod block's is laid out by its mod (`native_screen`).
/// `extra` is what the server sent for this window first.
pub(super) fn open_native_screen(
&mut self,
key: Pos,
target: Option<Pos>,
window: i32,
menu: &str,
extra: Option<horizon_protocol::menu_extra::Message>,
) {
self.mod_screen = None;
let Some(t) = target else {
@@ -9475,6 +9699,7 @@ impl Game<'_> {
block,
screen,
data: Vec::new(),
extra,
});
}
}
@@ -9861,6 +10086,18 @@ impl Game<'_> {
shake_pose(bounded_shake(shake, self.screen_effect_scale()))
}
/// The camera native mods draw this frame from (`native_camera_pose`), starting from
/// `view`, vanilla's position, yaw and pitch.
pub(super) fn native_camera_pose_now(
&self,
partial: f32,
view: (glam::DVec3, f32, f32),
) -> (glam::DVec3, f32, f32) {
let (mut position, mut yaw, mut pitch) = (view.0.to_array(), view.1, view.2);
self.native_camera_pose(partial, &mut position, &mut yaw, &mut pitch);
posed_camera(view, position, yaw, pitch)
}
/// What native mods hand shader packs in this frame (`native_shader_params`),
/// with values that aren't finite as 0.
pub(super) fn native_shader_params_now(&self, partial: f32) -> [[f32; 4]; 8] {
@@ -10235,6 +10472,26 @@ mod tests {
assert_eq!(super::chars_to_type("", |_| false), None);
}
/// A mod's camera replaces vanilla's value by value; one that isn't finite keeps vanilla's.
#[test]
fn a_mod_camera_replaces_each_finite_value() {
use super::posed_camera;
let vanilla = (glam::DVec3::new(1.0, 2.0, 3.0), 10.0, 20.0);
assert_eq!(
posed_camera(vanilla, [-40.5, 90.0, 1e7], 270.0, -35.0),
(glam::DVec3::new(-40.5, 90.0, 1e7), 270.0, -35.0)
);
assert_eq!(
posed_camera(
vanilla,
[f64::NAN, 5.0, f64::INFINITY],
f32::NAN,
f32::NEG_INFINITY
),
(glam::DVec3::new(1.0, 5.0, 3.0), 10.0, 20.0)
);
}
/// A shake is scaled by Distortion Effects, bounded on each axis, and a
/// value that isn't finite is none.
#[test]
@@ -10415,6 +10672,7 @@ mod tests {
block: "gtmod:machine".into(),
screen: super::NativeScreen::default(),
data: Vec::new(),
extra: None,
};
assert!(s.set_data(4, 1, 7));
assert!(!s.set_data(3, 1, 99));
@@ -37,7 +37,7 @@ impl Game<'_> {
/// The block state at `pos` (`Level.getBlockState`) as `/setblock` takes
/// it: `minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]`.
/// None for a chunk that isn't loaded or a `y` outside the world. API
/// @API:level_reads@.
/// 381.
pub(super) fn native_level_block_state(
&self,
level: LevelRef,
@@ -47,14 +47,14 @@ impl Game<'_> {
.map(|state| self.state_string(state))
}
/// The block at `pos` without its properties: `minecraft:oak_stairs`.
/// None as for `block_state`. API @API:level_reads@.
/// None as for `block_state`. API 381.
pub(super) fn native_level_block(&self, level: LevelRef, pos: [i32; 3]) -> Option<String> {
self.native_level_state(level, pos)
.map(|state| self.res.facts().get(state).name.to_string())
}
/// Property `key` of the block state at `pos` (`BlockState.getValue`):
/// `east` for `facing`. None for a block without it, or as for
/// `block_state`. API @API:level_reads@.
/// `block_state`. API 381.
pub(super) fn native_level_block_property(
&self,
level: LevelRef,
@@ -72,7 +72,7 @@ impl Game<'_> {
}
/// Whether `pos` is inside the world's height and its chunk is loaded
/// (`Level.isLoaded`, which is false outside `isInValidBounds`): a block
/// read or write there finds the world. API @API:level_reads@.
/// read or write there finds the world. API 381.
pub(super) fn native_level_is_loaded(&self, level: LevelRef, pos: [i32; 3]) -> bool {
self.native_level_world(level).is_some_and(|w| {
(w.bottom_y()..w.top_y()).contains(&pos[1])
@@ -80,19 +80,19 @@ impl Game<'_> {
})
}
/// The lowest block `y` and the height of the level
/// (`LevelHeightAccessor.getMinY`, `getHeight`). API @API:level_reads@.
/// (`LevelHeightAccessor.getMinY`, `getHeight`). API 381.
pub(super) fn native_level_height(&self, level: LevelRef) -> Option<(i32, i32)> {
self.native_level_world(level)
.map(|w| (w.shape.min_y, w.shape.height))
}
/// The ticks the server has run (`Level.getGameTime`), shared by every
/// level as in vanilla. API @API:level_reads@.
/// level as in vanilla. API 381.
pub(super) fn native_level_game_time(&self, level: LevelRef) -> Option<i64> {
self.native_level_world(level)?;
Some(self.world.game_time)
}
/// The time of day in ticks (`Level.getOverworldClockTime`), shared by
/// every level; it keeps counting past 24000. API @API:level_reads@.
/// every level; it keeps counting past 24000. API 381.
pub(super) fn native_level_day_time(&self, level: LevelRef) -> Option<i64> {
self.native_level_world(level)?;
Some(self.world.day_time)
@@ -62,14 +62,14 @@ impl Game<'_> {
}
/// The entity as a living entity (vanilla's `LivingEntity`: a mob, a
/// player, an armor stand or the dragon), or None for another kind or a
/// stale handle. API @API:living_refs@.
/// stale handle. API 382.
pub(super) fn native_living(&self, entity: EntityRef) -> Option<LivingRef> {
let living = LivingRef::new(entity);
self.native_living_target(living).map(|_| living)
}
/// The stack in equipment slot `slot` (`LivingEntity.getItemBySlot`):
/// None when it is empty, the entity has no such slot (a player has no
/// body or saddle slot), or the handle is stale. API @API:living_refs@.
/// body or saddle slot), or the handle is stale. API 382.
pub(super) fn native_living_equipment(
&self,
living: LivingRef,
@@ -101,7 +101,7 @@ impl Game<'_> {
}
}
/// The stack in hand `hand` (`LivingEntity.getItemInHand`). API
/// @API:living_refs@.
/// 382.
pub(super) fn native_living_hand_item(
&self,
living: LivingRef,
@@ -118,7 +118,7 @@ impl Game<'_> {
/// when the entity has no such slot: a player has no body or saddle
/// slot, the dragon none. A mob has every slot, head and body apart
/// (`EntityEquipment`), as `/item` does; Horizon keeps no mob saddle here
/// yet. API @API:living_refs@.
/// yet. API 382.
pub(super) fn native_living_set_equipment(
&mut self,
living: LivingRef,
@@ -162,4 +162,29 @@ impl Game<'_> {
}));
Ok(())
}
/// A mod's chat line or command (`/` first), sent as `send_chat` sends
/// it (ClientPacketListener.sendChat / sendCommand), to any server: no
/// chat history, and no client chat event (vanilla's `ClientChatEvent`
/// comes only from ChatScreen). An error when the player isn't in a
/// server's play state or the Chat option blocks the message. API
/// @API:send_chat@.
pub(super) fn native_send_chat(&mut self, message: &str) -> Result<(), String> {
let Some(remote) = self.remote.as_ref() else {
return Err("not connected to a server".into());
};
// A server that reconfigures the player has them out of the play state.
if !remote.joined || remote.reconfiguring.is_some() {
return Err("the player has not joined yet".into());
}
let message = message.trim();
if message.is_empty() {
return Err("the message is empty".into());
}
if !self.chat_abilities().allows(message) {
return Err("the Chat option blocks this message".into());
}
self.send_chat(message);
Ok(())
}
}
@@ -0,0 +1,244 @@
//! Native mods' own data folders, read on each data load of a server
//! (NeoForge's `AddServerReloadListenersEvent`, docs/native-mods.md). A mod
//! names a folder under `data/<namespace>/` (`native_add_server_reload_listeners`);
//! when a world opens and on `/reload`, the folder's `.json` files of the
//! resource root (vanilla's and content mods') and of the world's enabled data
//! packs are read as vanilla's `SimpleJsonResourceReloadListener.prepare` reads
//! its folder, and handed to the mod (`native_server_reload_listener_apply`).
use super::{Game, functions};
use horizon_client::datapack::DataPack;
use std::collections::BTreeMap;
use std::path::{Component, Path};
/// A folder of JSON files a mod reads from the data packs, as vanilla's
/// `SimpleJsonResourceReloadListener` over `FileToIdConverter.json(folder)`.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct NativeReloadListener {
/// `mymod:ore_veins`: which of the mod's listeners the files are for.
pub id: String,
/// The folder under `data/<namespace>/`: `ore_veins`, `mymod/ore_veins`.
pub folder: String,
}
impl NativeReloadListener {
/// The `.json` files of `data/<namespace>/<folder>/`, for listener `id`.
pub fn json(id: &str, folder: &str) -> Self {
Self {
id: id.to_owned(),
folder: folder.to_owned(),
}
}
}
/// Whether `folder` stays below `data/<namespace>/`.
fn folder_ok(folder: &str) -> bool {
let path = Path::new(folder);
!folder.contains('\\')
&& path.components().next().is_some()
&& path.components().all(|c| matches!(c, Component::Normal(_)))
}
/// `FileToIdConverter.fileToId`: `data/<ns>/<folder>/<path>.json` is
/// `<ns>:<path>`; None for a file of another folder or extension.
fn file_id(file: &str, folder: &str) -> Option<String> {
let (ns, rest) = file.strip_prefix("data/")?.split_once('/')?;
let path = rest
.strip_prefix(folder)?
.strip_prefix('/')?
.strip_suffix(".json")?;
(!path.is_empty()).then(|| format!("{ns}:{path}"))
}
/// The folder's files over `packs`, the bottom one first: each id's file is
/// the top pack's (`ResourceManager.listResources`), parsed; one that isn't
/// JSON is logged and skipped, as `prepare` does. Sorted by id.
fn read_folder(
packs: &[&DataPack],
listener: &str,
folder: &str,
) -> Vec<(String, serde_json::Value)> {
let mut top: BTreeMap<String, (&DataPack, String)> = BTreeMap::new();
for pack in packs {
for file in pack.list_folder(folder) {
if let Some(id) = file_id(&file, folder) {
top.insert(id, (pack, file));
}
}
}
let mut files = Vec::with_capacity(top.len());
for (id, (pack, file)) in top {
let Some(bytes) = pack.read(&file) else {
continue;
};
match serde_json::from_slice(&bytes) {
Ok(json) => files.push((id, json)),
Err(e) => log::error!(
"Horizon: couldn't parse data file '{id}' from '{file}' in pack {} for {listener}: {e}",
pack.id
),
}
}
files
}
impl Game<'_> {
/// A server's data load (a world opening, `/reload`): every listener's
/// files go to its mod, before `native_server_data_loaded`.
pub(super) fn native_reload_listeners(&mut self) {
let mut listeners = Vec::new();
self.native_add_server_reload_listeners(&mut listeners);
if listeners.is_empty() {
return;
}
let stack = functions::pack_stack(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
);
let packs: Vec<&DataPack> = stack.builtin.iter().chain(stack.enabled_packs()).collect();
for listener in &listeners {
if !folder_ok(&listener.folder) {
log::warn!(
"Horizon: reload listener {} folder {:?} is not a folder under data/<namespace>/",
listener.id,
listener.folder
);
continue;
}
let files = read_folder(&packs, &listener.id, &listener.folder);
self.native_server_reload_listener_apply(listener, &files);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use std::path::PathBuf;
struct Folder(PathBuf);
impl Folder {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"horizon-reload-listeners-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
Self(dir)
}
/// A folder pack with these files.
fn pack(&self, name: &str, files: &[(&str, &str)]) -> DataPack {
let root = self.0.join(name);
for (path, text) in files {
let path = root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
DataPack::builtin(&root)
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn the_top_pack_wins_each_id_and_ids_drop_the_folder_and_extension() {
let dir = Folder::new("top");
let root = dir.pack(
"root",
&[
("data/gt/ore_veins/copper.json", r#"{"weight": 1}"#),
("data/gt/ore_veins/deep/tin.json", r#"{"weight": 2}"#),
("data/other/ore_veins/zinc.json", r#"{"weight": 3}"#),
// Another folder, and a name that only starts like it.
("data/gt/recipe/plate.json", "{}"),
("data/gt/ore_veins_old/lead.json", "{}"),
],
);
let world = dir.pack(
"world",
&[
("data/gt/ore_veins/copper.json", r#"{"weight": 10}"#),
("data/gt/ore_veins/notes.txt", "not json, not read"),
],
);
let files = read_folder(&[&root, &world], "gt:veins", "ore_veins");
assert_eq!(
files,
[
("gt:copper".to_owned(), json!({"weight": 10})),
("gt:deep/tin".to_owned(), json!({"weight": 2})),
("other:zinc".to_owned(), json!({"weight": 3})),
]
);
}
#[test]
fn a_broken_top_file_is_skipped_without_falling_back() {
let dir = Folder::new("broken");
let root = dir.pack(
"root",
&[
("data/gt/machines/press.json", r#"{"tier": 1}"#),
("data/gt/machines/mixer.json", r#"{"tier": 2}"#),
],
);
let world = dir.pack("world", &[("data/gt/machines/press.json", "{ tier: ")]);
let files = read_folder(&[&root, &world], "gt:machines", "machines");
assert_eq!(files, [("gt:mixer".to_owned(), json!({"tier": 2}))]);
}
#[test]
fn a_nested_folder_reads_only_below_it() {
let dir = Folder::new("nested");
let pack = dir.pack(
"root",
&[
("data/gt/gt/veins/a.json", "1"),
("data/gt/gt/other/b.json", "2"),
],
);
let files = read_folder(&[&pack], "gt:veins", "gt/veins");
assert_eq!(files, [("gt:a".to_owned(), json!(1))]);
}
#[test]
fn a_zipped_pack_reads_only_the_folder() {
let dir = Folder::new("zip");
let packs = dir.0.join("datapacks");
fs::create_dir_all(&packs).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(packs.join("veins.zip")).unwrap());
for (name, text) in [
("pack.mcmeta", r#"{"pack":{"pack_format":48}}"#),
("data/gt/ore_veins/iron.json", r#"{"y": -16}"#),
("data/gt/ore_veins_old/lead.json", "{}"),
("data/gt/recipe/plate.json", "{}"),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
std::io::Write::write_all(&mut zip, text.as_bytes()).unwrap();
}
zip.finish().unwrap();
let found = horizon_client::datapack::discover_folder(&packs);
let refs: Vec<&DataPack> = found.iter().collect();
let files = read_folder(&refs, "gt:veins", "ore_veins");
assert_eq!(files, [("gt:iron".to_owned(), json!({"y": -16}))]);
}
#[test]
fn a_folder_outside_the_namespace_is_refused() {
assert!(folder_ok("ore_veins"));
assert!(folder_ok("gt/ore_veins"));
for bad in ["", "..", "../recipe", "gt/../../x", "/abs", "a\\b"] {
assert!(!folder_ok(bad), "{bad:?}");
}
}
}
+4
View File
@@ -357,6 +357,10 @@ impl Game<'_> {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
super::shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)
})
.transpose()
+70 -6
View File
@@ -109,6 +109,11 @@ pub(super) struct PotionCloud {
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
/// thrower, else the cloud.
source: Text,
/// `AreaEffectCloud.getOwner`: the thrower (`ThrownLingeringPotion.onHitAsPotion`), whose
/// kill a mob's death to its instant damage is.
owner: Option<Who>,
/// On a server, the guest (by connection) who threw it.
guest: Option<u32>,
}
impl PotionCloud {
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
@@ -131,6 +136,18 @@ pub(super) struct PotionOwner {
attacker: Option<u32>,
/// A player threw it: the water damage `indirectMagic` deals is theirs.
player: bool,
/// On a server, the guest (by connection) who threw it: whose kill the mobs it hurts are
/// (`LivingEntity.lastHurtByPlayer`).
guest: Option<u32>,
}
impl PotionOwner {
/// The owner as a damage source's causing entity.
fn who(&self) -> Option<Who> {
match self.attacker {
Some(id) => Some(Who::Mob(id)),
None => self.player.then_some(Who::Player),
}
}
}
/// A mob's effect ids carry the `minecraft:` namespace; a mod's has its own.
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
@@ -140,6 +157,9 @@ pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
/// decide first, then the tag of mobs that ignore poison and regeneration.
/// `WitherBoss.addEffect` refusing everything is `Mob::add_effect`'s: a cloud
/// still takes the wither as a victim (`AreaEffectCloud` asks `canBeAffected`),
/// so its instant effects land and it shrinks.
pub(super) fn mob_can_be_affected(
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
kind: &str,
@@ -1238,7 +1258,15 @@ impl Game<'_> {
}
}
if let Some((p, mob, block)) = hit {
landed.push((t.stack.clone(), p, mob, block, t.age, t.owner.clone()));
landed.push((
t.stack.clone(),
p,
mob,
block,
t.age,
t.owner.clone(),
t.guest,
));
return false;
}
t.position = next;
@@ -1250,7 +1278,7 @@ impl Game<'_> {
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at, mob, block, age, owner) in landed {
for (stack, at, mob, block, age, owner, guest) in landed {
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
// is heard at its centre, an entity at the hit.
let heard = if mob.is_some() {
@@ -1266,6 +1294,7 @@ impl Game<'_> {
player: owner.is_some(),
name: owner,
attacker: None,
guest,
};
self.splash(&stack, at, mob, block, age, &owner);
if item != "minecraft:experience_bottle" {
@@ -1356,6 +1385,7 @@ impl Game<'_> {
(&potion.hidden, &potion.ambient),
c.at,
&potion.source,
(potion.owner, potion.guest),
);
// setRadiusOnUse(-0.5).
c.radius -= 0.5;
@@ -1393,6 +1423,8 @@ impl Game<'_> {
color,
victims: Vec::new(),
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
owner: None,
guest: None,
}),
});
}
@@ -1406,6 +1438,7 @@ impl Game<'_> {
(hidden, ambient): (&[&'static str], &[&'static str]),
at: [f64; 3],
source: &Text,
(owner, guest): (Option<Who>, Option<u32>),
) {
// `indirectMagic(cloud, owner)` names the cloud in the death message
// (it gives no kill credit).
@@ -1460,8 +1493,13 @@ impl Game<'_> {
let amount =
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
if (*id == "instant_damage") != inverted {
let harm = m.potion_harm(amount(6));
m.hurt(harm, Some(at), &mut self.world.random);
// HealOrHarmMobEffect.applyInstantaneousEffect: indirectMagic(cloud, owner).
let source = Damage::indirect_magic(owner, at);
if m.damage_by(amount(6), source, "minecraft:area_effect_cloud")
&& owner == Some(Who::Player)
{
self.last_hurt_by_player.insert(mob, guest);
}
} else {
m.heal(amount(4));
}
@@ -1657,11 +1695,13 @@ impl Game<'_> {
name: Some(self.player_display_text()),
attacker: None,
player: true,
guest: self.lan_acting,
},
Some(Who::Mob(id)) => PotionOwner {
name: self.mob_display_name(id),
attacker: Some(id),
player: false,
guest: None,
},
None => PotionOwner::default(),
}
@@ -1764,6 +1804,8 @@ impl Game<'_> {
source: owner.name.clone().unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
owner: owner.who(),
guest: owner.guest,
}),
});
}
@@ -1804,8 +1846,13 @@ impl Game<'_> {
};
let m = &mut self.mobs[i];
if harm {
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
// HealOrHarmMobEffect.applyInstantaneousEffect: indirectMagic(potion, owner).
let harm = (intensity * f64::from(6 << amp) + 0.5) as i32 as f32;
let source = Damage::indirect_magic(owner.who(), [at.x, at.y, at.z]);
if m.damage_by(harm, source, "minecraft:splash_potion") && owner.player {
let id = m.id;
self.last_hurt_by_player.insert(id, owner.guest);
}
} else {
m.heal((intensity * f64::from(4 << amp) + 0.5) as i32 as f32);
}
@@ -1907,6 +1954,21 @@ mod tests {
use super::*;
use horizon_client::screen_effects::nausea_blend_added;
/// `WitherBoss.canBeAffected` refuses only the wither effect: its `addEffect`
/// refuses the rest, but a cloud of strength or healing still counts the boss
/// as a victim (`AreaEffectCloud.serverTick` asks `canBeAffected`).
#[test]
fn a_cloud_still_counts_the_wither_as_a_victim() {
let tags = std::collections::HashMap::new();
for effect in ["strength", "instant_health"] {
assert!(
mob_can_be_affected(&tags, "minecraft:wither", effect),
"{effect}"
);
}
assert!(!mob_can_be_affected(&tags, "minecraft:wither", "wither"));
}
/// #5876: a potion thrown straight up from inside its thrower's box does
/// not hit them while it climbs out, then hits them on the way down;
/// another player in its path is hit at once.
@@ -2070,6 +2132,8 @@ mod tests {
color: 0,
victims: Vec::new(),
source: Text::empty(),
owner: None,
guest: None,
}),
}];
assert!(!collect_dragon_breath(player_box, &mut lingering));
+193 -62
View File
@@ -195,14 +195,18 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text {
}
/// A player message Chat Delay holds back, its signature and trust level,
/// and what the narrator says of it once it shows.
type QueuedChat = (
horizon_client::text::Text,
horizon_client::chat::ChatSource,
Option<Signature>,
ChatTrust,
String,
);
/// what the narrator says of it once it shows, and who sent it in which
/// chat type, for mods told when it shows (`ClientChatReceivedEvent`).
struct QueuedChat {
line: horizon_client::text::Text,
signature: Option<Signature>,
trust: ChatTrust,
narration: String,
/// The player's UUID, 0 for disguised chat (`Util.NIL_UUID`).
sender: u128,
/// The chat type's network ID (`ChatType.Bound.chatType`).
chat_type: i32,
}
/// `ChatTrustLevel`; a modified message keeps the signed content it was
/// changed from.
@@ -537,6 +541,9 @@ pub(super) struct Remote {
pub(super) menu_title: Option<horizon_client::text::Text>,
state_id: i32,
use_target: Option<[i32; 3]>,
/// A mod menu's extra data, kept for the `OpenScreen` of its window that
/// follows (`horizon:menu_extra`).
menu_extra: Option<horizon_protocol::menu_extra::Message>,
entities: HashMap<i32, Tracked>,
lerps: HashMap<i32, Lerp>,
/// The minecarts the server moves with `ClientboundMoveMinecartPacket`
@@ -1039,6 +1046,7 @@ impl Game<'_> {
menu_title: None,
state_id: 0,
use_target: None,
menu_extra: None,
entities: HashMap::new(),
lerps: HashMap::new(),
cart_lerps: HashMap::new(),
@@ -1398,10 +1406,6 @@ impl Game<'_> {
signature: Option<Signature>,
trust: ChatTrust,
) -> bool {
// ChatListener.showMessageToPlayer: Only Show Secure Chat drops what is not secure.
if matches!(trust, ChatTrust::NotSecure) && self.settings.file.bool("onlyShowSecureChat") {
return false;
}
if !self.ui.chat.shows(source) {
return false;
}
@@ -1442,21 +1446,13 @@ impl Game<'_> {
/// `ChatListener.handleMessage` for player and disguised chat: held in the
/// queue while Chat Delay is set, shown at once otherwise. `narration` is
/// the chat type's narration of it (`decorateNarration`).
pub(super) fn handle_player_chat(
&mut self,
line: horizon_client::text::Text,
signature: Option<Signature>,
trust: ChatTrust,
narration: String,
) {
let source = horizon_client::chat::ChatSource::Player;
fn handle_player_chat(&mut self, chat: QueuedChat) {
if self.chat_delay_ms() > 0 {
if let Some(r) = self.remote.as_mut() {
r.chat_queue
.push_back((line, source, signature, trust, narration));
r.chat_queue.push_back(chat);
}
} else {
self.accept_chat(line, source, signature, trust, &narration);
self.accept_chat(chat);
}
}
@@ -1484,7 +1480,14 @@ impl Game<'_> {
let narration = self
.decorate_chat(chat_type, &error, Decoration::Narration)
.plain();
self.handle_player_chat(line, None, ChatTrust::Error, narration);
self.handle_player_chat(QueuedChat {
line,
signature: None,
trust: ChatTrust::Error,
narration,
sender: 0,
chat_type: chat_type.chat_type,
});
}
/// `ChatTrustLevel.evaluate` of a player message, with
@@ -1550,7 +1553,7 @@ impl Game<'_> {
r.secure_chat.ignore_pending(&signature);
let queued = r.chat_queue.len();
r.chat_queue
.retain(|(_, _, s, _, _)| s.as_ref() != Some(&signature));
.retain(|chat| chat.signature.as_ref() != Some(&signature));
if r.chat_queue.len() == queued {
self.ui
.chat
@@ -1561,15 +1564,9 @@ impl Game<'_> {
/// A message's `accept`: shown, it is narrated (`narrateChatMessage`) and
/// starts the Chat Delay wait again.
fn accept_chat(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
trust: ChatTrust,
narration: &str,
) -> bool {
let shown = self.player_chat_line(line, source, signature, trust, narration);
fn accept_chat(&mut self, chat: QueuedChat) -> bool {
let signature = chat.signature;
let shown = self.player_chat_line(chat);
if let Some(signature) = &signature {
self.mark_message_processed(signature, shown);
}
@@ -1579,28 +1576,60 @@ impl Game<'_> {
shown
}
/// `showMessageToPlayer`: the line joins the chat if the Chat option and
/// Only Show Secure Chat let it through, and the narrator says it
/// (`sayChatQueued`).
fn player_chat_line(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
trust: ChatTrust,
narration: &str,
) -> bool {
// handleChatMessageError says its line as system chat.
/// `showMessageToPlayer`: the line joins the chat if Only Show Secure
/// Chat, mods and the Chat option let it through, and the narrator says
/// it (`sayChatQueued`).
fn player_chat_line(&mut self, chat: QueuedChat) -> bool {
let QueuedChat {
mut line,
signature,
trust,
narration,
sender,
chat_type,
} = chat;
// ChatListener.showMessageToPlayer: Only Show Secure Chat drops what is not secure.
if matches!(trust, ChatTrust::NotSecure) && self.settings.file.bool("onlyShowSecureChat") {
return false;
}
// handleChatMessageError says its line as system chat, and mods are not told of it.
let error = matches!(trust, ChatTrust::Error);
if !error && !self.chat_received(false, false, sender, Some(chat_type), &mut line) {
return false;
}
let source = horizon_client::chat::ChatSource::Player;
let shown = self.signed_chat_line(line, source, signature, trust);
if shown && error {
self.say_system_chat_queued(&horizon_client::text::Text::literal(narration));
self.say_system_chat_queued(&horizon_client::text::Text::literal(&narration));
} else if shown {
self.say_chat_queued(narration);
self.say_chat_queued(&narration);
}
shown
}
/// NeoForge's `ClientChatReceivedEvent` (`ClientHooks.onClientChat`,
/// `onClientPlayerChat` and `onClientSystemChat`): mods may replace
/// `line` or cancel it. False when cancelled.
fn chat_received(
&mut self,
system: bool,
overlay: bool,
sender: u128,
chat_type: Option<i32>,
line: &mut horizon_client::text::Text,
) -> bool {
if horizon_client::mod_api::mods().is_empty() {
return true;
}
let registries = self.remote.as_ref().map(|r| Arc::clone(&r.registries));
let name = chat_type
.zip(registries.as_deref())
.and_then(|(id, registries)| registries.name("minecraft:chat_type", id));
let mut cancel = false;
self.native_client_chat_received(system, overlay, sender, name, line, &mut cancel);
!cancel
}
/// `ChatListener.messageDelay`: Chat Delay in milliseconds, as Java
/// truncates it.
fn chat_delay_ms(&self) -> i64 {
@@ -1640,10 +1669,8 @@ impl Game<'_> {
}
// A message the Chat option hides is dropped and the next one is tried,
// until one shows.
while let Some((line, source, signature, trust, narration)) =
self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
{
if self.accept_chat(line, source, signature, trust, &narration) {
while let Some(chat) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front()) {
if self.accept_chat(chat) {
break;
}
}
@@ -1659,8 +1686,8 @@ impl Game<'_> {
else {
return;
};
for (line, source, signature, trust, narration) in queue {
self.accept_chat(line, source, signature, trust, &narration);
for chat in queue {
self.accept_chat(chat);
}
if let Some(r) = self.remote.as_mut() {
r.chat_previous = None;
@@ -3051,6 +3078,18 @@ impl Game<'_> {
C::CustomPayload(p) if p.channel == horizon_protocol::attachment_sync::CHANNEL => {
self.remote_attachment_sync(&p.data);
}
// A mod menu type's block and its mod's bytes, just before the
// menu's `OpenScreen`.
C::CustomPayload(p) if p.channel == horizon_protocol::menu_extra::CHANNEL => {
match horizon_protocol::menu_extra::decode(&p.data) {
Ok(extra) => {
if let Some(r) = self.remote.as_mut() {
r.menu_extra = Some(extra);
}
}
Err(e) => log::debug!("Horizon: a bad menu extra payload: {e}"),
}
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
@@ -4566,8 +4605,12 @@ impl Game<'_> {
}
C::SystemChat(p) => {
// The component as sent, translated in the client's own
// language. ChatListener.handleOverlay for the action bar.
let text = remote_text(&p.content);
// language; mods may change or cancel it. ChatListener.handleOverlay
// for the action bar.
let mut text = remote_text(&p.content);
if !self.chat_received(true, p.overlay, 0, None, &mut text) {
return Ok(());
}
if p.overlay {
self.say_system_queued(&text);
self.titles.set_overlay(text, false);
@@ -4683,7 +4726,14 @@ impl Game<'_> {
}
return Ok(());
}
self.handle_player_chat(line, signature, trust, narration);
self.handle_player_chat(QueuedChat {
line,
signature,
trust,
narration,
sender: p.sender,
chat_type: p.chat_type.chat_type,
});
}
C::DisguisedChat(p) => {
let content = horizon_client::scoreboard::tag_to_json(&p.message);
@@ -4691,7 +4741,14 @@ impl Game<'_> {
.decorate_chat(&p.chat_type, &content, Decoration::Narration)
.plain();
let line = self.decorate_chat(&p.chat_type, &content, Decoration::Chat);
self.handle_player_chat(line, None, ChatTrust::Secure, narration);
self.handle_player_chat(QueuedChat {
line,
signature: None,
trust: ChatTrust::Secure,
narration,
sender: 0,
chat_type: p.chat_type.chat_type,
});
}
C::DeleteChat(p) => self.handle_delete_chat(&p.signature)?,
C::PlayerInfoUpdate(p) => {
@@ -6038,15 +6095,19 @@ impl Game<'_> {
// at 10, the hurt tilt restarted every frame and shook the camera.
player.health.advance_clock();
if riding && !dead {
MotionWorld {
player.client_side = true;
let mut passenger = MotionWorld {
entities: &entity_collisions,
player: &mut *player,
scene: &mut self.world,
assets: &self.pack.assets,
collision_binding: self.collision,
resources: self.motion,
}
.passenger_pose_tick()?;
};
passenger.passenger_pose_tick()?;
// LivingEntity.aiStep: a passenger freezes in powder snow too, and the
// frostbite overlay shows it (#8156).
passenger.passenger_block_effects_tick()?;
}
}
// The server decides fire the window cannot predict (blaze fireballs,
@@ -9434,6 +9495,18 @@ impl Game<'_> {
.as_mut()
.and_then(|r| r.use_target.take())
.map(|t| (t[0], t[1], t[2]));
// The server names a mod menu's block itself: it, not the last
// clicked block, is the one the menu is of. Only for this window:
// any other's is stale.
let extra = self
.remote
.as_mut()
.and_then(|r| r.menu_extra.take())
.filter(|e| e.window == p.container);
let target = extra
.as_ref()
.map(|e| (e.pos[0], e.pos[1], e.pos[2]))
.or(target);
if menu == "minecraft:lectern" {
// LecternScreen: the book and page come in the menu's content and data.
if let Some(r) = self.remote.as_mut() {
@@ -9541,7 +9614,7 @@ impl Game<'_> {
let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0));
self.world.block_entities.insert(key, entity);
self.container = Some(key);
self.open_native_screen(key, target, p.container, &menu);
self.open_native_screen(key, target, p.container, &menu, extra);
// A mod menu type shows through its mod's screen only: without one
// (`MenuScreens.create` finds no screen) the window closes again.
if self.mod_screen.is_none() && mod_type.is_some() {
@@ -10564,6 +10637,64 @@ mod tests {
};
use horizon_world::nbt::Tag;
/// A mod's chat goes out only from a joined player, and only what the
/// Chat option allows.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn mod_chat_needs_a_joined_player_and_what_the_chat_option_allows() {
use super::super::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_protocol::connection::Pipe;
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
assert!(game.native_send_chat("/warp spawn").is_err());
let (client, _server) = Pipe::pair();
game.connect_local(client, "ChatTest").unwrap();
assert!(game.native_send_chat("/warp spawn").is_err());
game.remote.as_mut().unwrap().joined = true;
assert!(game.native_send_chat(" ").is_err());
// Full, then Commands Only, then Hidden.
game.settings.chat_visibility = 0;
assert!(game.native_send_chat("hello").is_ok());
assert!(game.native_send_chat("/warp spawn").is_ok());
game.settings.chat_visibility = 1;
assert!(game.native_send_chat("hello").is_err());
assert!(game.native_send_chat("/warp spawn").is_ok());
game.settings.chat_visibility = 2;
assert!(game.native_send_chat("/warp spawn").is_err());
}
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
/// swing, CONSUME's do not (#6425).
#[test]
+84 -5
View File
@@ -41,7 +41,7 @@ pub(super) fn read_shader_options(
let chosen = chosen_shader_options(name);
let pack = pack.with_chosen(chosen.clone());
let text = pack.read_text("shaders.properties").unwrap_or_default();
let properties = Properties::parse(&text, &horizon_shaders::with_options(&pack, macros));
let properties = Properties::parse(&text, &horizon_shaders::properties_macros(&pack, macros));
let screens = horizon_shaders::Screens::parse(&properties, &options);
Some(ShaderOptions::new(
name.to_owned(),
@@ -108,16 +108,14 @@ fn active_pack<'a>(picked: &'a str, mods: &horizon_client::mod_list::ModList) ->
}
}
/// Whether the pack `settings` pick (or `HORIZON_SHADER_PACK`) is one of Horizon's own: only
/// then do resource loads read the resource packs' LabPBR maps (`sprites::set_labpbr_maps`).
pub(super) fn picks_native_pack(settings: &horizon_client::settings::Settings) -> bool {
/// A selected pack may read resource-pack material maps, whether native or GLSL.
pub(super) fn picks_material_pack(settings: &horizon_client::settings::Settings) -> bool {
let dev = std::env::var("HORIZON_SHADER_PACK").ok();
let picked = active_pack(
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
&settings.mods,
);
!picked.is_empty()
&& horizon_shader_api::package::is_pack(&Game::shader_packs_dir().join(picked))
}
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
@@ -134,6 +132,11 @@ pub(super) struct PackIds {
/// −1, or 0 without the file (5.3).
items: HashMap<String, i32>,
missing_item: i32,
/// `currentRenderedItemId` of each block item (`StateFacts::item` of its block's default
/// state): that state's `block.properties` id, or 0.
block_items: HashMap<String, i32>,
/// Whether the pack has `item.properties` (`currentRenderedItemId` of an item it lacks).
item_file: bool,
/// `oldHandLight` (3.2): the first held item and light are those of the brighter hand.
old_hand_light: bool,
/// `vignette`: `false` hides the game's vignette over the pack's image.
@@ -178,6 +181,11 @@ impl Game<'_> {
if picked.is_empty() {
return PackInputs::default();
}
if !horizon_client::sprites::labpbr_maps() {
horizon_client::sprites::set_labpbr_maps(true);
self.reload_packs();
return PackInputs::default();
}
let name = picked;
let dimension = match self.dim {
Dim::Overworld => 0,
@@ -248,7 +256,11 @@ impl Game<'_> {
&Properties::parse(text.as_deref().unwrap_or_default(), &macros),
"item",
);
let block_items =
block_item_ids(self.res.facts(), self.pack.assets.registry, &ids);
self.pack_ids = Some(PackIds {
block_items,
item_file: text.is_some(),
items: items.plain(),
missing_item: if text.is_some() { -1 } else { 0 },
// OQ-21: on unless the pack turns it off.
@@ -457,6 +469,17 @@ impl Game<'_> {
self.settings.vignette && self.pack_ids.as_ref().is_none_or(|ids| ids.vignette)
}
/// `currentRenderedItemId` of a held `item`, [`rendered_item_id`]'s; 0 without a pack.
pub(super) fn rendered_item_id(&self, item: &str) -> i32 {
self.pack_ids.as_ref().map_or(0, |ids| {
rendered_item_id(
ids.block_items.get(item).copied(),
ids.items.get(item).copied(),
ids.item_file,
)
})
}
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
if self.pack_ids.is_none() {
@@ -606,6 +629,41 @@ impl Game<'_> {
}
}
/// `currentRenderedItemId` of an item (Iris 1.11.7, observed with probe packs): a block item
/// (`block`: its block's `block.properties` id, 0 when the file lacks it) reads that, whatever
/// `item.properties` says of it; any other item its `item.properties` id (`item`), and one the
/// file lacks 65535, or 0 when the pack has no `item.properties` (`item_file`).
fn rendered_item_id(block: Option<i32>, item: Option<i32>, item_file: bool) -> i32 {
block.or(item).unwrap_or(if item_file { 65535 } else { 0 })
}
/// The block items' `currentRenderedItemId`: for each block's default state, the item it gives
/// (`StateFacts::item`) takes the state's id in `ids`, or 0. A block named as its item wins over
/// another block giving that item.
fn block_item_ids(
facts: &horizon_client::blocks::BlockFacts,
registry: &horizon_world::registry::BlockRegistry,
ids: &BlockIds,
) -> HashMap<String, i32> {
let mut out = HashMap::new();
for (i, state) in facts.states.iter().enumerate() {
let id = horizon_world::registry::BlockStateId(i as u32);
if &*state.item == "minecraft:air" || registry.default_state(&state.name) != Some(id) {
continue;
}
let value = registry
.state(id)
.and_then(|s| ids.id(&s.name, |k| s.properties.get(k).map(String::as_str)))
.unwrap_or(0);
if state.item == state.name {
out.insert(state.item.to_string(), value);
} else {
out.entry(state.item.to_string()).or_insert(value);
}
}
out
}
/// Spans for a native mod (`native_render::RenderNeeds::entity_spans`), without a pack.
fn native_spans() -> bool {
horizon_client::gfx::native_render::render_needs().entity_spans
@@ -826,6 +884,27 @@ mod emission_tests {
}
}
#[cfg(test)]
mod rendered_item_tests {
use super::rendered_item_id;
/// Iris 1.11.7's values (probe packs of ours): a block item reads its block's id, and 0 when
/// `block.properties` lacks it even if `item.properties` names it (oak planks as item 15);
/// other items read their `item.properties` id, or 65535 when the file lacks them, and 0
/// when the pack has no such file.
#[test]
fn a_held_item_reads_its_block_id_or_item_id() {
// Stone: block 21 and item 12.
assert_eq!(rendered_item_id(Some(21), Some(12), true), 21);
assert_eq!(rendered_item_id(Some(0), Some(15), true), 0);
assert_eq!(rendered_item_id(Some(0), None, true), 0);
assert_eq!(rendered_item_id(Some(0), None, false), 0);
assert_eq!(rendered_item_id(None, Some(11), true), 11);
assert_eq!(rendered_item_id(None, None, true), 65535);
assert_eq!(rendered_item_id(None, None, false), 0);
}
}
#[cfg(test)]
mod native_pack_tests {
use super::{active_pack, pack_draws_flat};
+8
View File
@@ -405,6 +405,10 @@ impl<'a> Split<'a> {
batching,
self.host.settings.horizon.wide_frame(),
shader_pack,
(
self.host.settings.file.int("textureFiltering"),
self.host.settings.file.int("maxAnisotropyBit"),
),
);
let renderer = match built {
Ok(r) => r,
@@ -483,6 +487,10 @@ impl<'a> Split<'a> {
batching,
self.host.settings.horizon.wide_frame(),
shader_pack,
(
self.host.settings.file.int("textureFiltering"),
self.host.settings.file.int("maxAnisotropyBit"),
),
);
let renderer = match built {
Ok(r) => r,
@@ -476,6 +476,10 @@ impl Split<'_> {
host.settings.horizon.wide_frame(),
// The pack its own game loads, from its profile.
crate::game::shader_packs::picked_pack_draws_flat(&guest.settings),
(
guest.settings.file.int("textureFiltering"),
guest.settings.file.int("maxAnisotropyBit"),
),
)?);
guest.window = Some(window);
guest.world_renderer_ready = true;
@@ -712,13 +712,18 @@ pub(super) fn make_streaming(
// even when automatic structure starts are disabled.
let automatic_structures =
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
let flat_structures = (dim == Dim::Overworld)
let flat_settings = (dim == Dim::Overworld)
.then(|| horizon_client::save::world_gen_settings(world_root))
.flatten()
.and_then(|s| horizon_client::flatgen::FlatTerrain::structure_settings(&s));
.and_then(|s| horizon_client::flatgen::FlatTerrain::pipeline_settings(&s));
// `adjustGenerationSettings`: a superflat world with features or lakes is decorated
// whether or not its structures generate.
let flat_decorates = flat_settings
.as_ref()
.is_some_and(|s| s.features || s.lakes);
// A generator that cannot be built (a layer block the registry lacks, a level shorter
// than the noise) leaves the world on its flat ground, as it was before.
let flat_vanilla = flat_structures.and_then(|settings| {
let flat_vanilla = flat_settings.and_then(|settings| {
let overrides =
world_worldgen_overrides(world_root, &options.resources, &biomes, enabled_features);
match VanillaTerrain::for_flat(
@@ -735,7 +740,7 @@ pub(super) fn make_streaming(
Some(Arc::new(terrain))
}
Err(e) => {
log::warn!("Horizon: the superflat structures are unavailable: {e}");
log::warn!("Horizon: the superflat structures and features are unavailable: {e}");
None
}
}
@@ -753,10 +758,12 @@ pub(super) fn make_streaming(
)
} else if let Some(inner) = flat_vanilla
.as_ref()
.filter(|_| automatic_structures)
.filter(|_| automatic_structures || flat_decorates)
.cloned()
{
inner.search_rings_behind();
if automatic_structures {
inner.search_rings_behind();
}
inner.set_reference_file(world_root.join("data").join(format!(
"horizon_structure_references_{}.json",
dim.id().replace(':', "_")
@@ -779,8 +786,8 @@ pub(super) fn make_streaming(
res.threads(),
)
} else if let Some(inner) = flat {
// Keep the existing flat terrain/decorations path when automatic
// structures are off; only manual commands use the locator.
// A superflat world that neither decorates nor generates structures: its
// layers alone. Only manual commands use the locator.
locator = flat_vanilla;
Streaming::new(
Arc::new(Persistent {
+36 -1
View File
@@ -7,6 +7,9 @@
//! them: [`gui`] into the HUD batch with the GUI item lighting, [`WorldSink`]
//! into entity vertices with the level's lighting.
pub mod cit;
mod glint_atlas;
#[cfg(test)]
mod glint_tests;
pub mod gui;
mod special;
@@ -45,6 +48,8 @@ pub struct ItemQuad {
/// The model UVs `GLINT_TEXTURING` scrolls for the glint (vanilla's UV0,
/// or UV3 for a special model).
pub foil_uv: [[f32; 2]; 4],
/// Standard GUI glint uses the vanilla items atlas; CIT keeps sprite-local UVs.
pub gui_glint_uv: Option<[[f32; 2]; 4]>,
pub normal: Vec3,
pub color: u32,
pub foil: Foil,
@@ -422,7 +427,10 @@ fn submit_quads(
FoilType::Standard => Foil::Standard,
FoilType::Special => Foil::Special,
};
let _ = display;
let atlas = renderer
.glint_atlas
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut normals = FaceNormals::new(m);
// Quads of one layer mostly share a sprite: look its rect and its
// translucency up once.
@@ -480,6 +488,12 @@ fn submit_quads(
pos,
uv,
foil_uv: q.uv,
gui_glint_uv: (display == DisplayContext::Gui && foil == Foil::Standard)
.then(|| atlas.get(sprite_name(&q.material.sprite)))
.flatten()
.map(|r| {
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v])
}),
normal: normals.face(q.facing),
color: argb(color),
foil,
@@ -631,6 +645,7 @@ pub struct ItemRenderer {
/// The enabled packs' OptiFine CIT rules and their swapped bakes; `None`
/// without rules.
pub cit: Option<cit::CitModels>,
glint_atlas: Mutex<HashMap<String, Rect>>,
}
/// Every `items/**/*.json` client item definition, by identifier. Through
@@ -663,7 +678,26 @@ fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
out
}
fn glint_padding(filtering: i32, bit: i32) -> usize {
if filtering == 2 {
bit.wrapping_sub(1).clamp(0, 4) as usize
} else {
0
}
}
impl ItemRenderer {
/// Prepare GUI glint after the device exists; propagate packing failures to pack rollback.
pub fn prepare_glint_atlas(&self, maximum: u32, filtering: (i32, i32)) -> crate::Result<()> {
let padding = glint_padding(filtering.0, filtering.1);
let sprites = glint_atlas::load(&self.sprites.root, 1 << padding, maximum)?;
*self
.glint_atlas
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = sprites;
Ok(())
}
/// Load item definitions, item prototypes (`items.json`), every model,
/// the special renderers' entity models and the grass colormap.
pub fn load(root: &Path) -> crate::Result<Self> {
@@ -737,6 +771,7 @@ impl ItemRenderer {
})
.unwrap_or_default();
Ok(Self {
glint_atlas: Mutex::default(),
item_ids,
models,
library,
@@ -0,0 +1,234 @@
//! Vanilla item-atlas coordinates for GUI glint; the HUD's unrelated packing cannot supply them.
use crate::{atlas_sources, hud::Rect};
use horizon_assets::animation::AnimationMetadata;
use std::{collections::HashMap, path::Path};
type Entry = (String, u32, u32);
/// `SpriteSourceList.list` and `SpriteResourceLoader.loadSprite` for the non-mipmapped ITEMS atlas.
pub(super) fn load(
root: &Path,
padding: u32,
maximum: u32,
) -> crate::Result<HashMap<String, Rect>> {
let sources = atlas_sources::atlases(root);
let sources = sources
.get("minecraft:items")
.map(Vec::as_slice)
.unwrap_or(&[]);
let textures = atlas_sources::list_textures(root);
let decode = |name: &str, dir: &str| {
crate::hud::decode(&atlas_sources::texture_path(root, name, dir)).ok()
};
let mut entries = vec![("minecraft:missingno".into(), 16, 16)];
let mut names = Vec::new();
for (id, origin) in atlas_sources::list(sources, &textures) {
names.push(id.clone());
let dimensions = match &origin {
atlas_sources::SpriteOrigin::File(texture) => (|| {
let (w, h, _) = decode(texture, "textures")?;
let path = atlas_sources::texture_path(root, texture, "textures")
.with_extension("png.mcmeta");
let metadata = horizon_core::prepared_files::read_to_string(&path)
.ok()
.map(|text| serde_json::from_str::<serde_json::Value>(&text))
.transpose()
.ok()?;
if let Some(metadata) = &metadata {
metadata.as_object()?;
if let Some(texture) = metadata.get("texture") {
texture_metadata(texture)?;
}
}
let size = match metadata.as_ref().and_then(|v| v.get("animation")) {
Some(animation) => AnimationMetadata::from_json(&animation.to_string())
.ok()?
.frame_size(w as i32, h as i32)
.map(|x| x as u32),
None => [w, h],
};
(size[0] > 0
&& size[1] > 0
&& w.is_multiple_of(size[0])
&& h.is_multiple_of(size[1]))
.then_some((size[0], size[1]))
})(),
// Generated sprites have empty resource metadata, so their whole image is one frame.
_ => atlas_sources::generate(&origin, &decode).map(|(w, h, _)| (w, h)),
};
if let Some((w, h)) = dimensions {
entries.push((id, w, h));
}
}
let mut sprites = stitch(&entries, padding, maximum).ok_or_else(|| {
std::io::Error::other(format!(
"item atlas does not fit texture limit {maximum} with padding {padding}"
))
})?;
// TextureAtlas.getSprite returns missingno for rejected or unplaced source sprites.
if let Some(missing) = sprites.get("missingno").copied() {
for name in names {
sprites
.entry(super::sprite_name(&name).to_owned())
.or_insert(missing);
}
}
Ok(sprites)
}
/// `TextureMetadataSection.CODEC`: invalid fields reject the sprite, even without mipmaps.
fn texture_metadata(section: &serde_json::Value) -> Option<()> {
let fields = section.as_object()?;
for (name, value) in fields.iter().filter(|(_, value)| !value.is_null()) {
let valid = match name.as_str() {
"blur" | "clamp" => value.is_boolean(),
"mipmap_strategy" => matches!(
value.as_str(),
Some("auto" | "mean" | "cutout" | "strict_cutout" | "dark_cutout")
),
"alpha_cutoff_bias" => value.is_number(),
_ => true,
};
if !valid {
return None;
}
}
Some(())
}
#[derive(Default)]
struct Region {
x: u32,
y: u32,
w: u32,
h: u32,
entry: Option<usize>,
children: Vec<Region>,
}
impl Region {
fn new(x: u32, y: u32, w: u32, h: u32) -> Self {
Self {
x,
y,
w,
h,
..Self::default()
}
}
/// `Stitcher.Region.add`: first fitting child, with vanilla's split order and no rotation.
fn add(&mut self, index: usize, w: u32, h: u32) -> bool {
if self.entry.is_some() || w > self.w || h > self.h {
return false;
}
if w == self.w && h == self.h {
self.entry = Some(index);
return true;
}
if self.children.is_empty() {
let (dw, dh) = (self.w - w, self.h - h);
self.children.push(Self::new(self.x, self.y, w, h));
if dw > 0 && dh > 0 {
if self.h.max(dw) >= self.w.max(dh) {
self.children.push(Self::new(self.x, self.y + h, w, dh));
self.children
.push(Self::new(self.x + w, self.y, dw, self.h));
} else {
self.children.push(Self::new(self.x + w, self.y, dw, h));
self.children
.push(Self::new(self.x, self.y + h, self.w, dh));
}
} else if dw == 0 {
self.children.push(Self::new(self.x, self.y + h, w, dh));
} else {
self.children.push(Self::new(self.x + w, self.y, dw, h));
}
}
self.children.iter_mut().any(|r| r.add(index, w, h))
}
fn gather(
&self,
entries: &[Entry],
size: [u32; 2],
padding: u32,
out: &mut HashMap<String, Rect>,
) {
if let Some(i) = self.entry {
let (name, w, h) = &entries[i];
let (x, y) = (self.x + padding, self.y + padding);
out.insert(
super::sprite_name(name).to_owned(),
Rect {
u0: x as f32 / size[0] as f32,
v0: y as f32 / size[1] as f32,
u1: (x + w) as f32 / size[0] as f32,
v1: (y + h) as f32 / size[1] as f32,
w: *w,
h: *h,
},
);
}
for child in &self.children {
child.gather(entries, size, padding, out);
}
}
}
/// `Stitcher.stitch/expand`: mip level zero, padding from the filtering option.
fn stitch(entries: &[Entry], padding: u32, maximum: u32) -> Option<HashMap<String, Rect>> {
let mut order: Vec<_> = entries
.iter()
.enumerate()
.map(|(i, (name, w, h))| {
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
(i, w + padding * 2, h + padding * 2, path, ns)
})
.collect();
order.sort_by(|a, b| {
b.2.cmp(&a.2)
.then(b.1.cmp(&a.1))
.then(a.3.cmp(b.3))
.then(a.4.cmp(b.4))
});
let mut regions: Vec<Region> = Vec::new();
let (mut width, mut height) = (0u32, 0u32);
let pow2 = |x: u32| if x == 0 { 0 } else { x.next_power_of_two() };
for (index, w, h, _, _) in order {
if regions.iter_mut().any(|r| r.add(index, w, h)) {
continue;
}
let (next_w, next_h) = (pow2(width.checked_add(w)?), pow2(height.checked_add(h)?));
let (can_w, can_h) = (next_w <= maximum, next_h <= maximum);
if !can_w && !can_h {
return None;
}
let (grow_w, grow_h) = (
can_w && pow2(width) != next_w,
can_h && pow2(height) != next_h,
);
let horizontal = if grow_w != grow_h {
grow_w
} else {
can_w && pow2(width) <= pow2(height)
};
let mut region = if horizontal {
if height == 0 {
height = next_h;
}
let r = Region::new(width, 0, next_w - width, height);
width = next_w;
r
} else {
let r = Region::new(0, height, width, next_h - height);
height = next_h;
r
};
// Pinned expand ignores add's result for a sprite wider than the device limit.
region.add(index, w, h);
regions.push(region);
}
let mut out = HashMap::new();
for r in regions {
r.gather(entries, [width, height], padding, &mut out);
}
Some(out)
}
@@ -0,0 +1,526 @@
use super::*;
fn png(path: &Path, w: u32, h: u32, color: [u8; 4]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&color.repeat((w * h) as usize))
.unwrap();
writer.finish().unwrap();
}
fn fixture(name: &str) -> PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-glint-5851-{name}-{}", std::process::id()));
let write = |name: &str, data: &[u8]| {
let path = root.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, data).unwrap();
};
write("items.json", b"{}");
write(
"assets/minecraft/atlases/items.json",
br#"{"sources":[
{"type":"directory","source":"item","prefix":"item/"},
{"type":"single","resource":"test:source","sprite":"test:item/enchanted_book"},
{"type":"unstitch","resource":"test:sheet","divisor_x":2,"divisor_y":1,
"regions":[{"sprite":"minecraft:item/compass_00","x":1,"y":0,"width":1,"height":1}]}]}"#,
);
for (name, w, h) in [
("minecraft/item/enchanted_book", 16, 16),
("minecraft/item/book", 8, 16),
("minecraft/item/diamond_sword", 32, 16),
("minecraft/item/clock_00", 16, 64),
("test/source", 16, 16),
("test/sheet", 16, 16),
] {
let (namespace, path) = name.split_once('/').unwrap();
png(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
w,
h,
[255; 4],
);
}
write(
"assets/minecraft/textures/item/clock_00.png.mcmeta",
br#"{"animation":{"width":16,"height":16}}"#,
);
root
}
fn state(sprite: &str, foil: FoilType, display: DisplayContext) -> ItemRenderState {
let material = horizon_assets::model::Material {
sprite: sprite.into(),
force_translucent: false,
};
let baked = BakedItemModel {
quads: vec![horizon_assets::bake::ModelQuad {
positions: [
[-0.5, 0.5, 0.0],
[-0.5, -0.5, 0.0],
[0.5, -0.5, 0.0],
[0.5, 0.5, 0.0],
],
uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
facing: Direction::South,
cull: None,
material: material.clone(),
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
}],
uses_block_light: false,
particle: material,
transforms: Default::default(),
};
ItemRenderState {
display,
layers: vec![LayerRender {
geometry: LayerGeometry::Quads(Arc::new(baked)),
tints: vec![],
foil,
matrix: Mat4::IDENTITY,
particle: sprite.into(),
uses_block_light: false,
extents: Arc::new(vec![]),
}],
oversized_in_gui: false,
animated: false,
cit_glint: None,
hdr_tier: 0,
hdr_light: 0,
}
}
#[test]
fn gui_glint_matches_pinned_vanilla_atlas_input() {
let root = fixture("gui");
let renderer = ItemRenderer::load(&root).unwrap();
renderer.prepare_glint_atlas(16384, (0, 2)).unwrap();
// The real vanilla oracle also checks a rectangular texture, an animation frame,
// an unstitch source and an equal-sized sprite in a second namespace.
for (name, x, y, w, h) in [
("item/enchanted_book", 1, 33, 16, 16),
("item/book", 53, 1, 8, 16),
("item/diamond_sword", 1, 1, 32, 16),
("item/clock_00", 35, 1, 16, 16),
("item/compass_00", 55, 33, 8, 16),
("test:item/enchanted_book", 19, 33, 16, 16),
("missingno", 37, 33, 16, 16),
] {
let r = renderer.glint_atlas.lock().unwrap()[name];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[x, y, x + w, y + h].map(|n| n as f32 / 64.0)
);
}
let sprites = HashMap::from([
(
"item/enchanted_book".into(),
Rect {
u0: 0.2,
v0: 0.3,
u1: 0.4,
v1: 0.5,
w: 16,
h: 16,
},
),
(
"misc/enchanted_glint_item".into(),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 128,
h: 128,
},
),
]);
let render = |foil| {
let mut batch = crate::hud::HudBatch::new([64, 64]);
gui::draw(
&state("minecraft:item/enchanted_book", foil, DisplayContext::Gui),
&renderer,
&sprites,
&NoHost,
&mut batch,
0.0,
0.0,
0.0,
0.0,
0.75,
);
batch
};
let batch = render(FoilType::Standard);
// Real 26.3 SpriteSourceList + SpriteResourceLoader + Stitcher + JOML, over this fixture:
// atlas 64x64, book rectangle (1,33)..(17,49). HUD packing above deliberately differs.
let expected = [
[-0.5931978, 4.084038],
[-0.94049406, 6.0536532],
[1.0291214, 6.4009495],
[1.3764176, 4.431334],
];
let mut seen = [false; 4];
for vertex in batch.vertices.iter().filter(|v| v.uv[0] >= 2.0) {
let index = match vertex.pos {
[0.0, 0.0] => 0,
[0.0, 16.0] => 1,
[16.0, 16.0] => 2,
[16.0, 0.0] => 3,
p => panic!("unexpected corner {p:?}"),
};
for (got, want) in vertex.glint_uv.into_iter().zip(expected[index]) {
assert!((got - want).abs() < 1e-5, "glint {got} != vanilla {want}");
}
seen[index] = true;
}
assert!(seen.into_iter().all(|x| x));
let plain = render(FoilType::None);
let base: Vec<_> = batch
.vertices
.iter()
.filter(|v| v.uv[0] < 2.0)
.map(|v| (v.pos, v.uv, v.color))
.collect();
assert_eq!(
base,
plain
.vertices
.iter()
.map(|v| (v.pos, v.uv, v.color))
.collect::<Vec<_>>()
);
for (display, foil) in [
(DisplayContext::Ground, FoilType::Standard),
(DisplayContext::FirstPersonRightHand, FoilType::Standard),
(DisplayContext::Gui, FoilType::Special),
] {
let mut quads = Vec::new();
state("minecraft:item/enchanted_book", foil, display).submit(
&renderer,
&sprites,
&NoHost,
Mat4::IDENTITY,
&mut quads,
);
assert_eq!(quads.len(), 1);
assert!(quads[0].gui_glint_uv.is_none());
assert_eq!(
quads[0].foil_uv,
[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]
);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn glint_atlas_boundaries() {
let root = fixture("boundary");
// Pinned vanilla over the same generated fixture; padding 1,2,4,8,16.
for (i, (width, height, x, y)) in [
(64, 64, 1, 33),
(128, 64, 2, 34),
(128, 64, 4, 36),
(128, 64, 40, 40),
(256, 128, 16, 80),
]
.into_iter()
.enumerate()
{
let atlas = glint_atlas::load(&root, 1 << i, 16384).unwrap();
let r = atlas["item/enchanted_book"];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[
x as f32 / width as f32,
y as f32 / height as f32,
(x + 16) as f32 / width as f32,
(y + 16) as f32 / height as f32
]
);
}
png(
&root.join("assets/minecraft/textures/item/wide.png"),
16384,
1,
[255; 4],
);
let atlas = glint_atlas::load(&root, 1, 32768).unwrap();
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 32768.0, 33.0 / 64.0, 17.0 / 32768.0, 49.0 / 64.0]
);
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 64.0, 33.0 / 128.0, 17.0 / 64.0, 49.0 / 128.0]
);
assert_eq!(atlas["item/wide"].u0, atlas["missingno"].u0);
assert!(glint_atlas::load(&root, 1, 16).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn invalid_and_generated_sprites_match_vanilla() {
let root = fixture("metadata");
for name in ["invalid_size", "invalid_metadata", "not_animated"] {
png(
&root.join(format!("assets/minecraft/textures/item/{name}.png")),
16,
32,
[255; 4],
);
}
for (name, meta) in [
("invalid_size", r#"{"animation":{"width":9,"height":16}}"#),
("invalid_metadata", r#"{"animation":{"frames":"bad"}}"#),
("not_animated", r#"{"texture":{"blur":true}}"#),
] {
fs::write(
root.join(format!("assets/minecraft/textures/item/{name}.png.mcmeta")),
meta,
)
.unwrap();
}
let path = root.join("assets/minecraft/atlases/items.json");
let mut atlas: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
atlas["sources"].as_array_mut().unwrap().extend([
serde_json::json!({"type":"single","resource":"test:absent","sprite":"test:missing_alias"}),
serde_json::json!({"type":"paletted_permutations","textures":["test:trim"],"palette_key":"test:key","permutations":{"red":"test:red"}}),
]);
fs::write(path, atlas.to_string()).unwrap();
for (name, w, h, color) in [
("trim", 8, 16, [255; 4]),
("palettes/key", 1, 1, [255; 4]),
("palettes/red", 1, 1, [255, 0, 0, 255]),
] {
png(
&root.join(format!("assets/test/textures/{name}.png")),
w,
h,
color,
);
}
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
// Real SpriteResourceLoader rejects both invalid animations; TextureAtlas resolves missingno.
for name in ["item/invalid_size", "item/invalid_metadata"] {
let r = atlas[name];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[103.0 / 128.0, 1.0 / 64.0, 119.0 / 128.0, 17.0 / 64.0]
);
}
let r = atlas["item/not_animated"];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[1.0 / 128.0, 1.0 / 64.0, 17.0 / 128.0, 33.0 / 64.0]
);
let r = atlas["test:trim_red"];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[19.0 / 128.0, 37.0 / 64.0, 27.0 / 128.0, 53.0 / 64.0]
);
assert!(!atlas.contains_key("test:missing_alias"));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn invalid_texture_metadata_does_not_move_book_glint() {
let root = fixture("texture-metadata");
let image = root.join("assets/minecraft/textures/item/large.png");
png(&image, 512, 256, [255; 4]);
let metadata = image.with_extension("png.mcmeta");
for invalid in [
r#"{"texture":[]}"#,
r#"{"texture":null}"#,
r#"{"texture":{"mipmap_strategy":"issue5851_invalid"}}"#,
r#"{"texture":{"blur":"true"}}"#,
r#"{"texture":{"clamp":1}}"#,
r#"{"texture":{"alpha_cutoff_bias":"0.5"}}"#,
] {
fs::write(&metadata, invalid).unwrap();
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
let rejected = atlas["item/large"];
let missing = atlas["missingno"];
assert_eq!(
[rejected.u0, rejected.v0, rejected.u1, rejected.v1],
[missing.u0, missing.v0, missing.u1, missing.v1],
"{invalid}"
);
// Existing real-vanilla fixture: rejecting the extra image leaves its book unchanged.
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 64.0, 33.0 / 64.0, 17.0 / 64.0, 49.0 / 64.0],
"{invalid}"
);
}
for strategy in ["auto", "mean", "cutout", "strict_cutout", "dark_cutout"] {
fs::write(
&metadata,
serde_json::json!({"texture":{"blur":true,"clamp":false,
"mipmap_strategy":strategy,"alpha_cutoff_bias":0.5}})
.to_string(),
)
.unwrap();
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
assert_eq!((atlas["item/large"].w, atlas["item/large"].h), (512, 256));
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn glint_atlas_reads_prepared_images_and_metadata_together() {
let root = fixture("prepared-metadata");
let image = root.join("assets/minecraft/textures/item/large.png");
png(&image, 512, 256, [255; 4]);
let metadata = image.with_extension("png.mcmeta");
fs::write(&metadata, r#"{"texture":[]}"#).unwrap();
fs::write(root.join("horizon-resources.json"), "{}").unwrap();
horizon_core::prepared_files::write(&root).unwrap();
// The renderer reads the prepared snapshot, even if loose files now differ.
fs::write(&metadata, r#"{"texture":{"blur":true}}"#).unwrap();
png(
&root.join("assets/minecraft/textures/item/enchanted_book.png"),
256,
256,
[0, 255, 0, 255],
);
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
assert_eq!(atlas["item/large"].u0, atlas["missingno"].u0);
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 64.0, 33.0 / 64.0, 17.0 / 64.0, 49.0 / 64.0]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
#[ignore = "requires the player's prepared pinned 26.3 resources in HORIZON_TEST_RESOURCES"]
fn prepared_item_atlas_matches_pinned_vanilla() {
let root =
PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources"));
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
assert_eq!(atlas.len(), 896);
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[437.0 / 1024.0, 163.0 / 512.0, 453.0 / 1024.0, 179.0 / 512.0]
);
let glint = Glint {
millis: 0.0,
speed: 0.0,
alpha: 0.75,
scale: 8.0,
};
let uv = glint.uv([book.u0, book.v0]);
for (got, want) in uv.into_iter().zip([2.919935, 3.101028]) {
assert!((got - want).abs() < 1e-5);
}
}
#[test]
#[ignore = "requires an independently generated pinned vanilla atlas JSON and matching resources"]
fn external_item_atlas_oracle() {
let root = PathBuf::from(std::env::var_os("HORIZON_GLINT_ROOT").expect("resource root"));
let expected: Value = serde_json::from_str(
&fs::read_to_string(
std::env::var_os("HORIZON_GLINT_EXPECTED").expect("vanilla atlas JSON"),
)
.unwrap(),
)
.unwrap();
let bit = std::env::var("HORIZON_GLINT_ANISO_BITS")
.unwrap()
.parse()
.unwrap();
let sprites = glint_atlas::load(
&root,
1 << glint_padding(if bit == 0 { 0 } else { 2 }, bit),
32768,
)
.unwrap();
for (name, value) in expected["sprites"].as_object().unwrap() {
let actual = sprites[sprite_name(name)];
for (got, key) in [actual.u0, actual.v0, actual.u1, actual.v1]
.into_iter()
.zip(["u0", "v0", "u1", "v1"])
{
let want = value[key].as_f64().unwrap() as f32;
assert!(
(got - want).abs() < 1e-7,
"{name} {key}: {got} != vanilla {want}"
);
}
}
if let Some(rejected) = expected["rejected"].as_array() {
let missing = sprites["missingno"];
for name in rejected {
let actual = sprites[sprite_name(name.as_str().unwrap())];
assert_eq!(
[actual.u0, actual.v0, actual.u1, actual.v1],
[missing.u0, missing.v0, missing.u1, missing.v1]
);
}
let renderer = ItemRenderer::load(&root).unwrap();
renderer
.prepare_glint_atlas(32768, (if bit == 0 { 0 } else { 2 }, bit))
.unwrap();
let hud_sprites = HashMap::from([
("item/enchanted_book".into(), missing),
("misc/enchanted_glint_item".into(), missing),
]);
let mut batch = crate::hud::HudBatch::new([64, 64]);
gui::draw(
&state(
"minecraft:item/enchanted_book",
FoilType::Standard,
DisplayContext::Gui,
),
&renderer,
&hud_sprites,
&NoHost,
&mut batch,
0.0,
0.0,
0.0,
0.0,
0.75,
);
let want = &expected["sprites"]["minecraft:item/enchanted_book"]["glint_uv_clock0"];
let mut seen = [false; 4];
for vertex in batch.vertices.iter().filter(|v| v.uv[0] >= 2.0) {
let index = match vertex.pos {
[0.0, 0.0] => 0,
[0.0, 16.0] => 3,
[16.0, 16.0] => 2,
[16.0, 0.0] => 1,
p => panic!("unexpected corner {p:?}"),
};
for (got, value) in vertex
.glint_uv
.into_iter()
.zip(want[index].as_array().unwrap())
{
assert!((got - value.as_f64().unwrap() as f32).abs() < 1e-5);
}
seen[index] = true;
}
assert!(seen.into_iter().all(|v| v));
}
}
+10 -2
View File
@@ -47,6 +47,7 @@ struct Point {
p: Vec2,
uv: [f32; 2],
glint_uv: [f32; 2],
cit_uv: [f32; 2],
}
fn lerp(a: Point, b: Point, t: f32) -> Point {
Point {
@@ -55,6 +56,7 @@ fn lerp(a: Point, b: Point, t: f32) -> Point {
a.uv[0] + (b.uv[0] - a.uv[0]) * t,
a.uv[1] + (b.uv[1] - a.uv[1]) * t,
],
cit_uv: std::array::from_fn(|k| a.cit_uv[k] + (b.cit_uv[k] - a.cit_uv[k]) * t),
glint_uv: [
a.glint_uv[0] + (b.glint_uv[0] - a.glint_uv[0]) * t,
a.glint_uv[1] + (b.glint_uv[1] - a.glint_uv[1]) * t,
@@ -155,7 +157,7 @@ fn emit_cit(batch: &mut HudBatch, fans: &[Vec<Point>], cit: CitDraw) {
}
let glint = &cit.rules[layer.rule].glint;
let color = vertex_color(slot, intensity);
let uv = |v: Point| layer_uv(v.glint_uv, cit.millis, glint.speed, glint.rotation, 8.0);
let uv = |v: Point| layer_uv(v.cit_uv, cit.millis, glint.speed, glint.rotation, 8.0);
let start = batch.vertices.len();
for fan in fans {
for k in 1..fan.len().saturating_sub(1) {
@@ -405,6 +407,7 @@ fn draw_quads_with(
p: v.pos.truncate(),
uv: v.uv,
glint_uv: v.glint_uv,
cit_uv: v.cit_uv,
})
.collect();
let color = lit_color(q, lights);
@@ -585,6 +588,7 @@ mod tests {
pos: [(0.0, y0), (0.0, y1), (8.0, y1), (8.0, y0)].map(|(x, y)| Vec3::new(x, y, z(y))),
uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
foil_uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
gui_glint_uv: None,
normal: Vec3::Z,
color,
foil: Foil::None,
@@ -725,8 +729,10 @@ mod tests {
};
let mut batch = HudBatch::new([320, 240]);
batch.glint_alpha = 0.5;
let mut quad = face(0.0, 8.0, |_| 0.0, 0xFFFF_FFFF);
quad.gui_glint_uv = Some([[42.0, 42.0]; 4]);
draw_quads_with(
&[face(0.0, 8.0, |_| 0.0, 0xFFFF_FFFF)],
&[quad],
(Vec3::Z, Vec3::Z),
&mut batch,
[0.0, 0.0, 16.0, 16.0],
@@ -742,6 +748,7 @@ mod tests {
for v in &batch.vertices[12..] {
// Slot 1 in red; full intensity at half the glint strength in alpha.
assert_eq!(v.color.to_le_bytes(), [1, 0, 0, 128]);
assert_eq!(v.glint_uv, v.pos, "CIT retains the sprite-local UVs");
}
}
@@ -762,6 +769,7 @@ mod tests {
p: Vec2::new(x, y),
uv: [x, y],
glint_uv: [0.0, 0.0],
cit_uv: [0.0, 0.0],
};
let fan = clip_triangle(
[p(-4.0, 0.0), p(8.0, 0.0), p(8.0, 8.0)],
@@ -11,6 +11,7 @@ pub(super) struct Corner {
pub pos: Vec3,
pub uv: [f32; 2],
pub glint_uv: [f32; 2],
pub cit_uv: [f32; 2],
}
pub(super) struct Face {
@@ -39,6 +40,7 @@ fn split(face: Face, distances: &[f32]) -> (Face, Face) {
let corner = Corner {
pos: a.pos.lerp(b.pos, t),
uv: std::array::from_fn(|k| a.uv[k] + (b.uv[k] - a.uv[k]) * t),
cit_uv: std::array::from_fn(|k| a.cit_uv[k] + (b.cit_uv[k] - a.cit_uv[k]) * t),
glint_uv: std::array::from_fn(|k| {
a.glint_uv[k] + (b.glint_uv[k] - a.glint_uv[k]) * t
}),
@@ -77,7 +79,8 @@ pub(super) fn sort(quads: &[ItemQuad]) -> Vec<Face> {
corners: std::array::from_fn::<_, 4, _>(|k| Corner {
pos: q.pos[k],
uv: q.uv[k],
glint_uv: q.foil_uv[k],
glint_uv: q.gui_glint_uv.unwrap_or(q.foil_uv)[k],
cit_uv: q.foil_uv[k],
})
.to_vec(),
})
@@ -136,6 +136,7 @@ fn draw(
]
}),
foil_uv: poly.map(|v| [v[3], v[4]]),
gui_glint_uv: None,
normal,
color,
foil,
+23 -2
View File
@@ -8,7 +8,10 @@ use crate::{containers::BlockEntity, items::ItemStack, world::World};
use horizon_core::random::RandomSource;
use horizon_protocol::{
item::Hashed,
packets::play::{self, Clientbound as C},
packets::{
configuration::CustomPayload,
play::{self, Clientbound as C},
},
};
use horizon_world::nbt::Tag;
use std::collections::HashMap;
@@ -222,7 +225,10 @@ impl Hub {
);
}
/// Open a block container for a guest (ServerPlayer.openMenu).
/// Open a block container for a guest (ServerPlayer.openMenu). `extra`,
/// for a mod menu type, goes first in a `horizon:menu_extra` payload with
/// the window and `pos` (`openMenu` with an extra-data writer).
#[allow(clippy::too_many_arguments)]
pub fn open_container(
&mut self,
conn: u32,
@@ -231,6 +237,7 @@ impl Hub {
pos: (i32, i32, i32),
slots: Slots,
items: Vec<Option<ItemStack>>,
extra: Option<Vec<u8>>,
) {
let Some(kind) = self
.sync
@@ -247,6 +254,20 @@ impl Hub {
let g = &mut self.guests[i];
g.next_window = g.next_window % 100 + 1;
let window = g.next_window;
if let Some(data) = extra {
let message = horizon_protocol::menu_extra::Message {
window,
pos: [pos.0, pos.1, pos.2],
data,
};
match horizon_protocol::menu_extra::encode(&message) {
Ok(data) => g.send(&C::CustomPayload(CustomPayload {
channel: horizon_protocol::menu_extra::CHANNEL.into(),
data,
})),
Err(e) => log::warn!("Horizon: {menu_kind}'s extra data not sent: {e}"),
}
}
g.send(&C::OpenScreen(play::OpenScreen {
container: window,
menu: kind,
+158 -25
View File
@@ -293,12 +293,13 @@ impl SyncData {
registries.push(((*key).to_owned(), e));
}
// The world's packs' dialogs joined the registry above
// (`pack_entries`); their `minecraft:dialog` tags join the root's here,
// for the Quick Actions key and `show_dialog`.
// (`pack_entries`); their tags join the root's here, the dialogs'
// (the Quick Actions key and `show_dialog`) and every other
// registry's a pack has tag files for (a block made climbable).
drop(registries_step);
let tags_step = startup::step("sync.tags");
let mut tags = Self::load_tags(&root, &builtin, &dynamic);
Self::merge_dialog_tags(&mut tags, &root, overlay, &builtin, &dynamic);
Self::merge_pack_tags(&mut tags, &root, overlay, &builtin, &dynamic);
drop(tags_step);
let content_identity = if resources.join(crate::content_identity::MARKER).exists()
|| horizon_protocol::item::custom::entries().next().is_some()
@@ -343,15 +344,19 @@ impl SyncData {
/// MinecraftServer.reloadResources: `/reload` keeps the synchronized
/// registries the server started with (`kept`'s, its
/// LayeredRegistryAccess) and reads the tags against them
/// (TagLoader.loadTagsForExistingRegistries); the rest stays reloaded.
pub fn keep_registries(&mut self, resources: &Path, kept: &SyncData) {
/// (TagLoader.loadTagsForExistingRegistries), the world's packs' as well
/// (`overlay`); the rest stays reloaded.
pub fn keep_registries(
&mut self,
resources: &Path,
kept: &SyncData,
overlay: &horizon_world::loot::PackOverlay,
) {
self.dynamic = kept.dynamic.clone();
self.registries = kept.registries.clone();
self.tags = Self::load_tags(
&resources.join("data/minecraft"),
&self.builtin,
&self.dynamic,
);
let root = resources.join("data/minecraft");
self.tags = Self::load_tags(&root, &self.builtin, &self.dynamic);
Self::merge_pack_tags(&mut self.tags, &root, overlay, &self.builtin, &self.dynamic);
}
/// Whether a synchronized registry holds an entry from a world's pack.
@@ -414,27 +419,63 @@ impl SyncData {
})
.collect()
}
/// The `minecraft:dialog` tags of the pack stack: the resource root's
/// files, then each world pack's in order (`TagLoader`: a file adds its
/// values, or replaces the earlier ones with `"replace": true`), nested
/// tags expanded to the registry's ids.
fn merge_dialog_tags(
/// The tags the server sends (`TagNetworkSerialization.serializeTagsToNetwork`
/// reads the registries' tags as `TagLoader` loaded them from the whole
/// pack stack): every synchronized registry the world's packs have tag
/// files for is merged again over the resource root's, and
/// `minecraft:dialog` always (its tags name the packs' dialogs too).
/// Without packs, the root's tags stand.
fn merge_pack_tags(
tags: &mut UpdateTags,
root: &Path,
overlay: &horizon_world::loot::PackOverlay,
builtin: &Builtin,
dynamic: &Dynamic,
) {
let Some(names) = Self::registry_names(builtin, dynamic, "minecraft:dialog") else {
let mut keys: Vec<&String> = builtin
.registries
.keys()
.chain(dynamic.registries.keys())
.collect();
keys.sort();
keys.dedup();
for key in keys {
// Registries.tagsDirPath: `tags/<path>`, a mod's registry under
// `tags/<namespace>/<path>`.
let kind = match key.split_once(':') {
Some(("minecraft", path)) => path.to_owned(),
Some((ns, path)) => format!("{ns}/{path}"),
None => key.clone(),
};
if key != "minecraft:dialog" && overlay.tag_kind(&kind).next().is_none() {
continue;
}
Self::merge_registry_tags(tags, root, overlay, builtin, dynamic, key, &kind);
}
}
/// One registry's tags of the pack stack: the resource root's files, then
/// each world pack's in order (`TagLoader`: a file adds its values, or
/// replaces the earlier ones with `"replace": true`), nested tags expanded
/// to the registry's ids.
fn merge_registry_tags(
tags: &mut UpdateTags,
root: &Path,
overlay: &horizon_world::loot::PackOverlay,
builtin: &Builtin,
dynamic: &Dynamic,
key: &str,
kind: &str,
) {
let Some(names) = Self::registry_names(builtin, dynamic, key) else {
return;
};
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
let mut own = BTreeMap::new();
walk(&root.join("tags/dialog"), &mut own);
walk(&root.join("tags").join(kind), &mut own);
for (name, value) in own {
Self::add_tag_values(files.entry(qualified(&name)).or_default(), &value);
}
for (id, packs) in overlay.tag_kind("dialog") {
for (id, packs) in overlay.tag_kind(kind) {
for bytes in packs {
if let Ok(value) = serde_json::from_slice::<Value>(bytes) {
Self::add_tag_values(files.entry(id.clone()).or_default(), &value);
@@ -445,16 +486,16 @@ impl SyncData {
for name in files.keys() {
let mut ids = Vec::new();
let mut seen = HashSet::new();
Self::expand_dialog_tag(&files, name, &names, &mut ids, &mut seen);
Self::expand_pack_tag(&files, name, &names, &mut ids, &mut seen);
entries.push(TagEntry {
name: name.clone(),
ids,
});
}
tags.registries.retain(|r| r.registry != "minecraft:dialog");
tags.registries.retain(|r| r.registry != key);
if !entries.is_empty() {
tags.registries.push(RegistryTags {
registry: "minecraft:dialog".into(),
registry: key.into(),
tags: entries,
});
}
@@ -470,8 +511,8 @@ impl SyncData {
}
}
/// A dialog tag's entries as registry ids, nested tags expanded once.
fn expand_dialog_tag(
/// A tag's entries as registry ids, nested tags expanded once.
fn expand_pack_tag(
files: &BTreeMap<String, Vec<Value>>,
tag: &str,
names: &HashMap<&str, i32>,
@@ -490,7 +531,7 @@ impl SyncData {
.or_else(|| value["id"].as_str())
.unwrap_or_default();
if let Some(reference) = id.strip_prefix('#') {
Self::expand_dialog_tag(files, &qualified(reference), names, out, seen);
Self::expand_pack_tag(files, &qualified(reference), names, out, seen);
} else if let Some(&n) = names.get(qualified(id).as_str())
&& !out.contains(&n)
{
@@ -675,7 +716,7 @@ mod tests {
let mut tags = UpdateTags {
registries: Vec::new(),
};
SyncData::merge_dialog_tags(
SyncData::merge_pack_tags(
&mut tags,
Path::new("a-resource-root-without-tags"),
&overlay,
@@ -699,6 +740,98 @@ mod tests {
);
}
/// #7843: the server sends its tags as `TagLoader` merged them from the
/// whole pack stack. A world pack that adds dirt to `climbable` and
/// replaces `mineable/pickaxe` reaches the block tags sent, over the
/// resource root's; a nested tag expands; a registry no pack has tag files
/// for keeps what `load_tags` read.
#[test]
fn world_packs_tags_reach_every_synchronized_registry() {
let root = std::env::temp_dir().join(format!("horizon-pack-tags-{}", std::process::id()));
let block_tags = root.join("tags/block");
std::fs::create_dir_all(block_tags.join("mineable")).unwrap();
std::fs::write(
block_tags.join("climbable.json"),
r#"{"values":["minecraft:ladder"]}"#,
)
.unwrap();
std::fs::write(
block_tags.join("mineable/pickaxe.json"),
r#"{"values":["minecraft:stone"]}"#,
)
.unwrap();
let names = |list: &[&str]| {
horizon_protocol::registry::Ids::from_names(
list.iter().map(|s| (*s).to_owned()).collect(),
)
};
let builtin = Builtin {
registries: HashMap::from([
(
"minecraft:block".to_owned(),
names(&["minecraft:stone", "minecraft:dirt", "minecraft:ladder"]),
),
("minecraft:item".to_owned(), names(&["minecraft:stick"])),
]),
};
let dynamic = Dynamic::default();
let mut tags = SyncData::load_tags(&root, &builtin, &dynamic);
let item_tags = RegistryTags {
registry: "minecraft:item".into(),
tags: vec![TagEntry {
name: "minecraft:sticks".into(),
ids: vec![0],
}],
};
tags.registries.push(item_tags.clone());
let files = vec![
(
"tags/minecraft:block/climbable".to_owned(),
vec![br##"{"values":["minecraft:dirt","#test:more"]}"##.to_vec()],
),
(
"tags/test:block/more".to_owned(),
vec![br#"{"values":["minecraft:stone"]}"#.to_vec()],
),
(
"tags/minecraft:block/mineable/pickaxe".to_owned(),
vec![br#"{"replace":true,"values":["minecraft:dirt"]}"#.to_vec()],
),
];
let overlay = horizon_world::loot::PackOverlay { files: &files };
SyncData::merge_pack_tags(&mut tags, &root, &overlay, &builtin, &dynamic);
let _ = std::fs::remove_dir_all(&root);
let sent = |registry: &str, tag: &str| -> Vec<i32> {
tags.registries
.iter()
.find(|r| r.registry == registry)
.and_then(|r| r.tags.iter().find(|t| t.name == tag))
.map(|t| t.ids.clone())
.unwrap_or_else(|| panic!("{registry} {tag} is sent"))
};
// The root's ladder, then the pack's dirt and its nested tag's stone.
assert_eq!(sent("minecraft:block", "minecraft:climbable"), [2, 1, 0]);
// `"replace": true` drops the root's stone.
assert_eq!(sent("minecraft:block", "minecraft:mineable/pickaxe"), [1]);
assert_eq!(sent("minecraft:block", "test:more"), [0]);
// No pack tags for items: the registry stays as it was read.
let items = tags
.registries
.iter()
.find(|r| r.registry == "minecraft:item")
.unwrap();
assert_eq!(items.tags.len(), 1);
assert_eq!(items.tags[0].ids, item_tags.tags[0].ids);
assert_eq!(
tags.registries
.iter()
.filter(|r| r.registry == "minecraft:block")
.count(),
1,
"one entry per registry"
);
}
/// RegistrySynchronization.packRegistry: a client that knows the core
/// pack gets the vanilla dialogs without data; a pack's dialog has it.
#[test]
+1 -1
View File
@@ -15,7 +15,7 @@ use horizon_world::nbt::{JavaString, Tag};
/// filter took it). A mod may cancel it, which drops it for everybody (the sender is not told, so push
/// a line to `replies` to answer them), or only answer. It cannot change the text: a signed message
/// would no longer verify. Once for each message a player sends, on a server. Commands are
/// `native_player_command`. API @API:player_chat@.
/// `native_player_command`. API 399.
#[inline(never)]
pub fn native_player_chat(
player: u128,
+15 -3
View File
@@ -816,9 +816,21 @@ mod tests {
let client = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
let server = Connection::from_stream(listener.accept().unwrap().0).unwrap();
let served = serve(server, quiet, Duration::from_secs(30));
// More than the socket's buffers take.
let bodies = (0..64).map(|i| body(i, 1 << 20)).collect();
served.send(bodies, Some(End::Disconnect));
// Fill the socket until the outbox waits. A fixed amount may all go
// through: Windows takes a write of any size whole while its buffer
// has room.
let full = || {
std::thread::sleep(Duration::from_millis(50));
served.pending.load(Ordering::Relaxed) > 0
};
for i in 0..=u8::MAX {
if full() && full() && full() {
break;
}
assert!(i < u8::MAX, "the socket took everything");
served.send(vec![body(i, 1 << 20)], None);
}
served.send(Vec::new(), Some(End::Disconnect));
std::thread::sleep(Duration::from_millis(300));
assert!(served.seen.try_recv().is_err(), "still writing");
served.send(Vec::new(), Some(End::Close));
+233 -15
View File
@@ -579,8 +579,39 @@ impl MobTypes {
}
}
pub use horizon_client_core::who::Who;
/// A native damage type's registry name and the tags consumed by mob combat.
///
/// Native code supplies a static name (no interning or leaked runtime strings)
/// and flags matching its data-pack damage tags. Register the same name/data for
/// player damage, death messages and loot predicates. Unset tags are false.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NamedDamage {
pub name: &'static str,
pub tags: u32,
}
impl NamedDamage {
pub const IS_FIRE: u32 = 1 << 0;
pub const BYPASSES_ARMOR: u32 = 1 << 1;
pub const NO_KNOCKBACK: u32 = 1 << 2;
pub const BYPASSES_INVULNERABILITY: u32 = 1 << 3;
pub const BYPASSES_EFFECTS: u32 = 1 << 4;
pub const BYPASSES_RESISTANCE: u32 = 1 << 5;
pub const PANIC_CAUSES: u32 = 1 << 6;
pub const IS_PROJECTILE: u32 = 1 << 7;
pub const IS_EXPLOSION: u32 = 1 << 8;
pub const IS_FALL: u32 = 1 << 9;
pub const fn new(name: &'static str, tags: u32) -> Self {
Self { name, tags }
}
pub const fn has(self, tag: u32) -> bool {
self.tags & tag != 0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DamageKind {
/// A registered native damage type; vanilla kinds keep their existing tags.
Named(NamedDamage),
PlayerAttack,
/// `mace_smash`: a mace hit after a fall (the player attack tags too).
MaceSmash,
@@ -650,14 +681,23 @@ pub enum DamageKind {
impl DamageKind {
/// DamageTypeTags.BYPASSES_INVULNERABILITY
pub fn bypasses_invulnerability(self) -> bool {
if let Self::Named(named) = self {
return named.has(NamedDamage::BYPASSES_INVULNERABILITY);
}
matches!(self, DamageKind::OutOfWorld | DamageKind::GenericKill)
}
/// DamageTypeTags.BYPASSES_EFFECTS
pub fn bypasses_effects(self) -> bool {
if let Self::Named(named) = self {
return named.has(NamedDamage::BYPASSES_EFFECTS);
}
matches!(self, DamageKind::Starve)
}
/// DamageTypeTags.BYPASSES_RESISTANCE
pub fn bypasses_resistance(self) -> bool {
if let Self::Named(named) = self {
return named.has(NamedDamage::BYPASSES_RESISTANCE);
}
matches!(self, DamageKind::OutOfWorld | DamageKind::GenericKill)
}
}
@@ -743,6 +783,9 @@ impl Damage {
}
/// DamageTypeTags.PANIC_CAUSES
pub fn causes_panic(&self) -> bool {
if let DamageKind::Named(named) = self.kind {
return named.has(NamedDamage::PANIC_CAUSES);
}
!matches!(
self.kind,
DamageKind::Fall
@@ -765,6 +808,9 @@ impl Damage {
}
/// DamageTypeTags.BYPASSES_ARMOR
pub fn bypasses_armor(&self) -> bool {
if let DamageKind::Named(named) = self.kind {
return named.has(NamedDamage::BYPASSES_ARMOR);
}
matches!(
self.kind,
DamageKind::Fall
@@ -786,6 +832,9 @@ impl Damage {
)
}
pub fn is_fire(&self) -> bool {
if let DamageKind::Named(named) = self.kind {
return named.has(NamedDamage::IS_FIRE);
}
matches!(
self.kind,
DamageKind::OnFire
@@ -799,6 +848,9 @@ impl Damage {
}
/// DamageTypeTags.NO_KNOCKBACK
pub fn no_knockback(&self) -> bool {
if let DamageKind::Named(named) = self.kind {
return named.has(NamedDamage::NO_KNOCKBACK);
}
matches!(
self.kind,
DamageKind::Explosion
@@ -829,6 +881,9 @@ impl Damage {
)
}
pub fn is_projectile(&self) -> bool {
if let DamageKind::Named(named) = self.kind {
return named.has(NamedDamage::IS_PROJECTILE);
}
matches!(
self.kind,
DamageKind::Arrow
@@ -1273,6 +1328,9 @@ pub struct Mob {
/// `LivingEntity.getAbsorptionAmount`: golden hearts that soak damage before health.
pub absorption: f32,
pub last_damage: Option<(Damage, i64)>,
/// `DamageSource.getDirectEntity` of `last_damage` when [`Damage`] cannot name it (a
/// thrown potion, an area effect cloud): its entity type.
pub last_direct: Option<&'static str>,
/// `LivingEntity.combatTracker`: the hits taken lately.
pub combat: combat::Tracker,
/// Set by `remove`: the mob disappears without dying (conversion, despawn, pickup).
@@ -2771,6 +2829,7 @@ impl Mob {
last_hurt: 0.0,
absorption: 0.0,
last_damage: None,
last_direct: None,
combat: combat::Tracker::default(),
removed: false,
custom_name: None,
@@ -3135,6 +3194,7 @@ impl Mob {
self.extra.in_love = 0;
}
self.last_damage = Some((source, i64::from(self.age)));
self.last_direct = None;
if let Some(who) = source.attacker {
self.ai.last_hurt_by = Some(who);
self.ai.last_hurt_by_time = self.age as i32;
@@ -3206,19 +3266,22 @@ impl Mob {
if equine::is_equine(self.species()) {
equine::after_hurt_server(self);
}
if self.species() == Species::GlowSquid {
// GlowSquid.hurtServer dims the squid only when Squid.hurtServer took the
// hit, which it reports only for a living entity's blow: environmental
// damage (suffocation, a campfire, drying out) leaves the glow alone.
if self.species() == Species::GlowSquid && self.ai.last_hurt_by.is_some() {
aquatic::hurt_server(self);
}
true
}
/// A splash or cloud's harming effect on this mob: the potion's
/// `indirect_magic` is in `#witch_resistant_to`, so a witch takes 15%.
pub fn potion_harm(&self, amount: f32) -> f32 {
if self.species() == Species::Witch {
amount * 0.15
} else {
amount
/// `damage` by a source whose direct entity is `direct` (an entity type id), which `source`
/// cannot carry: `DamageSources.indirectMagic(potion or cloud, owner)`.
pub fn damage_by(&mut self, amount: f32, source: Damage, direct: &'static str) -> bool {
let hurt = self.damage(amount, source);
if hurt {
self.last_direct = Some(direct);
}
hurt
}
/// `Wolf.canArmorAbsorb`: it wears wolf armor and the damage type is not
/// in `#bypasses_wolf_armor` (drowning, magic, thorns, in a wall, out of
@@ -3554,7 +3617,8 @@ impl Mob {
let effect = horizon_client_core::potions::namespaced(effect);
// LivingEntity.addEffect: an effect the mob refuses is never added, so it
// never joins the list the client draws or an effect tick reads.
if !self.can_be_affected(effect) {
// WitherBoss.addEffect returns false for every effect.
if self.species() == Species::Wither || !self.can_be_affected(effect) {
return;
}
// AbsorptionMobEffect.addAttributeModifiers: at least 4 hearts-halves per level.
@@ -4708,6 +4772,41 @@ mod magic_absorb_tests {
m.damage(20.0, own);
assert_eq!(m.health, 10.0);
}
/// `damage_by` with a thrown potion's `indirectMagic(potion, owner)`: the
/// hit keeps the potion as its direct entity and is the thrower's (a
/// player's sets `lastHurtByPlayer`, a dispenser's names nobody); the next
/// hit that names no direct entity forgets it.
#[test]
fn a_potion_hit_keeps_its_direct_entity_and_owner() {
let mut m = Mob::new(1, kind(), Vec3::ZERO, 0.0);
let thrown = Damage::indirect_magic(Some(Who::Player), [0.0, 1.0, 0.0]);
assert!(m.damage_by(1.0, thrown, "minecraft:splash_potion"));
assert_eq!(m.last_direct, Some("minecraft:splash_potion"));
let (last, _) = m.last_damage.unwrap();
assert_eq!(last.kind, DamageKind::IndirectMagic);
assert_eq!(last.attacker, Some(Who::Player));
assert_eq!(m.ai.last_hurt_by_player, 100);
m.invulnerable = 0;
assert!(m.damage(1.0, Damage::of(DamageKind::Generic)));
assert_eq!(m.last_direct, None);
let mut m = Mob::new(1, kind(), Vec3::ZERO, 0.0);
let dispensed = Damage::indirect_magic(None, [0.0, 1.0, 0.0]);
assert!(m.damage_by(1.0, dispensed, "minecraft:area_effect_cloud"));
assert_eq!(m.last_direct, Some("minecraft:area_effect_cloud"));
assert_eq!(m.ai.last_hurt_by_player, 0);
}
/// A harming potion's hit on a witch through `damage_by` is cut to 15%
/// once (`getDamageAfterMagicAbsorb`), not again by the potion code.
#[test]
fn a_witch_takes_15_percent_of_a_potion_hit() {
let mut m = witch();
let thrown = Damage::indirect_magic(Some(Who::Player), [0.0, 1.0, 0.0]);
assert!(m.damage_by(20.0, thrown, "minecraft:splash_potion"));
assert_eq!(m.health, 7.0);
}
}
#[cfg(test)]
@@ -4953,7 +5052,8 @@ mod effect_refusal_tests {
/// effect the mob's type refuses never joins the list, so the cloud on its head,
/// the `/effect` reply and an effect tick never see it. The refused ones are the
/// `ignores_poison_and_regen` tag (which is `#minecraft:undead`), a silverfish's
/// infested and a slime's oozing, plus the five classes that override the rule.
/// infested and a slime's oozing, the classes that override the rule, and the
/// wither, whose `addEffect` refuses every effect.
#[test]
fn an_effect_the_mobs_type_refuses_is_never_added() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -4976,7 +5076,6 @@ mod effect_refusal_tests {
"minecraft:skeleton",
"minecraft:phantom",
"minecraft:zombie_horse",
"minecraft:wither",
] {
for effect in ["minecraft:poison", "minecraft:regeneration"] {
assert_eq!(refused(undead, effect), (false, 0), "{undead} {effect}");
@@ -5010,11 +5109,29 @@ mod effect_refusal_tests {
assert_eq!(refused(spider, "minecraft:poison"), (false, 0), "{spider}");
}
assert_eq!(refused("minecraft:cow", "minecraft:poison"), (true, 1));
// WitherBoss.canBeAffected and WitherSkeleton.canBeAffected: the wither
// effect only, which a zombie takes.
for wither in ["minecraft:wither", "minecraft:wither_skeleton"] {
assert_eq!(refused(wither, "minecraft:wither"), (false, 0), "{wither}");
// WitherBoss.addEffect returns false: the boss takes no effect at all.
for effect in [
"minecraft:wither",
"minecraft:strength",
"minecraft:speed",
"minecraft:poison",
] {
assert_eq!(
refused("minecraft:wither", effect),
(false, 0),
"wither {effect}"
);
}
// WitherSkeleton.canBeAffected refuses the wither effect only, which a
// zombie takes.
assert_eq!(
refused("minecraft:wither_skeleton", "minecraft:wither"),
(false, 0)
);
assert_eq!(
refused("minecraft:wither_skeleton", "minecraft:strength"),
(true, 1)
);
assert_eq!(refused("minecraft:zombie", "minecraft:wither"), (true, 1));
// Parched.canBeAffected: weakness only, slowness takes.
assert_eq!(
@@ -5026,8 +5143,109 @@ mod effect_refusal_tests {
(true, 1)
);
}
/// Squid.hurtServer reports a hit only when a living entity dealt it, so
/// environmental damage leaves a glow squid glowing; a mob's blow dims it.
#[test]
fn only_a_mobs_blow_dims_a_glow_squid() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists() {
return;
}
let types = MobTypes::load_for_server(&root).unwrap();
let mut burned = mob(&types, "minecraft:glow_squid");
burned.health = 10.0;
assert!(burned.damage(1.0, Damage::of(DamageKind::Campfire)));
assert_eq!(aquatic::dark_ticks(&burned), 0);
let mut struck = mob(&types, "minecraft:glow_squid");
struck.health = 10.0;
assert!(struck.damage(1.0, Damage::mob(2, [0.0, 64.0, 0.0])));
assert_eq!(aquatic::dark_ticks(&struck), 100);
}
}
crate::impl_joined!(Mob);
#[cfg(test)]
mod named_damage_tests {
use super::*;
fn pig() -> Mob {
let mut kind = super::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = "minecraft:pig".into();
def.species = Species::Pig;
def.armor = 20.0;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
fn fire(tags: u32) -> Damage {
Damage::of(DamageKind::Named(NamedDamage::new(
"example:fan_fire",
NamedDamage::IS_FIRE | NamedDamage::BYPASSES_ARMOR | tags,
)))
}
#[test]
fn named_fire_uses_normal_immunity_and_protection_without_becoming_on_fire() {
let mut resistant = pig();
resistant.add_effect("minecraft:fire_resistance", -1, 0);
assert!(!resistant.damage(2.0, fire(0)));
assert_eq!((resistant.health, resistant.invulnerable), (10.0, 0));
let mut immune = pig();
Arc::make_mut(&mut immune.kind).flags.fire_immune = true;
assert!(!immune.damage(2.0, fire(0)));
let mut bare = pig();
assert!(bare.damage(2.0, fire(0)));
assert_eq!(bare.health, 8.0); // bypasses the 20 armor points
assert!(bare.velocity.x != 0.0 || bare.velocity.z != 0.0);
assert_eq!(bare.last_damage.unwrap().0.kind, fire(0).kind);
let mut protected = pig();
gear::set(
&mut protected,
gear::CHEST,
vec![("minecraft:fire_protection".into(), 4)],
);
assert!(protected.damage(2.0, fire(0)));
assert!((protected.health - 8.64).abs() < 0.00001);
let mut still = pig();
assert!(still.damage(2.0, fire(NamedDamage::NO_KNOCKBACK)));
assert_eq!(still.velocity, Vec3::ZERO);
}
#[test]
fn named_tags_control_resistance_and_specialist_enchantments() {
let mut mob = pig();
mob.add_effect("minecraft:resistance", -1, 0);
assert!(mob.damage(2.0, fire(0)));
assert!((mob.health - 8.4).abs() < 0.00001);
let mut bypass = pig();
bypass.add_effect("minecraft:resistance", -1, 0);
assert!(bypass.damage(2.0, fire(NamedDamage::BYPASSES_RESISTANCE)));
assert_eq!(bypass.health, 8.0);
let mut no_effects = pig();
no_effects.add_effect("minecraft:resistance", -1, 0);
gear::set(
&mut no_effects,
gear::CHEST,
vec![("minecraft:fire_protection".into(), 4)],
);
assert!(no_effects.damage(2.0, fire(NamedDamage::BYPASSES_EFFECTS)));
assert_eq!(no_effects.health, 8.0);
let mut armor = gear::Gear::default();
armor.slots[gear::FEET] = vec![("minecraft:feather_falling".into(), 4)];
armor.slots[gear::CHEST] = vec![("minecraft:blast_protection".into(), 4)];
let kind = |tags| DamageKind::Named(NamedDamage::new("example:impact", tags));
assert_eq!(gear::protection_points(&armor, kind(0)), 0.0);
assert_eq!(
gear::protection_points(&armor, kind(NamedDamage::IS_FALL)),
12.0
);
assert_eq!(
gear::protection_points(&armor, kind(NamedDamage::IS_EXPLOSION)),
8.0
);
}
}
#[cfg(test)]
mod silent_voice_tests {
use super::*;
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff. Show more