Compare commits

...
Author SHA1 Message Date
Ludovic Marie a3acb5f0d2 Merge pull request #3082 from Domanffe/test/port-harness-symlink-skip
🤖 Agent: read the diff at f4d6f9c0e: test_port_harness skips the reports-link case where the host can't make symlinks. verify green. Queued for the merger.
2026-10-28 20:22:19 +01:00
Ludovic Marie 49b77529dd Merge pull request #2811 from ESHAYAT102/feat/server-delete-confirmation
🤖 Agent: read the diff at 84390f6dc: Delete server in its settings: refused unless the server is in the list, not installed as a service, and its supervisor lock (`.horizon/lock`) can be taken; the folder goes to the system trash, never `rm`. Unit tests cover a live lock, a non-server folder and a failing trash. verify and launcher green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:15 +01:00
Ludovic Marie dafa794cb3 Merge pull request #2762 from ESHAYAT102/fix/stopped-server-settings
🤖 Agent: read the diff at bcdbc252f: the sidebar's stopped-server entry now goes through `open_server`, which loads the server's settings instead of only setting `servers.open`. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 20:22:09 +01:00
Cosmic b7df7bd9e0 Merge pull request #3318 from degepie/fix/accept-26.3-republish-5
Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
2026-10-28 15:17:44 -04:00
Ludovic Marie a1e56a4d1b Merge pull request #3284 from horizon-rust/perf/sign-text-cache
🤖 Agent: verified. 3458 text signs in view, same scene/build profile, Mac mini: 124 → 222 fps (CPU 8.01 → 4.46 ms/frame), no-sign view unchanged (~1620 fps); look unchanged (pixel diff 6/268800, below run-to-run noise). New unit test checks the cached text against the uncached renderer across text/glow/state/font changes; entity batch equivalence test passes. Local: fmt, clippy -D warnings, horizon-client tests, Python tests and the 50 parity harnesses parity.py --changed picks all pass; CI verify green.
2026-10-28 20:03:48 +01:00
Ludovic Marie ab32a64d08 Merge pull request #2791 from levzzz5154/fix/spawn-egg-baby
🤖 Agent: read the diff at 70068076f: spawn-egg offspring rule split into `spawn_egg_is_for` / `spawn_egg_offspring` (`SpawnEggItem.spawnOffspringFromSpawnEgg`), behaviour unchanged, with a test. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:03:44 +01:00
Ludovic Marie f843f28a27 Merge pull request #2748 from levzzz5154/fix/turtle-walk-animation-rate
🤖 Agent: read the diff at 186198afe: test only: the turtle's limb speed follows its walk (`LivingEntity.updateWalkAnimation`, `TurtleMoveControl`). verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 20:01:08 +01:00
Ludovic Marie abcc2e7c80 Merge pull request #3238 from masa-masa-0314/fix/show-dialog-click
🤖 Agent: read the diff at 4d477067c ("Chat and book links: a show_dialog click event opens its dialog") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:01:04 +01:00
Ludovic Marie ac78481794 Merge pull request #3210 from PH4N7OMx/codex/windows-default-browser
🤖 Agent: read the diff at 998c94bee ("fix(windows): open URLs with the registered protocol handler") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:59 +01:00
Ludovic Marie 650601848f Merge pull request #3127 from kacpherek/fix/shulker-bullet-model
🤖 Agent: read the diff at 4a18a4bde ("Shulker bullets are drawn as the spinning bullet model, not a flat spr") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:55 +01:00
Ludovic Marie f9ea299952 Merge pull request #3124 from kacpherek/fix/mob-crowd-push
🤖 Agent: read the diff at f6b467eba ("Crowded mobs take cramming damage, as vanilla") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 20:00:50 +01:00
Ludovic Marie b6eb028bb6 Merge pull request #3100 from Domanffe/perf/name-tags-in-range-first
🤖 Agent: read the diff at 5f8afb17f ("Name tags: copy a custom name only inside the tag's range") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:52 +01:00
Ludovic Marie 9dffa8a2a9 Merge pull request #3097 from Domanffe/perf/effect-panel-strings
🤖 Agent: read the diff at f7bb1ceb7 ("Effect panels: borrow the name and reuse the icon key") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:48 +01:00
Ludovic Marie cd89bc6dc1 Merge pull request #3095 from Domanffe/perf/server-row-sprite-path
🤖 Agent: read the diff at b9494af97 ("Server list: build each row's sprite key once") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:44 +01:00
Ludovic Marie 8a863ad378 Merge pull request #3092 from Domanffe/perf/creative-tab-by-reference
🤖 Agent: read the diff at d1ead6a5f ("Creative screen: draw the selected tab from a reference") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:40 +01:00
Ludovic Marie a27561da88 Merge pull request #3086 from Domanffe/perf/draw-slot-item-by-reference
🤖 Agent: read the diff at 3511d556e ("Inventory draw: a slot is drawn from a reference, not a copy") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:57:36 +01:00
Ludovic Marie ed7213df2e Merge pull request #3083 from Domanffe/perf/hud-paint-reuses-sprite-key
🤖 Agent: read the diff at 4bf633e8a ("HUD paint: build the sprite key in one reused buffer") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:50 +01:00
Ludovic Marie b1b27240d4 Merge pull request #3070 from iLostName/boat-view-clamp-every-turn
🤖 Agent: read the diff at a1b32304a ("boat: the view stops at 105 degrees at every turn, so it doesn't jitte") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:45 +01:00
Ludovic Marie 8a2a31e6a3 Merge pull request #3069 from iLostName/no-hitting-ridden-mob
🤖 Agent: read the diff at 7934a0b18 ("riding: the crosshair skips the mob being ridden, so it can't be hit") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:41 +01:00
Ludovic Marie 0264b39aa6 Merge pull request #3068 from iLostName/creeper-death-message
🤖 Agent: read the diff at 992c63eef ("death message: a creeper's blast names the creeper, not the last mob t") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:37 +01:00
Ludovic Marie 83935015cf Merge pull request #3067 from iLostName/pickup-fills-offhand
🤖 Agent: read the diff at a967ca434 ("item pickup: a stack in the off hand fills before the inventory, as ge") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:54:33 +01:00
Ludovic Marie 506a9c8f01 Merge pull request #3065 from iLostName/f1-only-without-screen
🤖 Agent: read the diff at c0b560527 ("F1 hides the HUD only with no screen open, as key.toggleGui does") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:56 +01:00
Ludovic Marie 475b22ff19 Merge pull request #3063 from iLostName/redstone-ore-lights-on-use
🤖 Agent: read the diff at e06134f71 ("redstone ore: a right-click lights it, as RedStoneOreBlock.useItemOn d") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:51 +01:00
Ludovic Marie 0d1f56e015 Merge pull request #3060 from kacpherek/fix/name-tags-face-front-camera
🤖 Agent: read the diff at c66554126 ("Name tags read the right way in the front third-person view") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:46 +01:00
Ludovic Marie 958c02593c Merge pull request #3056 from kacpherek/fix/spear-charge-while-riding
🤖 Agent: read the diff at 4a1c5fc54 ("Spears: charges hit while riding, and a stab stops the sprint") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:50:42 +01:00
Ludovic Marie a266eee6d2 Merge pull request #3102 from Domanffe/fix/random-sequences-write-failure
🤖 Agent: read the diff at 23d214606: the villagers' random_sequences write logs a warning on failure. verify green. Queued for the merger.
2026-10-28 19:50:37 +01:00
Ludovic Marie 62898b4cf6 Merge pull request #3053 from kacpherek/fix/entity-shadows-no-cutout
🤖 Agent: read the diff at 012a3cf90 ("Draw entity shadows in dim light like vanilla") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:45 +01:00
Ludovic Marie d08e05283a Merge pull request #3038 from hectoreccles/perf/spawn-candidate-buffers
🤖 Agent: read the diff at a48fcf0d1 ("Reuse natural spawn candidate buffers") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:41 +01:00
Ludovic Marie e7a07f88b3 Merge pull request #3035 from hectoreccles/perf/streaming-scan-interval
🤖 Agent: read the diff at 2eb3de274 ("Sweep far chunks every eighth tick") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:36 +01:00
Ludovic Marie 4e59dab892 Merge pull request #3033 from hectoreccles/perf/block-entity-ticker-list
🤖 Agent: read the diff at 67511d974 ("Reuse the block entity ticker list between ticks") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:32 +01:00
Ludovic Marie a1f4841526 Merge pull request #3032 from hectoreccles/perf/hopper-cooldown-first
🤖 Agent: read the diff at df90f62d3 ("Hopper ticks check their block only when they move") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:47:27 +01:00
Ludovic Marie 7dd4b772c9 Merge pull request #2997 from feeldev12/feat/link-prompt-copy
🤖 Agent: read the diff at 5c14d1814 ("fix(client): the link prompt has ConfirmLinkScreen's Copy to Clipboard") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:13 +01:00
Ludovic Marie 03dd78d67b Merge pull request #2994 from kacpherek/fix/particles-sounds-parity
🤖 Agent: read the diff at 0f8e3a9b0 ("Match vanilla mob and arrow sounds: horses, hostile fall and swim, hur") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:09 +01:00
Ludovic Marie 6267cd3e15 Merge pull request #2992 from kacpherek/fix/mob-ai-villager-parity
🤖 Agent: read the diff at efa8df83b ("Villagers hear bells, use bone meal, fetch dropped items and play tag ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:04 +01:00
Ludovic Marie 265640ac18 Merge pull request #2991 from kacpherek/fix/block-behaviour-parity
🤖 Agent: read the diff at ffb659460 ("Match vanilla for eight block behaviours: ender chest, doors, hanging ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:44:00 +01:00
Ludovic Marie f5a3088f0d Merge pull request #2985 from kacpherek/fix/hostile-mob-parity
🤖 Agent: read the diff at f69088012 ("Hostile mobs: hoglin timing, crossbow aim, witch resistance, wither sk") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:43:56 +01:00
Ludovic Marie 3ccbba76df Merge pull request #2984 from kacpherek/fix/passive-mob-parity
🤖 Agent: read the diff at 58422bcc1 ("Passive mobs: sound variants, donkey and mule stats, no mule breeding,") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:39 +01:00
Ludovic Marie c519ff6c56 Merge pull request #2977 from ASOwnerYT/fix/2832-return-to-sender
🤖 Agent: read the diff at c4348be8d ("Return to Sender needs the ghast's own fireball: check player_killed_e") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:34 +01:00
Ludovic Marie 400d15ab3f Merge pull request #2975 from kacpherek/fix/advancement-triggers-small
🤖 Agent: read the diff at c3e19973c ("Fire lightning, spear, nest, shear, allay and sculk advancement trigge") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:29 +01:00
Ludovic Marie d6971605d4 Merge pull request #2957 from kacpherek/fix/smooth-camera-mouse
🤖 Agent: read the diff at 7ab2b20bd ("Let a mouse button toggle the cinematic camera") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:24 +01:00
Ludovic Marie 7dc818eb5b Merge pull request #2948 from kacpherek/fix/hdr-crash-macos
🤖 Agent: read the diff at dddcc9972 ("Fix the crash on every start with HDR on in macOS, and start with HDR ") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:39:20 +01:00
Ludovic Marie 2772738d47 Merge pull request #2938 from ASOwnerYT/fix/2802-projectile-death-message
🤖 Agent: read the diff at 513aed64c ("Name the projectile in the death message when it has no owner") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:20 +01:00
Ludovic Marie 131625c414 Merge pull request #2906 from kacpherek/fix/2904-fullscreen-crash
🤖 Agent: read the diff at 22cab3eeb ("Open windowed after the window system kills the game in fullscreen") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:15 +01:00
Ludovic Marie 478ae35b53 Merge pull request #2880 from leifiyoo/dyed-leather-armour-colour
🤖 Agent: read the diff at 8057d2751 ("Worn dyed leather armour shows its dye colour") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:11 +01:00
Ludovic Marie 5f868011ee Merge pull request #2843 from levzzz5154/fix/breeze-wind-scroll
🤖 Agent: read the diff at f507a067c ("The breeze's wind and the wind charge scroll their textures") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:06 +01:00
Ludovic Marie 67712eeeef Merge pull request #2836 from levzzz5154/fix/spawn-egg-persistence
🤖 Agent: read the diff at 28f906c37 ("A summoned or egg-spawned mob is not persistent") against the 26.3 source it cites; verify green, merges cleanly with main. Proof: cg25 union a051cc285 (local branch cg25/union in t3-ef805fdb: main 6d3489d30 + 38 contributor heads merged cleanly, no fixups): clippy -p horizon-client --all-targets -D warnings clean, horizon-client lib 1105 + bin 133 + integration tests pass, parity --changed 355/355 harnesses PASS on the Mac mini (Cargo.lock changed by #3210, so every harness ran; tab_list passed on its one retry).
2026-10-28 19:35:02 +01:00
Ludovic Marie 5b7d19f02b Merge pull request #3080 from Domanffe/fix/port-regen-python-that-runs
🤖 Agent: read the diff at 93e61ef93: port_regen.python() probes python3/python/py and keeps the first that runs Python 3 (cached), with a test. verify green. Queued for the merger.
2026-10-28 19:30:16 +01:00
Ludovic Marie f66f66c3b4 Merge pull request #2815 from sirrayi/fix/zip-untrusted-sizes
🤖 Agent: read the diff at ce848af06: zip hardening: the raw buffer's capacity is capped at the archive length and inflate at 1 GiB with saturating maths, so a zip64 entry claiming u64::MAX/2 errs instead of aborting; real packs are unchanged (the limit stays size*2+1024 below 1 GiB). Test included. verify green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 18:39:20 +01:00
Ludovic Marie 7cbb8c4506 Merge pull request #2808 from levzzz5154/fix/shulker-body-yaw
🤖 Agent: read the diff at a8903cba0: test only: the shulker's body stays at 0 while its head turns (`ShulkerBodyRotationControl`, `ShulkerLookControl`). verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:28 +01:00
Ludovic Marie 6a05885ee3 Merge pull request #2797 from levzzz5154/fix/screen-cursor-centre
🤖 Agent: read the diff at 1d693cb3c: `MouseHandler.grabMouse`/`releaseMouse`: the cursor is centred only when the grab state changes; fixture test updated. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:22 +01:00
Ludovic Marie c979ce46e9 Merge pull request #2771 from ASOwnerYT/claude/select-world-blank-deletion-332d14
🤖 Agent: read the diff at f10558380: `WorldSelectionList.handleNewLevels`: an emptied list opens Create New World, whose Cancel returns to the title. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:13 +01:00
Ludovic Marie 0c5c38ac9e Merge pull request #2736 from levzzz5154/fix/2273-entity-view-scale
🤖 Agent: read the diff at fa940d5b6: `entity_view_scale` extracted from `Entity.setViewScale` (clamp(renderDistance / 8, 1, 2.5) * entityDistanceScaling) with tests for the 48-block chicken range. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:09 +01:00
Ludovic Marie ae3b9e273f Merge pull request #2732 from levzzz5154/fix/mining-hit-sound
🤖 Agent: read the diff at fcc7a818b: the remote-mining fixture now records that the hit sound plays once at dig start (`ClientLevel.addBreakingBlockEffects`), plus the `dig_sounds` LAN check for #2277. verify green, parity green on this head (merged into main), merges cleanly.
2026-10-28 18:35:04 +01:00
Ludovic Marie d0b6e48921 Merge pull request #3281 from horizon-rust/perf/world-entry-decoration-cache
🤖 Agent: verify green; pr_ready on the Mac mini passes (94 affected harnesses, 93 run and pass incl. the worldgen ones; lan_integration by name only; new test decoration_is_shared_by_biome_source). Through the Singleplayer screen, 2 workers, medians of 5: client joined 0.45 → 0.31 s (create) and 0.46 → 0.31 s (reload); reload first controllable frame 0.60 → 0.42 s.
2026-10-28 18:20:09 +01:00
Ludovic Marie 07e4a86658 Merge pull request #2817 from sirrayi/fix/server-http-line-limit
🤖 Agent: read the diff at d3be8dbf0: horizon-server HTTP API: request and header lines over 8 KiB are a 400 instead of growing without bound; test with a 100 KB line. verify green; no harness reaches it. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:58:46 +01:00
degepieandClaude Opus 5.5 3c98062662 Prepare: accept Mojang's fifth 26.3 republish (5df1795e, index 917ff6d7)
Mojang republished the 26.3 version JSON again. The live copy (5df1795e)
points at asset index 34 917ff6d7; neither was in research-lock.json, so
`horizon-prepare --prepare` from a fresh official-launcher install stops
with "is not a 26.3 version file Horizon knows", and the sounds step warns
about an unknown asset index. Jars, libraries and javaVersion are
unchanged; the index differs from 0c8f43b2 only in 142 minecraft/lang/*
objects and pack.mcmeta (uz_uz moved in the language list).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-28 21:51:58 +05:00
Ludovic Marie e1b2b0b2fd Merge pull request #2742 from levzzz5154/fix/fire-animated-textures
🤖 Agent: read the diff at a77639022: test only: every graphics preset keeps animated textures on (#2064 stays fixed). verify green; parity selects no harness for it. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:47:18 +01:00
levzzzandCodex 9755416a02 Fix Windows path assertions in the launcher toolchain test (#3285)
Compare managed compiler and PATH entries as platform paths, including Windows executable suffixes. Fixes the Windows assertion that blocked the native-capable launcher release.

Validation: pr_ready passed (401 Python and 219 launcher tests), and the release dry run passed tests and packaged smoke checks on Linux, macOS and Windows.

Co-Authored-By: Codex <[email protected]>
2026-10-28 21:42:53 +05:00
Ludovic MarieandClaude Opus 5.5 0281365907 Signs: keep laid-out sign text between frames, batch entity quads in runs
A wall of 3458 text signs ran at 128 fps against 1642 without them: each
frame laid every sign's text out again glyph by glyph (Font::glyph_of was a
quarter of the main thread), then regrouped every entity quad by material
through a per-quad index list.

- block_entity_render: a sign's text vertices are kept relative to its
  block and moved to the camera each frame. They are laid out again when
  the sign data, block state, light, glowing outline distance or font
  (Font::epoch: a new font, a glyph cache reset, the Unicode and Japanese
  options) changes; signs out of view for 100 ticks are dropped.
- gfx/entity_batch: quads are grouped in runs of one material and whole
  runs copied (2-4x faster on model-like input, the same when every quad
  changes material).

Fixes #1461 (the cost per sign; the vanilla text distance of 64 blocks
and the look are unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:08 +01:00
rdbt a5fcb49900 Merge pull request #3262 from Domanffe/fix/chat-log-duplicate
Log a chat message once, as [CHAT]
2026-10-28 16:27:04 +00:00
Ludovic MarieandClaude Opus 5.5 6f6229c2d4 Worldgen: build a biome source's feature decoration once, ahead of the world
`Decoration::new` decodes the placed features of every biome a source can
place: about 140 ms of the ~185 ms `OverworldGenerator::for_world` took on
each world opening (Mac mini). It depends on the worldgen resources and the
biome source alone, not on the seed, so `GeneratorData` now keeps it by
biome source (`decoration_for`, like the ring cache) and every generator
built for that source shares it.

The world screens' preload (#2481) already reads the worldgen bundle while
the player picks or creates a world; it now also builds the Overworld's
decoration (`prepare_decoration`), so opening the world finds it ready.
Reopening a world or returning to a dimension in the same session builds
nothing again either.

Mac mini, --profile fast, offscreen, seed 1234, RD 12, 2 workers, server
thread, through the Singleplayer screen, medians of 5 alternated with main:
  server ready to join: create 0.45 -> 0.31 s, reload 0.46 -> 0.31 s
  first controllable frame: reload 0.60 -> 0.42 s, create 0.85 -> 0.79 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:22:05 +01:00
Ludovic Marie 0ca3a5be98 Merge pull request #3077 from Domanffe/test/parity-reach-posix-fixture
🤖 Agent: read the diff at aef4186de: test_parity_reach's fixture records dependencies with `as_posix()`, as the tool does. verify green. Queued for the merger.
2026-10-28 17:19:37 +01:00
Ludovic Marie 8993772de0 Merge pull request #3078 from Domanffe/fix/port-harness-windows-log-path
🤖 Agent: read the diff at 28e3286e4: port_harness also picks up Windows `C:...x.log` paths from javac's "see ..." lines. verify green. Queued for the merger.
2026-10-28 17:00:51 +01:00
Ludovic Marie 6a261a00a5 Merge pull request #2886 from socksprox/fix/launcher-forget-offline-name
🤖 Agent: read the diff at 5096b4110: launcher offline names: Forget also deletes the name's skin and cape files, async skin picks bail if the name was forgotten meanwhile, account rows load-mutate-save, and "New name" rolls a fresh PlayerNNNNN into every game home. Tests included. verify and launcher green; no harness reaches launcher code. Queued for the merger, which waits for this head's parity run.
2026-10-28 17:00:45 +01:00
Ludovic Marie f8b83eea51 Merge pull request #3250 from horizon-rust/perf/world-entry-pump-between-ticks
🤖 Agent: verify green; pr_ready on the Mac mini passes (34 affected harnesses, 33 run and pass, lan_integration by name: 7 lan_e2e checks incl. spawned/respawn/portals/forced-levels pass). World entry, 2 workers, RD 12, medians of 3: create 8.05 → 3.57 s, reload 3.72 → 1.42 s to all view columns loaded.
2026-10-28 16:54:43 +01:00
Alexander KrylovandHermes Agent 88398e8bda Log a chat message once
`chat_line` logged `[chat]` and then `Chat::add_message` logged `[CHAT]` for
the same message, so every line appeared twice, and the `[chat]` copy slipped
past the `[CHAT]` filter `log_for_report` uses to keep chat out of an attached
log.

Vanilla's `ChatComponent.logChatMessage` writes the one line `add_message`
already writes, so the `chat_line` call is the leftover.

Fixes #3261

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 18:36:21 +03:00
Ludovic Marie 6d3489d30b Merge pull request #3064 from iLostName/trial-spawner-spawn-egg
🤖 Agent: read the diff at 557a82738: a spawn egg on a trial spawner follows `TrialSpawner.overrideEntityToSpawn` -> `FullConfig.overrideEntity` in the 26.3 source: data reset, normal and ominous configs replaced by `Holder.direct(withSpawning(type))` (saved inline through the new `TrialConfig::encode`, `optionalFieldOf` defaults left out), then `setState(INACTIVE)`; plain spawners unchanged. Round-trip test included. verify and parity green on this head. Queued for the merger.
2026-10-28 16:35:54 +01:00
Ludovic MarieandClaude Opus 5.5 7a84c93843 Server: feed the chunk workers between ticks while players' chunks load
A server thread (single player's, a LAN host's, a dedicated server's) ran
`streaming.update` once a tick, and `schedule_generation` keeps at most
three load or generate jobs per worker in flight. Joining a world was
capped at about six columns a tick (120 a second with two workers) while
the workers idled most of each tick. Vanilla's server runs its chunk
tasks while it waits for the next tick (`MinecraftServer.waitUntilNextTick`).

`pump_pregen`, now `pump_chunks`, already did that for a /pregen task and
force-loaded chunks; it now also pumps while the players' chunks are in
flight, except while the server is paused or a respawn search waits for
a chunk (its area is only asked for by the tick's own update).

Mac mini, --profile fast, offscreen, seed 1234, RD 12, server thread,
medians of 3, all 637 view columns in and meshed once:
  2 workers: create 8.05 -> 3.57 s, reload 3.72 -> 1.42 s
  4 workers: create 4.97 -> 2.16 s, reload 2.23 -> 1.27 s

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:26:03 +01:00
Ludovic Marie cba52f4e5d Merge pull request #3218 from horizon-rust/fix-server-junction
🤖 Agent: verified: verify, launcher and parity-scope green; local fmt, clippy -D warnings and launcher tests pass. Fixes #3200. Queued for merge.
2026-10-28 16:04:17 +01:00
masa-masa-0314andZCode 4d477067c3 Chat and books: open a show_dialog click event's dialog
A server may put a show_dialog click event on a chat line or a book
page; ClickEvent.Action.SHOW_DIALOG allows it from a server, and vanilla
opens the dialog through Screen.defaultHandleGameClickEvent. Horizon
dropped the click event while parsing the component (no arm for it in
component.rs, no ClickEvent variant to carry it), so clicking the text
did nothing.

The dialog now survives as ClickEvent::ShowDialog (a registry id or the
inline dialog), parses from click_event, round-trips back out, and both
click handlers (chat and BookViewScreen) open it with show_dialog_ref,
the same path a server's show_dialog packet takes.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 22:52:46 +08:00
Ludovic MarieandClaude Opus 5.5 9f1fa0a0a2 launcher: quote server junction paths and show why mklink failed
Creating a server on Windows failed with a bare "mklink /J failed" (#3200).
servers.rs passed the paths to cmd unquoted, so a user folder with `&`, `^`
or `(` broke cmd's parsing; sources.rs already quoted them. Both now use
one sources::junction, which also puts cmd's own message in the error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 15:15:13 +01:00
Ludovic Marie fe84cc07a1 Merge pull request #3208 from horizon-rust/launcher-sidebar-sponsor
🤖 Agent: verified: verify, launcher and parity-scope green; local cargo fmt, clippy -D warnings and launcher tests pass. Queued for merge.
2026-10-28 14:55:08 +01:00
Ludovic Marie d4e42cf726 Merge pull request #3195 from horizon-rust/feat/launcher-native-mods-window
🤖 Agent: proven with a real launcher GUI run in isolated dirs (Settings → Native mods row, red warning naming veinminer 0.3.0, trust → cached build reused, status line); launcher clippy and 265 tests green; #3194 merged and this now targets main with only its own 5 files. Verify green at 505de825.
2026-10-28 14:51:41 +01:00
PH4N7OMxandOpenAI Codex 998c94bee3 fix(windows): open URLs with the registered protocol handler
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-28 14:46:01 +01:00
Ludovic MarieandClaude Opus 5.5 466512c90e launcher: a Sponsor link in the sidebar footer
The GitHub Sponsors link was only on the patch notes page. Show it beside
Discord and Website in the sidebar footer (heart icon), from links.json's
sponsor entry, translated in fr and ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:44:51 +01:00
Ludovic Marie 4db071e281 Merge pull request #3194 from horizon-rust/feat/launcher-native-mods-page
🤖 Agent: proven with a real launcher run in isolated dirs: registry install of Veinminer's .hnmod into an instance, native-build of that instance's enabled native mods, instance pinned to the build; launcher clippy and tests green after merging main (#3140 and #3189 merged). Verify green at 1f6511d0.
2026-10-28 14:40:43 +01:00
Ludovic Marie 2649c5e506 Merge pull request #3202 from horizon-rust/ci/nightly-cap-12
Nightly: 12 builds a day instead of 6
2026-10-28 14:27:40 +01:00
Ludovic MarieandClaude Opus 5.5 505de825db Merge feat/launcher-native-mods-page (main merged in)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:27:32 +01:00
Ludovic MarieandClaude Opus 5.5 1f6511d0f3 Merge main into feat/launcher-native-mods-page (#3140 and #3189 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:26:02 +01:00
Ludovic MarieandClaude Opus 5.5 1523c118d8 Nightly: 12 builds a day instead of 6
The Team tests fixes on the Nightly and hit the cap of 6 builds a day
(Ludovic, 2026-10-28: raise it to 12).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:21:08 +01:00
Ludovic Marie 9239089b76 Merge pull request #3140 from levzzz5154/feat/native-mod-bundles
🤖 Agent: merging levzzz5154's native-mod registry kind; proven with the real Veinminer .hnmod through registry build, the launcher's install check and a native build that ran (see comment).
2026-10-28 14:20:09 +01:00
Ludovic Marie 281ab9759c Merge pull request #3180 from horizon-rust/refactor/furnace-cooking
🤖 Agent: verify passed at 8b5a27ed3. Move only (crate split B7): the furnace's recipe logic leaves containers as the FurnaceCooking extension trait. Full pr_ready green on this head (1261 tests, parity 69 affected / 0 failed); #2959, #2960 and #3131 still merge cleanly. Ready once parity is green.
2026-10-28 14:13:59 +01:00
Ludovic MarieandClaude Opus 5.5 febad05c78 launcher: French and Russian for the Native mods row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:07:55 +01:00
Ludovic MarieandClaude Opus 5.5 519edfac19 docs: native mods in the launcher window
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:06:21 +01:00
Ludovic Marie 6561df77f6 Merge pull request #3189 from horizon-rust/feat/launcher-native-build
🤖 Agent: merging: native-build compiles horizon-mixins bundles on their pinned Nightly with the launcher's own Rust; proven by building levzzz's Veinminer .hnmod on 83f15fa (580 s cold, 2m46s warm) and /veinminer answering in the built game.
2026-10-28 14:05:32 +01:00
Ludovic MarieandClaude Opus 5.5 cd7f177104 Launcher: a Native mods switch and Build in Settings, with the warning
Settings → Game → Native mods (experimental), off by default. On, Build names the native mods
turned on in the instance Play starts, warns in red that they run with full access to the
computer, and compiles them only from "I trust these mods: build"; the row shows the step while
it runs, the error if it fails, and the build the instance plays. Ported from native-mixins-1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:04:35 +01:00
Ludovic MarieandClaude Opus 5.5 07ab7ccfc1 Merge the native build's measured numbers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 727f6c932a docs/native-mods.md: the measured build times and sizes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:36 +01:00
Ludovic MarieandClaude Opus 5.5 cd1f58ef72 Launcher: native mods from the registry install and build like the others
- native-mod entries (levzzz5154's #3140) are listed as mods and install as
  horizon-native-mods/<id>.hnmod (the folder #3112's inventory reads), turned on in
  horizon-mods.txt, after the .hnmod is checked as the registry checks it.
- native-build with no folder builds the native mods turned on in the instance, and that
  instance then plays the build (the game's own home builds into the Native mods instance).
- Every registry project says who made it; a project not made by Horizon says it is not made
  or endorsed by the Horizon project, with the mod policy's link (playhorizon.dev/mods/policy).
  A native mod says it runs with full access to the computer and which Horizon it builds on.
- Pruning keeps every native build an instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:54:35 +01:00
Ludovic Marie 291aae00e8 Merge pull request #3174 from horizon-rust/ci/nightly-source
🤖 Agent: merging: nightly publishes horizon-source.tar.gz (signed) and tags its commit nightly-source/<date>-<short>; proven by a full dry run of the workflow (run 40686585965) and tests/test_nightly.py.
2026-10-28 13:49:40 +01:00
Ludovic Marie bec8cce82d Merge pull request #3179 from horizon-rust/feat/launcher-rust-toolchain
🤖 Agent: merging: the launcher's portable Rust 1.99.1 (pinned SHA-256 components, own data dir); proven by a real isolated rust install + crate build on the Mac mini and the launcher tests.
2026-10-28 13:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 0ee2425d40 Merge levzzz5154's #3140 (native-mod registry kind, .hnmod validator)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:44:24 +01:00
Ludovic MarieandClaude Opus 5.5 7f3bd901bc Launcher: build native mods on the Nightly they pin, with its own Rust
horizon-launcher native-build FOLDER --trust-native-code compiles mixin bundles (a folder with a
horizon-mixin.toml, or an unpacked .hnmod from the registry) into a game of their own:
- the Horizon commit the mods pin (all must agree; else the installed version's), its source from
  GitHub by commit (Nightlies keep theirs as nightly-source/ tags);
- horizon-mixins at the commit the mods were tested with, built once with the launcher's Rust;
- horizon-mixins apply, then the release's cargo build, with the portable toolchain;
- installed as native-<commit>-<key>, played by the Native mods instance.
Based on native-mixins-1's unmerged work; docs/native-mods.md describes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 d308df6167 Launcher: a portable Rust toolchain of its own, for native mods
Native mods compile the game on the player's computer (levzzz5154: many native mods per build). The
launcher now downloads the official rustc, cargo and rust-std components of Rust 1.99.1 (plus
rust-mingw on Windows, the windows-gnu host that needs no Visual Studio) from static.rust-lang.org,
each pinned by SHA-256, into <launcher data>/rust/, and runs them with its own CARGO_HOME. No rustup,
nothing installed on the system. `horizon-launcher rust status|install|remove`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:32:09 +01:00
Ludovic MarieandClaude Opus 5.5 5ded675a7d Nightly: publish the exact source, and tag its commit for good
The nightly release names its commit, and now also attaches horizon-source.tar.gz (git archive of that
commit, made from git objects in pick and signed with the builds) and tags the commit
nightly-source/<date>-<short>. The dated release and the nightly/build-* branch are pruned after 7 days;
the tag is not, so native mods pinned to a Nightly's commit keep building.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:16:48 +01:00
Ludovic Marie 564162a0b0 Merge pull request #2741 from horizon-rust/refactor/cut-biome-spawn-parsers
🤖 Agent: verify passed at 0f565f4e4. Move only: parse_entries/parse_costs/SpawnCost go from mobs/spawn_selection.rs to biome.rs (re-exported), cutting biome -> mobs (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-28 13:12:01 +01:00
Ludovic Marie 8b5a27ed3f Merge remote-tracking branch 'origin/main' into refactor/furnace-cooking 2026-10-28 13:11:00 +01:00
Hermes AgentandHermes Agent c0505424bd fix(launcher): keep native sources hidden until build integration lands
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:27:51 +05:00
Hermes AgentandHermes Agent 137045e321 feat(mods): validate source-only native mod bundles
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-28 15:20:36 +05:00
Kacpherek 4a18a4bde4 Draw the shulker bullet with its model instead of a sprite
The bullet fell through to the item billboard and showed the whole spark texture. Vanilla ShulkerBulletRenderer draws the tumbling bullet model plus a 1.5x translucent copy at full block light.
2026-10-28 10:47:31 +01:00
Kacpherek f6b467eba9 Hurt crammed mobs by 6 at the max entity cramming limit
Mobs only drew the cramming random and never took damage, so a crowd was never thinned. Vanilla's LivingEntity.pushEntities hurts a mob by 6 on a quarter of the ticks while max_entity_cramming or more non-riding entities are pushable around it.
2026-10-28 10:47:13 +01:00
AlexanderandHermes Agent 23d2146066 Villagers: report a failed random_sequences write
The merchant's random sequences (the salts that keep its offers stable) were
written with the result dropped, so a failed write rerolled every trade on
the next load with nothing in the log. Log it like the other save paths.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:38:23 +03:00
AlexanderandHermes Agent 5f8afb17fc Name tags: copy a custom name only inside the tag's range
name_tags cloned every named mob's custom name each frame and dropped the tag
afterwards when the mob was beyond 64 blocks. Test the distance first, so the
copy happens only for a mob the frame can show.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:26:00 +03:00
AlexanderandHermes Agent f7bb1ceb79 Effect panels: borrow the name and reuse the icon key
The panel list is painted every frame the inventory is open. An unclipped
name was cloned per panel and the icon key was a fresh format! per panel; the
name is borrowed and the key is built in one buffer now.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:16:09 +03:00
AlexanderandHermes Agent b9494af976 Server list: build each row's sprite key once
The row made the short sprite name (format! or to_owned) and then wrapped it
in format!("gui/sprites/{sprite}") for the atlas. Build the full key in the
match arm and hand it straight to sprite_scaled.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:14:14 +03:00
AlexanderandHermes Agent d1ead6a5fa Clippy: tab is already a reference
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 11:09:17 +03:00
AlexanderandHermes Agent 3511d556ef rustfmt
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:58:20 +03:00
AlexanderandHermes Agent 4ff7118db7 Creative screen: draw the selected tab from a reference
tab_headers() builds a header per tab without the item lists, and the caller
then cloned the selected one again: three Strings copied every frame the
screen is open. Borrow it instead.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:55:47 +03:00
AlexanderandHermes Agent 626068da3a Inventory draw: a slot is drawn from a reference, not a copy
draw_slot_item needs neither ownership nor &mut self: it reads the carried
stack only for a drag. Taking `&Option<ItemStack>` and &self drops the copy
every caller made, including the two the creative hotbar made per slot each
frame (and one clone per hovered slot becomes a clone only when hovered).

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:40:11 +03:00
AlexanderandHermes Agent 4bf633e8a9 HUD paint: build the sprite key in one reused buffer
paint() runs for every drawn HUD call each frame and made a fresh String per
sprite to look it up in the atlas: sprite.to_string() for a mob_effect and
format!("gui/sprites/{sprite}") otherwise. One buffer outside the loop keeps
the same keys without the allocation.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:33:46 +03:00
AlexanderandHermes Agent f4d6f9c0ed Tests: skip the reports-link case when the host cannot link
os.symlink needs Developer Mode or an elevated token on Windows, so the test
errored with WinError 1314 instead of exercising own_reports. Skip it there
and keep it on hosts that can link.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:30:03 +03:00
AlexanderandHermes Agent 93e61ef936 Port regen: hand the tools a Python that runs
python() returned shutil.which("python3"), which on Git for Windows is the
Microsoft Store stub (prints "Python", exits 49): every tool it launched
failed before its first line. Probe python3, python and py and keep the first
that is Python 3, once per process.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:27:21 +03:00
AlexanderandHermes Agent 28e3286e40 Port harness: read the log a Windows javac error names
named_logs only matched a Posix absolute path (`see /...log`), so on Windows
horizon-prepare's "see C:\...\export.log" matched nothing and its javac
errors were never shown. The pattern now takes either form.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:21:06 +03:00
AlexanderandHermes Agent aef4186de4 Tests: the parity-reach fixture names files the way the tool does
narrow() and its graph key files by Posix path (`crates/a/src/used.rs`), so
a fixture built from str(relative_to) on Windows produced backslashes and all
seven tests errored in setUp. as_posix() in the fixture matches the tool.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-28 10:16:41 +03:00
Cosmic b6c936f82b Merge pull request #3058 from kacpherek/perf/cart-signals-only-on-detector-rails
Server lag in new forests: count minecart signals only when a detector rail reads them
2026-10-28 02:52:35 -04:00
Kacpherek 6960b65c62 Keep the cart and its box apart in detector_rail_signal 2026-10-28 07:07:43 +01:00
Kacpherek c66554126d Turn name tags to the camera in third-person front view
Name tags faced the player's own look, so in the front view (F5 twice) every name and hologram read mirrored. They now face the camera, turned round in that view, like Camera.rotation().
2026-10-28 06:58:59 +01:00
Kacpherek 4a1c5fc54c Stop sprinting after a spear stab with knockback
Player.causeExtraKnockback stops the attacker's sprint, as the melee path already does.
2026-10-28 06:57:48 +01:00
Kacpherek 5b13af2f75 Count a minecart's comparator signal only when a detector rail reads it
Every with_level built every chest and hopper cart's signal through item lookups. tick_scheduled calls it once per scheduled tick, so freshly generated forests (thousands of leaf ticks) next to mineshaft chest carts spent a large share of the server tick there. detector_rail_signal now counts the cart it reads.
2026-10-28 06:54:33 +01:00
iLostName a1b32304a3 boat: the view stops at 105 degrees at every turn, so it doesn't jitter
Fixes #2983.
2026-10-28 06:52:29 +01:00
iLostName 7934a0b180 riding: the crosshair skips the mob being ridden, so it can't be hit
Fixes #2870.
2026-10-28 06:52:28 +01:00
iLostName 992c63eefb death message: a creeper's blast names the creeper, not the last mob that hit
The creeper is out of the mob list when its blast lands, so its name now comes with the explosion event. Fixes #2978.
2026-10-28 06:52:28 +01:00
iLostName a967ca4340 item pickup: a stack in the off hand fills before the inventory, as getSlotWithRemainingSpace does
Fixes #2865.
2026-10-28 06:52:28 +01:00
iLostName c0b560527f F1 hides the HUD only with no screen open, as key.toggleGui does
Fixes #2942.
2026-10-28 06:52:27 +01:00
iLostName 557a827383 trial spawner: a spawn egg sets its mob, as overrideEntityToSpawn does
Fixes #3041.
2026-10-28 06:52:26 +01:00
iLostName e06134f71a redstone ore: a right-click lights it, as RedStoneOreBlock.useItemOn does
Fixes #3044.
2026-10-28 06:52:15 +01:00
Kacpherek 330314327a Give a mounted spear charge its mount's speed
The charge read the rider's own last move, which a rider never sends, so on horseback it had no speed and hit nothing. ServerPlayer.getKnownSpeed gives a rider its vehicle's movement.
2026-10-28 06:30:14 +01:00
Kacpherek 012a3cf908 Use the End's ambient light for entity shadows
DimensionType.ambientLight is 0.25 in the End; shadows used 0, so they were weaker there.
2026-10-28 06:10:38 +01:00
Kacpherek 0e03cac368 Draw faint entity shadows instead of cutting them out
Shadows used the entity fragment shader, which discards alpha under 0.1, so they vanished at block light 7 and below and faded out a few blocks away. rendertype_entity_shadow.fsh has no cutout.
2026-10-28 06:10:38 +01:00
Hector EcclesandOpenCode a48fcf0d16 Reuse natural spawn candidate buffers
Every spawning pass built a new 289-per-player candidate Vec and a new
dedup set. Keep both in a scratch struct on the game and clear them between
passes, so their capacity survives. The first pass and the thunder pass use
the same buffers.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:55:47 -04:00
Hector EcclesandOpenCode 2eb3de2748 Sweep far chunks every eighth tick
Streaming walked every loaded column and every proto chunk on each update to
drop the ones that had fallen out of range. The sweep now runs at once when
the view moves and every eighth tick otherwise; unloads can wait 400 ms
without changing what is visible or generated.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:47:29 -04:00
Hector EcclesandOpenCode 67511d9749 Reuse the block entity ticker list between ticks
The list of ticking block entities was cloned into a new Vec on every tick,
even when no slot had been freed and nothing had been added. Keep it behind an
Arc and clone the Arc instead; promotions and sweeps use Arc::make_mut, so the
list is copied only when it actually changes.

Also skip the block lookup in tick_spawner: the caller only reaches it for a
BlockEntity::Spawner, and changing the block removes or replaces that entity.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:40:12 -04:00
Hector EcclesandOpenCode df90f62d35 Hopper ticks check their block only when they move
A hopper on its 8-tick cooldown looked up its block and compared the state
name every tick, then returned without moving. Advance the cooldown first and
only resolve the block when it is due to move. The sweep that drops hoppers
whose block entity is gone can run once a second; a hopper that ticks still
checks its own block.

Co-Authored-By: OpenCode <[email protected]>
2026-10-27 21:28:52 -04:00
rdbt 56f968e11c Merge pull request #2868 from masa-masa-0314/fix/closed-captions
Subtitles: fix closed captions never appearing
2026-10-28 00:48:18 +00:00
rdbt ae4cea18ef Merge pull request #3004 from feeldev12/feat/slim-skins
feat(client): slim skins wear the slim player model
2026-10-28 00:47:38 +00:00
rdbt 296802a618 Merge pull request #2980 from feeldev12/fix/own-skin-first-person
fix(client): the first-person arm and the player models wear the player's own skin
2026-10-28 00:47:14 +00:00
rdbt a5d3ad165a Merge pull request #2891 from socksprox/t3/clickable-book-links
Books: page click events work, run_command ones included
2026-10-28 00:46:58 +00:00
rdbt 2120ffedb4 Merge pull request #2878 from leifiyoo/riptide-spin-server-countdown
Count down Riptide's spin on a server's copy of a player
2026-10-28 00:46:44 +00:00
rdbt 04e6e21813 Merge pull request #2883 from sirrayi/deps/webpki-advisories
Update rustls-webpki and webpki-roots for RUSTSEC-2026-0382 and -0381
2026-10-28 00:46:15 +00:00
rdbt acee253e1c Merge pull request #2940 from ASOwnerYT/tools/harness-no-vscreen
Harnesses run offscreen on a Linux without vscreen
2026-10-28 00:46:02 +00:00
rdbt 555dce8bb3 Merge pull request #2944 from kacpherek/fix/armor-stand-plates-op-warning
Armor stands press pressure plates; match vanilla's op warning in item tooltips
2026-10-28 00:43:56 +00:00
rdbt 067163ded8 Merge pull request #2968 from kacpherek/fix/block-advancement-triggers
Award the target hit and honey slide advancements
2026-10-28 00:43:49 +00:00
rdbt 704cf97780 Merge pull request #2972 from kacpherek/fix/player-nausea-eyeblossom
Show Nausea on the player: spin the view and draw the swirl
2026-10-28 00:43:30 +00:00
rdbt 007d38fe49 Merge pull request #2822 from sirrayi/fix/telemetry-ipv6-buckets
Telemetry server: count IPv6 clients by /64 in the rate limits
2026-10-28 00:43:14 +00:00
rdbt 43e2ad34c7 Merge pull request #2953 from kacpherek/fix/beacon-christmas-chest-glow-frame
Render beacon beams, Christmas chests and glow frame maps like vanilla
2026-10-28 00:42:39 +00:00
rdbt a6dbc6651f Merge pull request #2837 from iLostName/pack-prompt-keeps-pointer
server packs: the download prompt acts as the screen it is
2026-10-28 00:42:18 +00:00
rdbt 4bb3de29e7 Merge pull request #2931 from elythepreciouscat/feat/vanilla-mouse-settings-26-3
Options: add Java 26.3 mouse settings
2026-10-28 00:41:15 +00:00
rdbt 786f67f77b Merge pull request #2967 from kacpherek/fix/blindness-sky-fog
Apply blindness fog and hide the blocked sky
2026-10-28 00:40:49 +00:00
rdbt 797852c62b Merge pull request #2840 from iLostName/client-skips-loot-tables
remote: a server's client doesn't load the loot tables on its first tick
2026-10-28 00:39:53 +00:00
rdbt 1f8f1055d5 Merge pull request #2852 from levzzz5154/fix/armadillo-roll-ball
A hit rolls the armadillo up into a ball (fixes #2759)
2026-10-28 00:39:36 +00:00
rdbt 0121466ed2 Merge pull request #2943 from kacpherek/fix/2928-mod-recipes-before-world-rest
Skip mod recipe tables until the world resources are loaded
2026-10-28 00:38:21 +00:00
rdbt 55b0545c11 Merge pull request #3017 from DanielGodriNeto/perf/sprite-pixels-shrink
perf(assets): drop the spare capacity of the sprite pixel buffer
2026-10-28 00:37:35 +00:00
rdbt 5e14e309bc comment not needed 2026-10-28 00:37:08 +00:00
rdbt 9e236c7914 Merge pull request #2834 from socksprox/fix/2747-eat-sounds
Play the player's own bites while eating on a remote server
2026-10-28 00:35:59 +00:00
rdbt 2d7fc7f000 Merge pull request #2958 from kacpherek/fix/extended-keybinds
Allow F13-F24 and other extended keys in key bindings
2026-10-28 00:35:39 +00:00
rdbt 644a399389 Merge pull request #2965 from kacpherek/fix/high-contrast-block-option
Enable the High Contrast Block Outline option
2026-10-28 00:34:16 +00:00
rdbt 9e31ce2f3d Merge pull request #2844 from Domanffe/fix/pixelify-cyrillic-o-p
Fonts: Pixelify Sans gets the Cyrillic О and П it was missing
2026-10-28 00:32:49 +00:00
rdbt 66d03b4d1c Merge pull request #2887 from masa-masa-0314/fix/creative-search-reset-on-open
Creative inventory: fix the search box keeping the last search after closing
2026-10-28 00:32:36 +00:00
rdbt 84d5574887 Merge pull request #2956 from gemakfy/fix/creative-f-offhand-crash
Fix creative inventory off-hand swap crash on Key F
2026-10-28 00:32:16 +00:00
Ludovic Marie 4118e026c3 Merge pull request #3014 from horizon-rust/docs/team-merge-rule
🤖 Agent: Merged by Ludovic's AI agent after verify: docs and lists only (Team merge rule, levzzz5154 trusted reviewer, licence holder), Ludovic's request 2026-10-28.
2026-10-28 00:56:21 +01:00
Ludovic MarieandClaude Opus 5.5 51b374121b Team merge rule: ordinary pull requests merge on CI, risky ones get needs-proof; levzzz5154 trusted
The Horizon Team merges ordinary pull requests once verify and parity pass; saves, network, world generation,
launcher updates, the harnesses and big changes wait for a local proof. Also names the licence holder with the
contributors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:46:44 +01:00
DanielGodriNeto 9042746704 perf(assets): drop the spare capacity of the sprite pixel buffer 2026-10-27 20:39:24 -03:00
Ludovic Marie 795490271d Merge pull request #2988 from horizon-rust/refactor/be-layer-deps
🤖 Agent: verify passes on this head; full pr_ready green on the Mac mini (1340 tests, parity 89 affected / 0 failed). Move-only step B6 of the crate split (crate-split-1).
2026-10-27 23:27:53 +01:00
feeldev 9fe3ddfec3 feat(client): slim skins wear the slim player model
A skin whose metadata says model slim (PlayerSkin.Model.SLIM) now draws
with PlayerModel's slim layer (3-pixel arms), on other players, the local
player's model, mannequins and the first-person arm. Without a skin, the
profile's default skin (DefaultPlayerSkin.get) keeps its own model, so
Alex and the other slim defaults are slim again.

Fixes #2926
2026-10-27 17:26:01 -05:00
Ludovic MarieandClaude Opus 5.5 efa8df83b3 villagers: a torchflower crop is never ripe
TorchflowerCropBlock.getMaxAge is 2 while its age property stops at 1, so
CropBlock.isMaxAge never holds for it: HarvestFarmland leaves it alone and
UseBonemeal keeps it as a target until it turns into a torchflower.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:06:19 +01:00
Ludovic MarieandClaude Opus 5.5 58422bcc19 Bred babies keep the classic sound set; allays walk back to within 4 blocks
- Animal.spawnChildFromBreeding creates the baby with getBreedOffspring and
  no finalizeSpawn, so its sound variant stays the default (classic); the
  spawn path here had just drawn a random one.
- StayCloseToTarget sets a walk target with closeEnough 4: once started at
  16 blocks, the allay walks until MoveToTargetSink.reachedTarget (Manhattan
  block distance 4), not only back inside 16.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:54:14 +01:00
feeldev 5c14d18141 fix(client): the link prompt has ConfirmLinkScreen's Copy to Clipboard button
ConfirmLinkScreen.addButtons: an untrusted link asks with three 100-wide
buttons, Yes, Copy to Clipboard and No; Copy puts the link on the
clipboard and answers no. confirm_screen::Choice lays out a row of any
number of buttons; Confirm keeps its two of 150 through it.
2026-10-27 16:52:15 -05:00
Ludovic MarieandClaude Opus 5.5 7a62c6f4a7 Give the capture test's fixture the pack prompt
capture() now asks the server pack prompt and the dialog; the fixture
gets both, and the prompt's own case: it keeps the pointer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:46:23 +01:00
Kacpherek 0f8e3a9b09 Format the mob sound changes 2026-10-27 22:40:25 +01:00
Kacpherek 4ce1eb6b0a Play the crossbow hit sound for arrows shot from a crossbow
CrossbowItem.createProjectile sets the arrow's sound event to item.crossbow.hit; every arrow hit played entity.arrow.hit.
2026-10-27 22:40:16 +01:00
Kacpherek 47d249e7dd Pick hurt and death sounds by water, size, age and shell
Drowned and guardians use their water or land pair, tiny slimes and magma cubes the _small pair, a closed shulker hurt_closed and a baby turtle the _baby pair, as getHurtSound and getDeathSound do. Horizon always used the plain sound.
2026-10-27 22:40:10 +01:00
Kacpherek ea6ebae264 Fix horse standing, angry, ambient and baby horse voices
RandomStandGoal plays getAmbientStandSound (the ambient sound) for every horse, not entity.horse.angry for horses only; makeMad uses getAngrySound (zombie horse its own, skeleton horse none, baby horse baby_horse.angry); a baby horse and a skeleton horse in water pick their own ambient, hurt and death sounds.
2026-10-27 22:40:10 +01:00
Kacpherek d878f3bed2 Give donkeys, mules and baby horses their own jump and eating sounds
playJumpSound is entity.donkey.jump or entity.mule.jump (skeleton_horse.jump_water in water); Horse.getEatingSound is entity.baby_horse.eat for a baby. Horizon played the horse's for all.
2026-10-27 22:39:37 +01:00
Kacpherek ec5fc9d5e4 Play horse hoof, wood, gallop and breathing steps
AbstractHorse.playStepSound plays entity.horse.step (baby_horse.step), step_wood on wood or for the first five ridden steps, and gallops every third step with Horse.playGallopSound's breathing. Horizon played the block's step sound.
2026-10-27 22:39:37 +01:00
Kacpherek 99948fdf3f Play the hostile fall sounds when a monster takes fall damage
Monster.getFallSounds is entity.hostile.small_fall and big_fall; Horizon played the generic pair.
2026-10-27 22:39:37 +01:00
Kacpherek 73ad5f1dbb Play the hostile swim sound for swimming monsters and hoglins
Monster and Hoglin override getSwimSound with entity.hostile.swim; Horizon played the generic swim sound for them.
2026-10-27 22:39:37 +01:00
Kacpherek 6e26990ad3 Run cargo fmt on the villager changes
Formatting only.
2026-10-27 22:39:05 +01:00
Kacpherek ffb6594600 Format the block behaviour changes 2026-10-27 22:37:59 +01:00
Ludovic MarieandClaude Opus 5.5 e0bb0881e8 item frames: glow frame map light is block coordinate 210, as vanilla's 15728850
15728850 is 0xF000D2: sky coordinate 240 and block coordinate 210, which
the packed light here stores as 240 | 210 << 8 (208 was 13 * 16).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:36:57 +01:00
Ludovic Marie 4b4950d7c4 Merge branch 'refactor/be-layer-deps' into refactor/furnace-cooking 2026-10-27 22:36:31 +01:00
Ludovic Marie e902b6fc18 Merge remote-tracking branch 'origin/main' into refactor/be-layer-deps 2026-10-27 22:34:05 +01:00
Ludovic MarieandClaude Opus 5.5 1d693cb3c3 Read mouse.rs where the input crate keeps it now
#2750 moved it to crates/horizon-client-input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:33:10 +01:00
Ludovic MarieandClaude Opus 5.5 23e3cf465c Merge main into screen-cursor-centre
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:33:03 +01:00
Kacpherek 0d3ca74bd0 Break turtle eggs by stepping on them
TurtleEggBlock.stepOn calls destroyEgg(100) for any step that is not careful; only the fall path (1 in 3) existed.
2026-10-27 22:33:01 +01:00
Kacpherek f69088012b Format the hostile mob fixes 2026-10-27 22:32:05 +01:00
Kacpherek 154aaa2051 Fix clippy and format the passive mob fixes 2026-10-27 22:32:05 +01:00
Kacpherek 1ab273641e Disarm a tripwire with shears before breaking it
TripWireBlock.playerWillDestroy sets DISARMED (flags 260) and fires the shear event when the main hand holds shears; the property was never set.
2026-10-27 22:31:36 +01:00
Kacpherek 785d579f47 Stop vines spreading when the spread_vines rule is off
VineBlock.randomTick only runs when GameRules.SPREAD_VINES is on; Horizon ignored the rule.
2026-10-27 22:31:17 +01:00
Kacpherek 3fe8aa76b2 Build a golem whenever a carved pumpkin is placed
CarvedPumpkinBlock.onPlace runs on every placement; /setblock, /fill, /clone and piston moves never completed a golem.
2026-10-27 22:30:05 +01:00
Kacpherek dc75e77eb1 Pick a parent's variant at random for pig, cow and chicken babies
Pig, Cow and Chicken.getBreedOffspring take either parent's variant with equal odds; Horizon always took the initiator's.
2026-10-27 22:29:41 +01:00
Kacpherek 94e8c85fd0 Roll a natural salmon's size
Salmon.finalizeSpawn picks small 30, medium 50 or large 15; natural spawns kept variant 0, so every salmon rendered small.
2026-10-27 22:29:29 +01:00
Kacpherek 28c30923f4 Play the sound variant of pigs, cows, chickens, wolves and cats
Vanilla plays the mob's sound variant set, the baby set for babies (Pig/Cow/Chicken/Wolf/Cat.getSoundSet); Horizon always played the classic adult sounds and never gave a natural spawn a variant. Tame cats also purreow one time in four (Cat.getAmbientSound).
2026-10-27 22:29:29 +01:00
Kacpherek 2b74cfae4a Drop a beehive with its bees when it is broken in creative
BeehiveBlock.playerWillDestroy drops the hive item with bees and honey_level when drops are prevented and block_drops is on; creative breaks dropped nothing.
2026-10-27 22:29:06 +01:00
Kacpherek 60a4edaef9 Chain a hanging sign under a ceiling hanging sign
CeilingHangingSignBlock.useItemOn passes for a hanging sign item on the DOWN face; Horizon opened the editor instead of placing the next sign.
2026-10-27 22:28:29 +01:00
Ludovic MarieandClaude Opus 5.5 5096b41100 Merge main into #2886: offline names need an owner since main
Keep main's owner check (play_as takes the store, Sign out updates
the owner state) with the branch's load-mutate-save and skin cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:22 +01:00
Kacpherek e9eaa66542 Offer the iron golem's poppy for 400 game ticks, not 800
OfferFlowerGoal.start sets tick to adjustedTickDelay(400), which is 200 goal ticks. Horizon counted 400 goal ticks, so the offer lasted twice as long.
2026-10-27 22:28:16 +01:00
Kacpherek 2c2aa41619 Clear a door's lower half when the tool would not drop it
DoorBlock.playerWillDestroy removes the bottom half for creative or !hasCorrectToolForDrops (iron door by hand); only creative was handled.
2026-10-27 22:27:59 +01:00
Kacpherek f164427cba Use the full not_scary_for_pufferfish list for the puff trigger
Turtles, guardians, tadpoles, nautiluses and sulfur cubes no longer puff a pufferfish, and axolotls do, as in Pufferfish.PUFFER_SCARY_MOB and the tag.
2026-10-27 22:27:50 +01:00
Kacpherek b77301b6eb Let an allay wander up to 16 blocks from its player
StayCloseToTarget only pulls an allay back once its target is 16 blocks away (closerThan(tooFar)); Horizon pulled it back beyond 4.
2026-10-27 22:27:44 +01:00
Kacpherek e08adcd31c Anger nearby piglins when an ender chest is opened
EnderChestBlock.useWithoutItem calls PiglinAi.angerNearbyPiglins(true); the ender chest branch skipped the generic container path that does it.
2026-10-27 22:27:31 +01:00
Kacpherek 830a9d3d84 Give the skeleton horse vanilla's water drag
SkeletonHorse.getWaterSlowDown is 0.96; Horizon used the 0.8 default so it swam too slowly.
2026-10-27 22:27:21 +01:00
Kacpherek 6f0d56512d Stop mules from breeding
Mule inherits AbstractHorse.canMate, which is false; only horses and donkeys (Horse/Donkey.canMate) find a partner.
2026-10-27 22:27:21 +01:00
Kacpherek 482444afc4 Roll vanilla's donkey, mule and zombie horse attributes on spawn
A natural donkey or mule rolled a horse jump; vanilla rolls only health and leaves jump at 0.5. A zombie horse uses its own jump 0.5-0.7 and speed (9+3 rolls)/42.16 (AbstractChestedHorse, ZombieHorse).
2026-10-27 22:27:09 +01:00
Ludovic MarieandClaude Opus 5.5 46c84a8b24 Merge main into #2891: main has run_command click events since
Keep main's ClickEvent::RunCommand and click_command (now pub(super) for
the book screen) instead of the branch's copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:27:03 +01:00
Ludovic Marie ae82c1b693 Merge remote-tracking branch 'origin/main' into cg24/f2891 2026-10-27 22:26:51 +01:00
Ludovic MarieandClaude Opus 5.5 501e543105 Creative on a server: the off hand's edits reach the server too
CreativeInventoryListener sends every InventoryMenu slot that changes,
the off hand (slot 45) included; only the 36 inventory slots were sent,
so Key F's off-hand stack stayed on this client (single player runs on
its server thread too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:47 +01:00
Kacpherek 0d7f842b09 Hurt the dragon with a wind charge
AbstractWindCharge.onHitEntity hurts whatever it hits for 1, the dragon's parts included (crystals are not hit); Horizon only looked at ordinary mobs.
2026-10-27 22:26:23 +01:00
Kacpherek a1dfcbf2b3 Remove the dragon's flame cloud when it takes off
DragonSittingFlamingPhase.end discards the cloud it made; Horizon left it to last its full 200 ticks after the phase ended.
2026-10-27 22:26:23 +01:00
Kacpherek 5703fdfdc4 Let slimes, ghasts and calm piglins not block a bed
ServerPlayer.startSleepInBed looks for Monster entities that isPreventingPlayerRest; slimes, ghasts, phantoms and unangry zombified piglins are not. Horizon counted every hostile-category mob.
2026-10-27 22:26:23 +01:00
Kacpherek 57494e6871 Slow mobs in cobwebs again
WebBlock.entityInside makes a mob stuck at 0.25/0.05/0.25 (0.5/0.25/0.5 with Weaving) and ends its fall; spiders and withers ignore it. The block-effects pass replaced the earlier cobweb code and dropped it.
2026-10-27 22:26:23 +01:00
Kacpherek ad8ef0b7e8 Wither a skull's target by difficulty, and only on a hit
WitherSkull.onHitEntity adds Wither for 10 s on Normal, 40 s on Hard and nothing on Easy, only when the hit hurt; Horizon always gave 10 s.
2026-10-27 22:26:23 +01:00
Kacpherek f9fca5688d Give witches their resistance to magic
Witch.getDamageAfterMagicAbsorb takes 85% off #witch_resistant_to (magic, indirect_magic, sonic_boom, thorns) and all of the witch's own damage; Horizon had no such rule.
2026-10-27 22:26:23 +01:00
Kacpherek 4156a6ac93 Aim a mob's crossbow by the world difficulty
RangedAttackMob.rangedAttackUncertainty is 14 - 4 * difficulty; Horizon always used the Normal value 6.
2026-10-27 22:26:23 +01:00
Kacpherek 6c3defa821 Hit with a hoglin or zoglin every 40 ticks, 15 for a baby
HoglinAi and Zoglin use MeleeAttack.create(40) for adults and create(15) for babies; Horizon used the 20-tick default.
2026-10-27 22:26:23 +01:00
Ludovic MarieandClaude Opus 5.5 8057d27511 Worn armour: only a dyeable layer takes the stack's dye
getColorForLayer returns white for a layer without Dyeable, whatever
dyed_color the stack has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:12 +01:00
Ludovic MarieandClaude Opus 5.5 466fef4486 Remote eating: the sound plays for items without consume particles too
Consumable.emitParticlesAndSounds gates only the particles on
hasConsumeParticles; the sound always plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:02 +01:00
Kacpherek 4e814a152a Make every wolf beg for a bone or its food
BegGoal.playerHoldingInteresting is a bone or Wolf.isFood for any wolf. Horizon had untamed wolves beg only for bones and tamed wolves only for food.
2026-10-27 22:25:20 +01:00
Kacpherek 3f378dda96 Make a chased child villager run away in tag
PlayTagWithOtherKids always chased the first friend. Vanilla flees to a village spot (LandRandomPos 20, 8, speed 0.6) when a friend chases it, otherwise chases the kid with one to five chasers, else any friend.
2026-10-27 22:25:02 +01:00
Ludovic MarieandClaude Opus 5.5 70068076f2 Keep set_baby's doc comment on set_baby
The two new functions went in between it and its comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:39 +01:00
Kacpherek 59b67af6a8 Send villagers to the dropped items they want
NearestItemSensor was a no-op and GoToWantedItem never started for villagers. Vanilla scans 32 blocks every 20 ticks for the nearest item the villager wants and sees, and walks to it at 0.5 when it is within 4 blocks.
2026-10-27 22:23:43 +01:00
Ludovic MarieandClaude Opus 5.5 e34f01715c Merge main into pack-prompt-keeps-pointer
main's draw_toasts takes the F1 flag now; the prompt still hides the
HUD, sign editor and book under it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:22:25 +01:00
Kacpherek 1b469a542f Let villagers with bone meal grow crops
UseBonemeal never started: it computed the bone meal count and returned false. Vanilla picks a crop below max age around the villager every 10 ticks, holds bone meal and grows it, then picks again 40 ticks later, for up to 80 cycles.
2026-10-27 22:21:24 +01:00
Kacpherek 567e92e2a2 Let villagers harvest torchflower crops
TorchflowerCropBlock is a CropBlock with max age 1. It was missing from the villagers' crop lists, so farmers planted torchflower seeds but never harvested them.
2026-10-27 22:20:12 +01:00
Kacpherek 5388064d88 Ring the bell for real when a villager rings it
RingBell calls BellBlock.attemptToRing: the bell shakes, the sound plays and the block_change game event fires. Horizon only played the sound.
2026-10-27 22:19:17 +01:00
Kacpherek 20ffd8e538 Tell villagers within 32 blocks that a bell was rung
BellBlockEntity.updateEntities sets HEARD_BELL_TIME on living entities within 32 blocks. Nothing set it, so a rung bell never sent villagers into the hide activity.
2026-10-27 22:18:52 +01:00
Kacpherek c3e19973cf Fire item_used_on_block for a compass used on a lodestone
Vanilla fires ITEM_USED_ON_BLOCK when a compass locks onto a lodestone (ServerPlayerGameMode via useOn).
2026-10-27 22:08:13 +01:00
Kacpherek 7aaa4b433b Fire avoid_vibration when a sneaking step is ignored
Vanilla's VibrationSystem fires AVOID_VIBRATION for a sneaking player whose step a sculk sensor or a warden ignores; a shrieker cannot trigger it.
2026-10-27 22:08:13 +01:00
Kacpherek 3cf601f760 Fire kill_mob_near_sculk_catalyst for the player who hurt the mob
Vanilla's SculkCatalystBlockEntity fires it for the player who last hurt a mob whose experience a catalyst takes.
2026-10-27 22:08:13 +01:00
Kacpherek 39327fca33 Fire allay_drop_item_on_block when an allay drops at the liked block
Vanilla's AllayAi.throwItem fires ALLAY_DROP_ITEM_ON_BLOCK for the liked player with the block below the deposit.
2026-10-27 22:08:12 +01:00
Kacpherek 6489cdf829 Fire player_interacted_with_entity for leashing, feeding and repairing
Vanilla's PlayerInteractTrigger fires after a successful item use on an entity: leashing, feeding a baby sniffer, repairing wolf armour. Item predicates now check the damage component, and guests' interactions fire it too.
2026-10-27 22:08:12 +01:00
Kacpherek 1727f2acf1 Fire player_sheared_equipment when wolf armour is sheared off
Vanilla's Mob.shearItem fires PLAYER_SHEARED_EQUIPMENT with the armour and the mob. A wolf's armour now counts as its body slot, with its durability, for entity and item predicates.
2026-10-27 22:08:04 +01:00
Kacpherek 079963f27e Fire bee_nest_destroyed with the bees left inside
Vanilla's BeehiveBlock.playerDestroy fires BEE_NEST_DESTROYED after emptying the hive, with the tool and the bees still inside; a silk touch break keeps them in.
2026-10-27 22:07:25 +01:00
Kacpherek 3c72bf2d79 Fire spear_mobs when a spear stabs several mobs in one use
Vanilla's KineticWeapon fires SPEAR_MOBS with the number of living entities stabbed in the use, so stabbing five or more completes the spear advancement.
2026-10-27 22:07:25 +01:00
Kacpherek 16cda1637b Fire lightning_strike and channeled_lightning for the right players
Vanilla's LightningBolt fires LIGHTNING_STRIKE when it is discarded, for players within 256 blocks, counting the fires it set and the mobs it missed as bystanders, and CHANNELED_LIGHTNING for the player who channelled it. The lightning rod advancement no longer passes for every bolt.
2026-10-27 22:07:24 +01:00
asowneryt 513aed64c5 Merge upstream main into fix/2802-projectile-death-message 2026-10-28 10:02:41 +13:00
Kacpherek ad61c30f46 Award target hit and honey slide advancements 2026-10-27 22:02:19 +01:00
Kacpherek f35ee16412 Apply blindness fog and hide the blocked sky 2026-10-27 22:02:19 +01:00
Kacpherek 5216403562 Enable the high contrast block outline option 2026-10-27 22:02:18 +01:00
Kacpherek 7ab2b20bd5 Let mouse buttons toggle cinematic camera 2026-10-27 22:02:17 +01:00
Kacpherek 2aea6138b7 Allow extended keyboard keys in bindings 2026-10-27 22:02:17 +01:00
Kacpherek dddcc99726 Start with HDR off after a start that died switching it on
HDR output now writes horizon-hdr-pending before it is switched on and removes it after the first presented HDR frame. A start that finds the file turns hdr off in horizon-graphics.txt and logs it, so a native crash in HDR output costs one start, not every start.
2026-10-27 22:02:16 +01:00
Kacpherek 5b41d9ebf3 Light glow frame maps at 15728850
A map in a glow item frame is drawn at light 15728850 (sky 15, block 13), not full bright, as ItemFrameRenderer does.
2026-10-27 21:59:46 +01:00
Kacpherek 52f3dcc5ef Use christmas chest textures on 24-26 December
Normal and trapped chests use the christmas sprites on 24-26 December, as ChestRenderer does, with the local date read once. minecraft:local_time reads the system time zone instead of UTC, so the Christmas chest item switches on the local date.
2026-10-27 21:59:46 +01:00
Kacpherek 7d99d23aef Fix beacon beam distance and cull at render distance
The beam width used x and y instead of the horizontal distance, so it was wrong for beacons to the north or south. Beams beyond render distance x 16 are skipped, as BeaconRenderer.shouldRender does. A beacon with Levels 0 still draws no beam.
2026-10-27 21:59:46 +01:00
Kacpherek b8f0cde900 Fix the Metal HDR colour space lookup that crashed macOS at start
wgpu-hal's Metal backend read the CoreGraphics colour space names (kCGColorSpaceITUR_2100_PQ and others) as if dlsym returned the string, but it returns the address of the global that holds it. Configuring a PQ swapchain passed that address to CGColorSpaceCreateWithName, which faulted with no Rust panic. Read through the pointer.
2026-10-27 21:59:16 +01:00
Ludovic Marie 1feb28c045 Merge pull request #2750 from horizon-rust/refactor/horizon-client-input
🤖 Agent: Merged by Ludovic's AI agent (orchestrator), crate split route (c): head 64c174118 (main merged in, no rebase) passed the full tools/pr_ready.py on the Mac mini (2327 workspace tests, 398 Python, launcher 205; parity --changed 130 run, 0 failed) plus command_exec and save_roundtrip --fresh; verify green.
2026-10-27 21:58:06 +01:00
asownerytandClaude Opus 5.5 c4348be8d2 Check the killing blow of player_killed_entity: Return to Sender needs the ghast's fireball
KilledTrigger.TriggerInstance.matches tests killing_blow, a
DamageSourcePredicate, against the damage that killed the entity.
Horizon ignored it, so any ghast kill awarded Return to Sender (and any
breeze kill Blowback, any skeleton kill from 50 blocks Sniper Duel).

Fixes #2832

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:56:46 +13:00
Kacpherek 7f6e8b634e Spin the level view and draw the Nausea swirl for the player
Nausea never reached the camera or the HUD: the swirl input was hard-coded to 0 and the level projection had no portal or Nausea spin. Add vanilla's spin (max of portal and Nausea intensity, scaled by Distortion Effects squared) and the Nausea blend fade (150 in, 20 out). Vanilla: GameRenderer.renderLevel, LocalPlayer.tickSpinningEffect, MobEffects NAUSEA blend.
2026-10-27 21:56:00 +01:00
Ludovic Marie 2e69ac9886 Merge remote-tracking branch 'origin/main' into refactor/be-layer-deps 2026-10-27 21:49:00 +01:00
asownerytandClaude Opus 5.5 c309d331ad Harnesses run offscreen on a Linux without vscreen
Without vscreen, save_roundtrip, world_reopen and spawn_loot stopped on
FileNotFoundError, and lan_e2e's games opened windows on the real
desktop: unfocused, the host paused before its world loaded and waited
at "Connecting to the server..." until wait-ready gave up. They now run
offscreen there, as on macOS, and lan_e2e's games also skip the pause
(as HORIZON_E2E_HEADLESS does).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 09:47:06 +13:00
Kacpherek 7acfc655e8 Match vanilla's op warning on item tooltips
The warning was missing its bold first line and skipped spawn eggs of op-only entities. Vanilla: ItemStack.OP_NBT_WARNING, BlockItem and SpawnEggItem.shouldPrintOpWarning (non-block items never warn).
2026-10-27 21:45:45 +01:00
Kacpherek d485c688d6 Let armor stands press pressure plates and tripwires
A non-marker armor stand is a LivingEntity, so it counts for the mob and weighted plates and trips wires. ArmorStand.isIgnoringBlockTriggers() is only true for markers.
2026-10-27 21:45:44 +01:00
Ludovic Marie 93d77962ee Merge pull request #2936 from horizon-rust/chore/horizon-rust-links
🤖 Agent: Merged by Ludovic's AI agent (orchestrator): links after the move to horizon-rust, the mod index's new address, and the release-notes fix that makes verify pass on main again. pr_ready --quick and the full Python suite pass locally.
2026-10-27 21:43:18 +01:00
Kacpherek 47ca0312df Skip mod recipe tables until the world resources are loaded
On a slow start the title is drawn while the world resources still load. A running mod that reads recipes built its tables every title frame and hit the unloaded resources, which panicked. Fixes #2928.
2026-10-27 21:38:40 +01:00
Ludovic MarieandClaude Opus 5.5 c9fa1c8115 Format the mod registry after the URL change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:31:47 +01:00
Ludovic MarieandClaude Opus 5.5 d2b0347777 Point links at the horizon-rust organization
The repositories moved from ludovic111 to the horizon-rust organization.
GitHub redirects repository URLs but not GitHub Pages, so the mod index
moved to https://horizon-rust.github.io/horizon-mods/: the launcher and
the game read it there. The launcher still accepts icons and files from
the old addresses, which a mirror keeps serving for launchers released
before the move.

Release notes thanked ludovic111 once the repository's owner became the
organization (verify failed on main): the maintainer is now named.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:28:04 +01:00
feeldev 78c028321c fix(client): the first-person arm and the player models wear the player's own skin
The empty first-person arm drew Steve's skin always; it now uses the local
player's skin, as the inventory's model does (PlayerRenderer.renderRightHand
reads AbstractClientPlayer.getSkin). Player models without a downloaded skin
wear their profile's default skin (DefaultPlayerSkin.get), as the player
list does. In single player the integrated server is offline-mode and sends
no textures, so the signed-in account's own profile is fetched from the
services (Minecraft.profileFuture) and gives the local player its skin.

Fixes #2921
2026-10-27 15:21:32 -05:00
Ludovic Marie 64c174118a Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 21:13:23 +01:00
Ludovic Marie f21e09510e Merge pull request #2910 from ludovic111/feat/triage-commands
🤖 Agent: Merged by Ludovic's AI agent (orchestrator) after review: comment commands for the trusted reviewers (/close, /reopen, /label, /unlabel), from main's code only, same list and pause as /nightly.
2026-10-27 21:11:21 +01:00
ElyTheCat 127434c46c Options: add Java 26.3 mouse settings 2026-10-27 23:21:09 +03:30
gemakfy 7b40465f85 Fix creative inventory off-hand swap crash on Key F 2026-10-27 22:38:19 +03:00
Ludovic MarieandClaude Opus 5.5 a34a41e1b5 refactor: move the furnace's recipe logic out of containers
Move-only step B7 of the horizon-client crate split. `impl Furnace`
needed `crafting::Recipes`, the one gameplay module containers still
reached. Its four methods (set_input, recipe_experience, input_clicked,
tick) move unchanged to a new `furnace` module, as the extension trait
`FurnaceCooking` implemented for the Furnace data, with the two helpers
only they use (remainder, total_time). Callers add one `use`.

containers now depends only on modules of the render/world layer
(skins and tab_list included), so it can move into that crate whole.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:55 +01:00
Ludovic MarieandClaude Opus 5.5 9861a1ba04 refactor: cut vault, command_block, sculk and brewing loose from gameplay
Move-only step B6 of the horizon-client crate split. These four modules
only reached gameplay modules for small helpers, so after this they depend
only on the render/world layer and can move into its crate whole, impls
included.

- save::float moves to horizon_client_core::nbt_value (save re-exports it).
- mobs::spawner::normalize_id moves to horizon_client_core::identifier
  (spawner re-exports it).
- containers::Providers moves to its own module, providers.rs
  (containers re-exports it).
- vault, command_block and sculk import number/text/float, item_nbt,
  Who and scoreboard from their real homes instead of through save and
  mobs; brewing imports Providers from providers.
- The vault, spawner and trial_spawner harnesses record the client-core
  files the moved helpers now live in.
- villager_brain's `impl Poi` (max_tickets, valid_range) moves next to
  the type in poi_sections, and its two methods become pub.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:59:32 +01:00
Ludovic Marie d9ab241406 Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 19:48:28 +01:00
Ludovic MarieandClaude Sonnet 5.5 d87b081c34 Trusted reviewers triage on GitHub: /close, /reopen, /label, /unlabel
One list (.github/trusted-reviewers.txt) now governs /nightly and the triage commands.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 19:21:08 +01:00
Kacpherek 22cab3eeb3 Open windowed after the window system kills the game in fullscreen
When winit's event loop ends with ExitFailure (on Linux, a compositor
dropping the connection after a protocol error) while fullscreen is on,
turn fullscreen off in options.txt before the error goes on, so a
fullscreen the system can't show doesn't crash every launch (#2904).
2026-10-27 19:02:20 +01:00
socksproxandDevin 9b6faeb8cf launcher: the game's made-up name can be rolled for a new one
The game's own name (horizon-player.txt) isn't a kept offline name, so
Forget never applied to it and there was no way to be rid of it. Settings
> Account shows it with "New name", which writes a fresh PlayerNNNNN to
every existing game home — the default and each instance's — so the
player stays the same (new) player in its worlds, as PLAYER_FILES' copies
do.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:13:55 +01:00
socksproxandDevin (SWE-2) e36cd17dd1 Books: page click events work, run_command ones included
Two gaps left every link in a written book dead. view_pages only parsed
compound page components, so the list form ComponentSerialization also
takes (and servers behind ViaVersion send) fell back to plain text and
lost its click events. And run_command was never parsed into a
ClickEvent, so even a parsed page's command links did nothing.

The book's click handler now follows Screen.defaultHandleClickEvent:
open_url through the link flow, run_command sent unsigned (client mods
first, as clickCommandAction), suggest_command into the chat box,
copy_to_clipboard, with chat's own click match gaining run_command the
same way.

Co-Authored-By: Devin (SWE-2) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 18:06:53 +01:00
masa-masa-0314andZCode e6c1a91a65 creative: rebuild the search state when the screen opens
CreativeModeInventoryScreen is a fresh screen per open (init() makes a new
search box and a zeroed scrollbar; only selectedTab is static), but Horizon
keeps one Creative for the game's lifetime, so the last search stayed in
the box after closing the inventory. Clear the box, refocus it on the
search tab and reset the scrollbar at both open paths (local and remote).

Fixes #2873.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 00:53:07 +08:00
socksproxandDevin 8cfc13fe2c launcher: the account list follows changes made outside the window
accounts.json was read once at start-up, so an offline name added or
forgotten through the CLI or another window left the running window
without the name's row (and its Forget button) until relaunch. Page
switches and opening the account chip's menu read it again, as the
game's made-up name already was.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:49:50 +01:00
socksproxandDevin a7e3bcf79f launcher: a forgotten offline name is gone for good
Forget removed the name from accounts.json but left skins/<name>.png and
skins/<name>.cape.png behind, the skin panel's file pick could still
record a look for a name that no longer existed, and the account rows'
Play as / Play offline / Sign out saved the window's stale account list,
which could write a name forgotten elsewhere (CLI, another window) back.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:41:07 +01:00
sirrayiandDevin 62bb83857c Update rustls-webpki to 0.103.16 and webpki-roots to 1.0.10 (RUSTSEC-2026-0382, -0381)
cargo deny check advisories flags rustls-webpki 0.103.15 (distrusted emSign intermediates may be accepted, RUSTSEC-2026-0382) and webpki-roots 1.0.9 (bundles the distrusted emSign ECC Root CA - G3, RUSTSEC-2026-0381). Both are patch releases; every HTTPS request of the game, the launcher, horizon-server and the telemetry server goes through them (ureq -> rustls). horizon-mod-registry's lock already has the fixed versions.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 16:10:51 +00:00
leifiyooandClaude Haiku 5.5 de286f236f Count down Riptide's spin on a server's copy of a player
The host runs a guest's trident release on its copy of the guest, which
sets the spin for 20 ticks. The copy never ran the client's movement
tick, so the count never went down: the copy stayed in the spin pose
(eye 0.4), and every arrow or projectile the guest shot was spawned from
the leg until something else reset it.

LivingEntity.aiStep counts autoSpinAttackTicks down on the server too.
Count it down in the guest's server tick, before updatePlayerPose.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:30:53 +01:00
leifiyooandClaude Haiku 5.5 e7f5201c1f Client: worn dyed leather armour shows its dye colour
EquipmentLayerRenderer.getColorForLayer takes a dyeable layer's colour from
the stack's minecraft:dyed_color and falls back to the asset's undyed colour.
The player's own armour and LAN guests' armour, plus armour stands, used only
the undyed colour, so dyed leather looked brown.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:30:53 +01:00
masa-masa-0314andZCode 91d401aa8a Subtitles: a server's client ticks the overlay too
Single player joins its integrated server as a client, so `Minecraft.tick`
takes the `tick_remote` path and never reaches `tick_mobs`, where the
subtitle overlay's tick had its only call. The overlay therefore never
started listening to the sound engine: no sound notified it, and the
closed captions stayed empty with "Show Subtitles" on or off.

Call `tick_subtitles` from `tick_remote_level` beside `tick_hud`, as the
local path does.

Co-Authored-By: ZCode (Z.ai) <[email protected]>
2026-10-27 23:17:06 +08:00
levzzzandminimax-m3.1-flash a7d0586d95 Merge upstream/main into fix/armadillo-roll-ball
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:16:53 +05:00
levzzzandminimax-m3.1-flash f507a067cd The breeze's wind and the wind charge scroll their textures
Vanilla draws the breeze's wind layer and the wind charge with
`RenderTypes.breezeWind`, whose texture scrolls with the entity's age: by
0.02 of the texture per tick for the breeze's wind layer and 0.03 for the
wind charge. Horizon drew both with a still texture, so the breeze's gust
and the wind charge did not move.

The draw now takes a scroll offset and wraps the vertex's U inside the
texture, as the texture matrix of the vanilla's render type does.

Fixes #2760

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:30:29 +05:00
levzzz 28f906c37d Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-27 19:21:10 +05:00
levzzzandminimax-m3.1-flash f8f2f904bc A summoned or egg-spawned mob is not persistent
Vanilla never calls `Mob.setPersistenceRequired` for a mob it spawns: the
spawn egg's offspring, the spawn egg on a block and `/summon` all leave
the mob to the despawn rule, and only the NBT can make it persistent.
Horizon marked every one of them persistent, so they never despawn and
they are left out of the mob cap.

The three paths now spawn the mob like before, without the persistence
flag, and the command execution corpus checks it against vanilla: a
summoned zombie is not selected by `nbt={PersistenceRequired:1b}`.

Fixes #2787

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:21:08 +05:00
Alexander KrylovandHermes Agent c3fb1656e6 Fonts: Pixelify Sans gets the Cyrillic О and П it was missing
Upstream maps U+043E (о) and U+043F (п) and not their capitals, so a Russian
heading drew О and П from the fallback font: they came out smooth and taller
than the pixel letters around them (screenshots in the PR).

Both come from the font's own drawing. О is the Latin O's glyph under the
Cyrillic codepoint, which is the same round shape this font gives о: its
lowercase is the O with the top shifted down. П is that weight's lowercase п
with everything above the baseline lifted to the cap height the font uses there
(Н's top), 175 units in SemiBold, 177 in Medium and 171 in Bold, since each
weight draws its own metrics. Both now sit on the bbox of the O and Н of their
own weight.

О keeps the O's hinting instructions; П is a new outline with none, which a
pixel design does not need at the sizes the launcher draws it. GDEF, GPOS and
GSUB are byte-identical to the files they replace.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-27 17:19:10 +03:00
socksproxandDevin (SWE-2 High) b98633b677 Play the player's own bites while eating on a remote server
On a remote (non-Horizon) server the use-item tick spawned the crumb
particles but never played the consumable's eat sound, so only the burp
at the end was heard. Call emit_consume for the periodic emit, as
tick_used_item does on the player's own server, so the bites sound too.

Fixes #2747.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 15:14:34 +01:00
levzzzandminimax-m3.1-flash 3c7942d2bd Test that a hit rolls the armadillo up into a ball
An armadillo hit by a living thing curls up: Armadillo.actuallyHurt
rolls it up, Armadillo.rollUp plays entity.armadillo.roll and switches
the state to ROLLING, and ArmadilloState.shouldHideInShell then shows
the shell instead of the armadillo. The roll itself is on main (#2535),
but nothing held it in place, so this test pins it: a hit rolls the
armadillo up, the roll plays its sound and the model shows the shell
(#2759).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 19:14:31 +05:00
iLostName de89e758cc remote: a server's client doesn't load the loot tables on its first tick 2026-10-27 14:57:27 +01:00
iLostName 5f5a43fd54 focus loss doesn't open the pause menu over the pack prompt or a server dialog 2026-10-27 14:47:53 +01:00
sirrayiandDevin c0bf77f3c3 Telemetry server: count IPv6 clients by /64 in the rate limits
An IPv6 client rotating addresses inside its /64 got a fresh bucket for every address, so the per-address limits on batches, live pings, hub votes, reports and suggestions didn't hold, and the bucket maps grew until the daily upkeep. Key them by /64, as horizon-relay already does.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:45:27 +00:00
sirrayiandDevin ce848af067 Zip: don't trust an entry's declared sizes for allocation
A server resource pack whose zip64 sizes claim ~2^63 bytes aborted the client on Vec::with_capacity. Cap the capacity at the archive's length and the inflate limit at 1 GiB, with saturating arithmetic.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
sirrayiandDevin d3be8dbf05 horizon-server: bound HTTP request and header lines
read_line had no limit, and the read timeout applies per read, so a client streaming bytes without a newline to the API or the web panel grew the supervisor's memory without bound. Lines longer than 8 KiB now get a 400.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:51 +00:00
iLostName 86474679db server packs: the prompt takes the open screen's place, as PackConfirmScreen does 2026-10-27 14:26:17 +01:00
levzzz 152334eee3 Merge remote-tracking branch 'upstream/main' into fix/screen-cursor-centre 2026-10-27 18:23:26 +05:00
levzzzandminimax-m3.1-flash 67e0d54062 tests: give the capture fixture the mouse position helper
The fixture compiles `Game::capture` on its own, so the helper it now
calls has to be part of it: the test extracts it from the client, like it
extracts the methods, and the call inside the fixture loses the crate
path.

The fixture also asserts the grab path now: grabbing the pointer puts
the mouse at the window's centre, as `MouseHandler.grabMouse` does.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:18:07 +05:00
asownerytandClaude Opus 5.5 53cb3a19d1 Name the projectile in the death message when it has no owner
AbstractArrow.onHitEntity makes an ownerless arrow (or trident) its own
causing entity, and fireballs, spit and shulker bullets are the
DamageSource's direct entity. Horizon recorded no entity for an ownerless
projectile's hit, so DamageSource.getLocalizedDeathMessage's one-argument
form showed the raw two-placeholder template (%1$s was shot by %2$s).

Fixes #2802

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 02:16:56 +13:00
Eshayat Al-WasiuandCodex 84390f6dc1 Match server deletion input to button row width
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 9d191c28f2 Narrow server deletion confirmation input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5d42108a98 Place deletion confirmation help below input
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 1972ef3bef Use shared settings row styling for server deletion
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex e8145b07fa Remove redundant server deletion confirmation hint
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:53 +06:00
Eshayat Al-WasiuandCodex 5e7295a631 Align server deletion controls with the settings rows
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 761a770174 Emphasize the server name in the delete confirmation
Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
Eshayat Al-WasiuandCodex 7f0a61954b Add a confirmed server delete action to launcher settings
Move stopped servers to the system trash after name confirmation. Refuse active supervisor locks and background services, and refresh the sidebar after removal.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 19:16:52 +06:00
levzzzandminimax-m3.1-flash a8903cba07 Merge upstream/main into fix/shulker-body-yaw
Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:14:07 +05:00
iLostName 560aa61e91 server packs: the download prompt keeps the pointer and stops the walking behind it 2026-10-27 14:05:45 +01:00
Ludovic MarieandClaude Opus 5.5 0d554de016 parity: game_loop records mouse, window and keymapping from the input crate
The harness hashed crates/horizon-client/src/{mouse,window,keymapping}.rs,
which this branch moves to horizon-client-input, so CI's parity job failed
with FileNotFoundError before running it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:48:09 +01:00
levzzzandminimax-m3.1-flash 00df6c646f The shulker's body stays square to its block while the head turns
Shulker.recreateFromPacket sets the body rotation to 0 and
Shulker.ShulkerBodyRotationControl.clientTick is empty, so a shulker's
body never turns: it stays square to the block it is fixed to and only
its head turns. The fix (#2311) is already on main, but nothing held it
in place: the entity model harness compares the model's parts and cannot
see the entity's own rotation. This test drives the body rotation
control and the look control and pins both halves of the vanilla's
behaviour - the body stays at 0 and the head still turns to its target
(#1661).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:45:49 +05:00
levzzz 1f040d06eb Merge remote-tracking branch 'upstream/main' into fix/screen-cursor-centre 2026-10-27 17:45:16 +05:00
levzzzandminimax-m3.1-flash d13b478ca2 ui: the mouse position is the centre whenever the grab state changes
`MouseHandler.grabMouse` and `MouseHandler.releaseMouse` both put the
mouse at the window's centre, so a screen opened from gameplay starts
with the cursor in the middle. Horizon centred it when the pointer was
released, but not when it was grabbed again, so closing a screen left
the cursor where the screen had it.

The rule is one function now, `mouse_position_after_grab_change`, and the
game applies it on both sides of the grab state change.

Fixes #1925

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:44:55 +05:00
levzzz 91de744250 Merge remote-tracking branch 'upstream/main' into fix/spawn-egg-baby 2026-10-27 17:24:11 +05:00
levzzzandminimax-m3.1-flash 1654cb51db The spawn egg's offspring is a named, tested rule
The behaviour is in main (#2275): `Mob.checkAndHandleImportantInteractions`
uses a spawn egg of the mob's own kind on a mob, and
`SpawnEggItem.spawnOffspringFromSpawnEgg` spawns the offspring as a baby
of that kind at the parent's position, and one egg less in hand.

The rule now lives in the library, where it is named and tested:
`spawn_egg_is_for` (the egg is for the mob's own kind) and
`spawn_egg_offspring` (the baby, with the parent's variant and colour).

Fixes #2269

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:24:09 +05:00
asownerytandClaude Opus 5.5 f105583803 Select World: deleting the last world opens Create New World
WorldSelectionList.handleNewLevels opens a fresh CreateWorldScreen when
the singleplayer list comes back empty, with Cancel going to the title
screen. Horizon went back to an empty list instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 00:42:44 +13:00
levzzzandminimax-m3.1-flash fa940d5b6b Add a regression test for the entity draw distance
The view scale introduced by #2317 (Entity.setViewScale in
LevelExtractor.extractVisibleEntities) decides how far an entity is drawn:
Entity.shouldRenderAtSqrDistance multiplies the bounding box's mean edge by
64 and by that scale, so a chicken (0.4 by 0.7 by 0.4) is drawn up to 48
blocks away at the vanilla defaults. Without the scale it vanished at 32
blocks, the distance reported in #2273. The merged fix carried no test, so
the scale formula now lives in a free function and the draw distance of a
chicken, of a broken bounding box and of every render distance is pinned.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:15 +05:00
Eshayat Al-WasiuandCodex bcdbc252fe Load server details when opening from the launcher sidebar
Use the shared open_server path so stopped servers load settings immediately and switching servers clears stale detail state.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
2026-10-27 17:07:42 +06:00
levzzzandminimax-m3.1-flash 186198afe6 Pin the turtle's walk animation to vanilla's rate
The turtle's flailing feet (#1776) were the walk animation running at its
maximum: the generic MoveControl walked the turtle at its full movement
speed (0.25), so LivingEntity.updateWalkAnimation's min(distance * 4, 1)
saturated at 1.0 and TurtleModel swung the legs at the maximum rate and
amplitude. PR #2565 gave the turtle its own pace, but its tests only pin
the forward input the control sets, not the rate of the animation the
reporter saw. This test pins the symptom: the turtle covers 0.18447
blocks a tick and its walk animation runs at 0.73789, vanilla's
min(distance * 4, 1) for that pace.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:44:33 +05:00
levzzz a77639022c Merge remote-tracking branch 'upstream/main' into fix/fire-animated-textures 2026-10-27 15:40:21 +05:00
levzzz fcc7a818b4 Merge remote-tracking branch 'upstream/main' into fix/mining-hit-sound 2026-10-27 15:36:16 +05:00
levzzzandminimax-m3.1-flash a3e581b56f Keep the animated textures on in every graphics preset
The Performance preset used to turn the animated textures off, which froze
the fire, the water, the lava and the portals: players reported a fire with
no animation (#2064). The presets keep them on now, and a test keeps it
that way, so a preset that freezes the animation again fails the build
instead of the report.

Fixes #2064

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:35:06 +05:00
Ludovic Marie 1db0eda4ae Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input 2026-10-27 11:33:45 +01:00
levzzzandminimax-m3.1-flash 9ebccd6a59 Cover the mining sound cadence in the LAN harness
The fix for #2277 landed in #2350: the server's destroy progress level
events (2019 and 2020) play the block's hit sound, so the digger and the
players around hear the same hits. Nothing checked that, though. Add a LAN
check that holds the attack on a stone block and compares the hit sounds
both sides count, and assert in the remote mining fixture that the client
plays the hit sound once, when the dig starts, and not in the loop. On the
tree before #2350 the check fails: the digger counted 20 hits against the 10
the player nearby heard.

Fixes #2277

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:14:04 +05:00
Ludovic MarieandClaude Opus 5.5 0f565f4e4c refactor: biome's spawn-list parsers live in biome.rs
parse_entries, parse_costs and SpawnCost read the biome JSON's
natural_mob_spawns and build biome::SpawnEntry; biome.rs reached into
mobs::spawn_selection for them, one of the edges that keep the client lib
in a single cycle. They move to biome.rs and spawn_selection re-exports
them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:11:39 +01:00
Ludovic Marie d8cc59dd55 Merge remote-tracking branch 'origin/main' into refactor/horizon-client-input
# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-10-27 11:10:57 +01:00
Ludovic MarieandClaude Opus 5.5 beac63849c refactor: move the client's input modules into horizon-client-input
Key bindings and names, toggle keys, mouse options, controller settings,
the clipboard and window options move to their own crate, re-exported under
their old paths. Their unit tests now rebuild in about a second after an
edit instead of rebuilding the client lib's test binary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:42:36 +01:00
284 changed files with 8167 additions and 1435 deletions

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
# Trusted reviewers: they pick the pull requests the Nightly holds with `/nightly 2640 2642` in any issue or pull
# request (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). Ludovic is always allowed and isn't
# listed. One GitHub login per line, with who added it and when. Removing a line revokes that login at once.
# A line with only `paused` stops every reviewer's `/nightly` (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
+9
View File
@@ -0,0 +1,9 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
+20 -1
View File
@@ -25,6 +25,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
@@ -76,6 +79,7 @@ jobs:
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -103,6 +107,18 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- name: The source of the build (horizon-source.tar.gz)
if: steps.pick.outputs.build == 'true'
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
@@ -144,7 +160,7 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -187,6 +203,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
@@ -198,7 +215,9 @@ jobs:
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
+2 -2
View File
@@ -1,5 +1,5 @@
name: nightly command
# `/nightly …` comments: the trusted reviewers in .github/nightly-reviewers.txt (and Ludovic) pick the pull requests
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
#
@@ -33,7 +33,7 @@ jobs:
persist-credentials: false
sparse-checkout: |
.github/CODEOWNERS
.github/nightly-reviewers.txt
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
sparse-checkout-cone-mode: false
- name: Run the command
+48
View File
@@ -0,0 +1,48 @@
name: triage command
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
# tools/ci/triage_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: >-
github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/close') ||
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/triage_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/triage_command.py command
+4 -3
View File
@@ -39,6 +39,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -58,7 +59,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
@@ -114,7 +115,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
@@ -130,7 +131,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
+4 -1
View File
@@ -86,6 +86,9 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
@@ -98,5 +101,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+55 -6
View File
@@ -876,6 +876,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
@@ -1370,7 +1376,6 @@ name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"arboard",
"bytemuck",
"chrono",
"cpal",
@@ -1380,6 +1385,7 @@ dependencies = [
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-dualsense",
@@ -1398,6 +1404,7 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"open",
"png",
"pollster",
"rustix 1.1.5",
@@ -1422,6 +1429,18 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-client-input"
version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
@@ -1865,6 +1884,25 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2679,6 +2717,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -3194,9 +3243,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4374,14 +4423,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
+5 -2
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
@@ -60,8 +61,10 @@ large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Ludovic
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+4 -4
View File
@@ -3,7 +3,7 @@
</p>
<p align="center">
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
@@ -15,7 +15,7 @@
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
@@ -29,7 +29,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
@@ -99,7 +99,7 @@ Horizon contains no game files and never downloads them. It builds its resources
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
```sh
git clone https://github.com/ludovic111/horizon.git
git clone https://github.com/horizon-rust/horizon.git
cd horizon
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
+66 -66
View File
@@ -32,7 +32,7 @@ Making Horizon faster than vanilla on every computer, with smooth camera movemen
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
</details>
@@ -42,7 +42,7 @@ Speeding up the code that reads and writes the blocks of the world, which almost
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
</details>
@@ -54,7 +54,7 @@ Making the world-generation mods of popular modpacks (new structures, YUNG's dun
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -66,7 +66,7 @@ When you play on a server, other players, mobs, items and menus look and behave
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
</details>
@@ -76,7 +76,7 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
</details>
@@ -86,7 +86,7 @@ A /pregen command that builds an area of the world ahead of time, so players don
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
</details>
@@ -98,7 +98,7 @@ One place for mods, modpacks and resource packs made for Horizon: browse them on
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
More: [docs/mods.md](docs/mods.md)
@@ -110,7 +110,7 @@ Mods that run on the server side of a world, so a dedicated server or a LAN worl
<details><summary>Technical</summary>
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
</details>
@@ -120,7 +120,7 @@ Mods will be able to open their own screens and settings pages and react to keys
<details><summary>Technical</summary>
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
More: [docs/mods.md](docs/mods.md)
@@ -132,7 +132,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
<details><summary>Technical</summary>
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
@@ -146,7 +146,7 @@ Single player now runs a small server in the background, as vanilla does. We are
<details><summary>Technical</summary>
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -156,7 +156,7 @@ Item names, container titles and server messages follow the language you pick, a
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
</details>
@@ -166,9 +166,9 @@ After you throw an ender pearl, its slot should show the cooldown sweeping away,
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
</details>
@@ -178,9 +178,9 @@ The Hidden and Commands Only chat settings should hide chat, and team prefixes s
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
</details>
@@ -190,9 +190,9 @@ The mouse pointer should turn into a pointing hand over menu buttons, as in vani
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
</details>
@@ -202,9 +202,9 @@ Ctrl+Backspace should delete a whole word in the creative search, and every list
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
</details>
@@ -214,9 +214,9 @@ Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
</details>
@@ -228,9 +228,9 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
</details>
@@ -240,7 +240,7 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
</details>
@@ -252,7 +252,7 @@ Your Discord profile can show that you are playing Horizon, with a switch to tur
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
</details>
@@ -264,7 +264,7 @@ Cleaning up Horizon's code: removing what nothing uses and making it quicker to
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
@@ -278,7 +278,7 @@ New public numbers comparing Horizon with vanilla on many kinds of computers, me
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
Redo the comparison after the current performance work ([#1073](https://github.com/horizon-rust/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/horizon-rust/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/horizon-rust/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
</details>
@@ -288,7 +288,7 @@ Creating a world and joining it should be quick even on a four-thread laptop, wh
<details><summary>Technical</summary>
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/horizon-rust/horizon/issues/1760) and [#1778](https://github.com/horizon-rust/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
</details>
@@ -320,7 +320,7 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
<details><summary>Technical</summary>
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
</details>
@@ -330,7 +330,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
@@ -364,7 +364,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -386,7 +386,7 @@ Set up Horizon, a server first, without Java on your computer: published tables
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
@@ -406,7 +406,7 @@ Smoother hosting: a real Windows service, a backup before every update, backups
<details><summary>Technical</summary>
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/horizon-rust/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
</details>
@@ -464,7 +464,7 @@ Mods that add machines: blocks that tick, hold items and have their own menus. T
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
</details>
@@ -486,7 +486,7 @@ Pistons and slime blocks will push players, mobs and items as in vanilla. Today
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
</details>
@@ -506,7 +506,7 @@ Villages and fights behaving as in vanilla: iron golems defending villages, vill
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
</details>
@@ -516,7 +516,7 @@ Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
</details>
@@ -544,7 +544,7 @@ Coming soon: sign in with your Microsoft account to play on online-mode servers.
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
</details>
@@ -566,7 +566,7 @@ Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
</details>
@@ -576,7 +576,7 @@ Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboar
<details><summary>Technical</summary>
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
@@ -626,7 +626,7 @@ Safer releases: changes to the release machinery need the owner's review, and AI
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
@@ -636,7 +636,7 @@ Every download Horizon makes is checked against the project's signature, includi
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
</details>
@@ -650,7 +650,7 @@ Far terrain drawn from the world you really built and explored, caves and builds
<details><summary>Technical</summary>
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
</details>
@@ -692,7 +692,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
</details>
@@ -704,7 +704,7 @@ Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible
<details><summary>Technical</summary>
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
@@ -748,7 +748,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
<details><summary>Technical</summary>
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
</details>
@@ -790,7 +790,7 @@ Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captur
<details><summary>Technical</summary>
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/horizon-rust/horizon/issues/562) asked for the F3 editor).
</details>
@@ -814,7 +814,7 @@ An optional ray-tracing mode for graphics cards that support it (RTX and recent
<details><summary>Technical</summary>
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/horizon-rust/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
</details>
@@ -824,7 +824,7 @@ Output for HDR monitors: brighter highlights and richer colour where your screen
<details><summary>Technical</summary>
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
@@ -858,7 +858,7 @@ Game and launcher builds for 64-bit ARM Linux computers.
<details><summary>Technical</summary>
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
</details>
@@ -868,7 +868,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
@@ -912,7 +912,7 @@ An optional AI teammate you can talk to by voice. It plays beside you, helps or
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/horizon-rust/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/horizon-rust/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
</details>
@@ -938,7 +938,7 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/horizon-rust/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
@@ -952,7 +952,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
</details>
@@ -962,19 +962,19 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
</details>
## Recently shipped
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/horizon-rust/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/horizon-rust/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/horizon-rust/horizon/issues/1199) · [#1029](https://github.com/horizon-rust/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/horizon-rust/horizon/issues/1154) · [#1242](https://github.com/horizon-rust/horizon/issues/1242) · [#1243](https://github.com/horizon-rust/horizon/issues/1243) · [#1246](https://github.com/horizon-rust/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/horizon-rust/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/horizon-rust/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/horizon-rust/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
@@ -119,7 +119,7 @@ machines during the runs.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+1 -1
View File
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+4
View File
@@ -97,6 +97,9 @@ impl Report {
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
@@ -118,6 +121,7 @@ impl Report {
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
+1 -1
View File
@@ -25,7 +25,7 @@ use std::{
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
+2 -2
View File
@@ -1,5 +1,5 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
@@ -8,7 +8,7 @@ use horizon_mods::index::{
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
@@ -195,6 +195,11 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
}
@@ -16,3 +16,20 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
/// characters, `minecraft` when the namespace is left out.
pub fn normalize_id(s: &str) -> Option<String> {
let (ns, path) = match s.split_once(':') {
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
None => ("minecraft", s),
};
let ok = |c: char, slash: bool| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| matches!(c, '_' | '-' | '.')
|| slash && c == '/'
};
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
.then(|| format!("{ns}:{path}"))
}
@@ -22,3 +22,11 @@ pub fn number(tag: Option<&Tag>) -> Option<i64> {
_ => return None,
})
}
/// Any numeric tag as f64, keeping a float's or double's fraction.
pub fn float(tag: Option<&Tag>) -> Option<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
Tag::Double(b) => f64::from_bits(*b),
other => number(Some(other))? as f64,
})
}
+8
View File
@@ -50,6 +50,9 @@ pub enum ClickEvent {
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -557,6 +560,11 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
ClickEvent::ShowDialog(dialog) => json!({
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = ["dep:horizon-dualsense"]
[lints]
workspace = true
File renamed without changes.
@@ -145,6 +145,22 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.period", KeyCode::Period),
("key.keyboard.slash", KeyCode::Slash),
("key.keyboard.caps.lock", KeyCode::CapsLock),
("key.keyboard.world.1", KeyCode::IntlBackslash),
("key.keyboard.international1", KeyCode::IntlRo),
("key.keyboard.international3", KeyCode::IntlYen),
("key.keyboard.lang1", KeyCode::Lang1),
("key.keyboard.lang2", KeyCode::Lang2),
("key.keyboard.lang3", KeyCode::Lang3),
("key.keyboard.lang4", KeyCode::Lang4),
("key.keyboard.lang5", KeyCode::Lang5),
("key.keyboard.help", KeyCode::Help),
("key.keyboard.select", KeyCode::Select),
("key.keyboard.again", KeyCode::Again),
("key.keyboard.undo", KeyCode::Undo),
("key.keyboard.cut", KeyCode::Cut),
("key.keyboard.copy", KeyCode::Copy),
("key.keyboard.paste", KeyCode::Paste),
("key.keyboard.find", KeyCode::Find),
("key.keyboard.f1", KeyCode::F1),
("key.keyboard.f2", KeyCode::F2),
("key.keyboard.f3", KeyCode::F3),
@@ -157,6 +173,18 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.f10", KeyCode::F10),
("key.keyboard.f11", KeyCode::F11),
("key.keyboard.f12", KeyCode::F12),
("key.keyboard.f13", KeyCode::F13),
("key.keyboard.f14", KeyCode::F14),
("key.keyboard.f15", KeyCode::F15),
("key.keyboard.f16", KeyCode::F16),
("key.keyboard.f17", KeyCode::F17),
("key.keyboard.f18", KeyCode::F18),
("key.keyboard.f19", KeyCode::F19),
("key.keyboard.f20", KeyCode::F20),
("key.keyboard.f21", KeyCode::F21),
("key.keyboard.f22", KeyCode::F22),
("key.keyboard.f23", KeyCode::F23),
("key.keyboard.f24", KeyCode::F24),
("key.keyboard.print.screen", KeyCode::PrintScreen),
("key.keyboard.scroll.lock", KeyCode::ScrollLock),
("key.keyboard.pause", KeyCode::Pause),
@@ -188,6 +216,36 @@ const KEYS: &[(&str, KeyCode)] = &[
("key.keyboard.keypad.0", KeyCode::Numpad0),
("key.keyboard.keypad.period", KeyCode::NumpadDecimal),
("key.keyboard.keypad.equal", KeyCode::NumpadEqual),
("key.keyboard.keypad.comma", KeyCode::NumpadComma),
("key.keyboard.keypad.backspace", KeyCode::NumpadBackspace),
(
"key.keyboard.keypad.left.parenthesis",
KeyCode::NumpadParenLeft,
),
(
"key.keyboard.keypad.right.parenthesis",
KeyCode::NumpadParenRight,
),
("key.keyboard.keypad.hash", KeyCode::NumpadHash),
(
"key.keyboard.keypad.memory.store",
KeyCode::NumpadMemoryStore,
),
(
"key.keyboard.keypad.memory.recall",
KeyCode::NumpadMemoryRecall,
),
(
"key.keyboard.keypad.memory.clear",
KeyCode::NumpadMemoryClear,
),
("key.keyboard.keypad.memory.add", KeyCode::NumpadMemoryAdd),
(
"key.keyboard.keypad.memory.subtract",
KeyCode::NumpadMemorySubtract,
),
("key.keyboard.keypad.clear", KeyCode::NumpadClear),
("key.keyboard.keypad.clear.entry", KeyCode::NumpadClearEntry),
("key.keyboard.menu", KeyCode::ContextMenu),
("key.keyboard.left.control", KeyCode::ControlLeft),
("key.keyboard.left.shift", KeyCode::ShiftLeft),
+14
View File
@@ -0,0 +1,14 @@
//! The client's input layer, below gameplay and screens: key bindings and their
//! names, toggle keys, mouse settings, controllers, the clipboard and window
//! options. horizon-client re-exports each module under its old path
//! (`horizon_client::keybinds`, ...).
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod input;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod mouse;
pub mod window;
@@ -324,6 +324,24 @@ pub fn ease_in_out(t: f64) -> f64 {
0.5 - 0.5 * (std::f64::consts::PI * t).cos()
}
/// `MouseHandler.grabMouse` / `releaseMouse`: the mouse position is the
/// window's centre whenever the pointer's grab state changes, so a screen
/// opened from gameplay starts with the cursor in the middle. A release of
/// an already free pointer (or a grab of an already grabbed one) leaves the
/// position where it is, as vanilla does.
pub fn mouse_position_after_grab_change(
grab: bool,
was_grabbed: bool,
size: [u32; 2],
scale: f32,
) -> Option<(f32, f32)> {
if grab == was_grabbed {
None
} else {
Some((size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -485,4 +503,27 @@ mod tests {
assert_eq!(look.on_scroll(0.0, 0.4, false, 1.0), None);
assert_eq!(look.on_scroll(0.0, 0.7, false, 1.0), Some((1, (0, 1))));
}
#[test]
fn the_mouse_position_is_the_centre_when_the_grab_changes() {
// A grab of a free pointer and a release of a grabbed one both put
// the position at the window's centre (`MouseHandler.grabMouse`,
// `MouseHandler.releaseMouse`).
assert_eq!(
mouse_position_after_grab_change(true, false, [1920, 1080], 1.0),
Some((960.0, 540.0))
);
assert_eq!(
mouse_position_after_grab_change(false, true, [1920, 1080], 2.0),
Some((480.0, 270.0))
);
// Without a change of the grab state the position stays where it is.
assert_eq!(
mouse_position_after_grab_change(false, false, [1920, 1080], 1.0),
None
);
assert_eq!(
mouse_position_after_grab_change(true, true, [1920, 1080], 1.0),
None
);
}
}
+4 -2
View File
@@ -10,6 +10,7 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
@@ -55,7 +56,6 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -70,6 +70,8 @@ unicode-bidi = "0.3.18"
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
@@ -90,7 +92,7 @@ default = ["audio", "controllers"]
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
@@ -161,6 +161,7 @@ pub fn main() -> Result<()> {
"sound {} {:08x} {:08x}",
match sound {
ArrowSound::HitGround => "hit",
ArrowSound::CrossbowHit => "crossbow_hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
@@ -369,6 +369,7 @@ pub fn main() -> Result<()> {
banner: &|_| layers.clone(),
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
glyphs: atlas.font.epoch(),
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
@@ -87,7 +87,11 @@ pub fn main() {
}
"tick" => {
out.push_str("tick");
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
for (p, _) in entities
.tickers(|p| states.get(&p).copied().unwrap_or(0))
.iter()
.copied()
{
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
}
out.push('\n');
@@ -7,6 +7,7 @@
use horizon_client::{
containers::{Furnace, FurnaceKind},
crafting::Recipes,
furnace::FurnaceCooking,
item_data::{canon_stack, parse_stack},
items::Items,
menu::{Env, Input, Kind, Menu},
@@ -1121,6 +1121,7 @@ pub fn main() -> horizon_client::Result<()> {
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
+54 -2
View File
@@ -444,6 +444,8 @@ pub struct EntityView {
pub gamemode: Option<String>,
/// Lightning: entities it struck.
pub struck: Vec<EntityView>,
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
pub blocks_set_on_fire: i32,
pub fishing_open_water: Option<bool>,
pub variant: Option<String>,
/// The data components the entity reports (`Entity.get`): id and value.
@@ -538,6 +540,11 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
{
return false;
}
if let Some(d) = comps["minecraft:damage"].as_u64()
&& u64::from(it.damage) != d
{
return false;
}
let preds = &v["predicates"];
if let Some(list) = preds["minecraft:enchantments"].as_array() {
for e in list {
@@ -734,7 +741,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
&& (p["entity_struck"].is_null()
|| e.struck
.iter()
@@ -818,7 +825,13 @@ pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> boo
{
return false;
}
let ty = &v["type"];
damage_source(&v["type"], d, w, origin)
}
/// `DamageSourcePredicate`: the damage type's tags and the entities behind it.
pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if ty.is_null() {
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);
@@ -1326,8 +1339,47 @@ mod tests {
fn in_tag(&self, _: &str, tag: &str, id: &str) -> bool {
(tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone")
|| (tag == "minecraft:sulfur_cube_archetype/explosive" && id == "minecraft:tnt")
|| (tag == "minecraft:is_projectile"
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
/// projectile, kills it for the advancement; a sword or an arrow does not.
#[test]
fn killing_blow_needs_the_fireball() {
let blow = serde_json::json!({
"direct_entity": {"minecraft:entity_type": "minecraft:fireball"},
"tags": [{"expected": true, "id": "#minecraft:is_projectile"}]
});
let entity = |kind: &str| EntityView {
kind: kind.into(),
..Default::default()
};
let player = entity("minecraft:player");
let fireball = DamageView {
kind: "minecraft:fireball".into(),
direct: Some(entity("minecraft:fireball")),
source: Some(player.clone()),
..Default::default()
};
assert!(damage_source(&blow, &fireball, &Flat, [0.0; 3]));
let sword = DamageView {
kind: "minecraft:player_attack".into(),
direct: Some(player.clone()),
source: Some(player.clone()),
is_direct: true,
..Default::default()
};
assert!(!damage_source(&blow, &sword, &Flat, [0.0; 3]));
let arrow = DamageView {
kind: "minecraft:arrow".into(),
direct: Some(entity("minecraft:arrow")),
source: Some(player),
..Default::default()
};
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
}
#[test]
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
+10
View File
@@ -130,6 +130,8 @@ pub enum ArrowSound {
Splash,
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
HitGround,
/// An arrow shot from a crossbow: `item.crossbow.hit`.
CrossbowHit,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
@@ -782,8 +784,16 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
// CrossbowItem.createProjectile: its arrows hit with the crossbow's sound.
let crossbow = self
.st
.fired_from_weapon
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:crossbow");
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else if crossbow {
ArrowSound::CrossbowHit
} else {
ArrowSound::HitGround
};
+2
View File
@@ -218,6 +218,8 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
+59 -3
View File
@@ -20,12 +20,68 @@ pub struct SpawnEntry {
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
/// A `natural_mob_spawns` / `spawn_overrides` entry list.
pub fn parse_entries(list: &Value) -> Vec<SpawnEntry> {
list.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let count = &e["count"];
let (min, max, constant) = match count {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c if c["type"].as_str() == Some("minecraft:constant") => {
let n = c["value"].as_u64().unwrap_or(1) as u32;
(n, n, true)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
false,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
constant,
})
})
.collect()
}
/// `MobSpawnSettings.MobSpawnCost`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpawnCost {
pub energy_budget: f64,
pub charge: f64,
}
/// The `spawn_costs` of a `natural_mob_spawns` value.
pub fn parse_costs(costs: &Value) -> HashMap<String, SpawnCost> {
costs
.as_object()
.into_iter()
.flatten()
.map(|(k, c)| {
(
k.clone(),
SpawnCost {
energy_budget: c["energy_budget"].as_f64().unwrap_or(0.0),
charge: c["charge"].as_f64().unwrap_or(0.0),
},
)
})
.collect()
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
pub spawn_costs: HashMap<String, SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
/// The `gameplay/increased_fire_burnout` attribute (jungles, swamps, peaks,
@@ -150,12 +206,12 @@ impl Biomes {
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = crate::mobs::spawn_selection::parse_entries(entries);
let list = parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
spawn_costs: parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
+10 -9
View File
@@ -26,6 +26,7 @@
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
pub type Pos = (i32, i32, i32);
@@ -46,7 +47,7 @@ pub enum Ticking {
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Vec<(Pos, u32)>,
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
live: FxHashMap<Pos, u32>,
/// Positions that may need a slot, in the order vanilla would have made
@@ -233,7 +234,7 @@ impl BlockEntities {
}
pub fn clear(&mut self) {
self.map.clear();
self.tickers.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
self.pending.clear();
@@ -274,7 +275,7 @@ impl BlockEntities {
/// and freed ones leave it. `state` reads the block state at a position.
/// Returns the slots to tick, in order; check each with `is_live` before
/// ticking it, as the ticks before it can free it.
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Vec<(Pos, u32)> {
pub fn tickers(&mut self, state: impl Fn(Pos) -> u32) -> Arc<Vec<(Pos, u32)>> {
for (pos, token) in std::mem::take(&mut self.added) {
if self.pending.get(&pos) != Some(&token) {
continue;
@@ -288,21 +289,21 @@ impl BlockEntities {
}
let slot = self.next();
self.live.insert(pos, slot);
self.tickers.push((pos, slot));
Arc::make_mut(&mut self.tickers).push((pos, slot));
}
if !std::mem::take(&mut self.freed) && self.out.is_empty() {
return self.tickers.clone();
return Arc::clone(&self.tickers);
}
self.out.clear();
let (live, map) = (&mut self.live, &self.map);
self.tickers.retain(|(p, slot)| {
Arc::make_mut(&mut self.tickers).retain(|(p, slot)| {
let mine = live.get(p) == Some(slot);
if mine && !map.contains_key(p) {
live.remove(p);
}
mine && map.contains_key(p)
});
self.tickers.clone()
Arc::clone(&self.tickers)
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
@@ -329,8 +330,8 @@ mod tests {
}
fn order(b: &mut BlockEntities, states: &HashMap<Pos, u32>) -> Vec<Pos> {
b.tickers(|p| states.get(&p).copied().unwrap_or(0))
.into_iter()
.map(|(p, _)| p)
.iter()
.map(|(p, _)| *p)
.collect()
}
@@ -435,7 +435,8 @@ pub struct BellTick {
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
}
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
/// `Vec3i.closerToCenterThan`: `p` is within `distance` of the block's centre.
pub fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
let dx = f64::from(pos[0]) + 0.5 - p[0];
let dy = f64::from(pos[1]) + 0.5 - p[1];
let dz = f64::from(pos[2]) + 0.5 - p[2];
@@ -35,6 +35,25 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
/// permission: a spawn egg carries data of an op-only entity type
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
stack.item.ends_with("_spawn_egg")
&& matches!(
stack
.data()
.and_then(|d| d.other("minecraft:entity_data"))
.and_then(|data| data.get("id")),
Some(Tag::String(id)) if matches!(
id.to_string_lossy().as_str(),
"minecraft:falling_block"
| "minecraft:command_block_minecart"
| "minecraft:spawner_minecart"
)
)
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
@@ -97,6 +97,16 @@ pub fn diffuse(normal: Vec3, lights: (Vec3, Vec3)) -> f32 {
pub fn vertex_light(packed: u8) -> u32 {
(u32::from(packed >> 4) * 16) | ((u32::from(packed & 15) * 16) << 8)
}
/// `ChestRenderer.xmasTextures`: `SpecialDates.isExtendedChristmas` (24 to 26
/// December in the local time zone), read once when the renderer is created.
fn extended_christmas() -> bool {
use chrono::Datelike;
static XMAS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*XMAS.get_or_init(|| {
let now = chrono::Local::now();
now.month() == 12 && (24..=26).contains(&now.day())
})
}
fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
@@ -587,6 +597,18 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
line.to_owned()
}
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
/// the sign's centre.
fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(camera)
< 256.0
}
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
/// full-bright with an 8x outline in the dark colour.
fn sign_text_calls(
@@ -747,6 +769,9 @@ pub struct Scene<'a> {
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),
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
}
pub struct BlockEntityRenderer {
@@ -758,7 +783,30 @@ pub struct BlockEntityRenderer {
pot_patterns: HashMap<String, String>,
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
visibility: Option<Mutex<FaceVisibility>>,
sign_texts: Mutex<SignTexts>,
}
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
/// again each frame, glyph by glyph; its vertices only change with what is
/// in the key, so they are kept relative to the block and moved to the camera.
#[derive(Default)]
struct SignTexts {
signs: crate::fast_hash::FxHashMap<[i32; 3], SignTextEntry>,
/// Game time of the last sweep of signs out of view.
swept: i64,
}
struct SignTextEntry {
state: u32,
data: crate::signs::SignData,
packed: u8,
/// Within the glowing text outline's distance (only kept for glowing text).
near: bool,
glyphs: u64,
/// Game time it was last drawn.
used: i64,
vertices: Vec<Vertex>,
}
/// Game ticks a sign's text is kept after it was last drawn.
const SIGN_TEXT_KEEP: i64 = 100;
const MODELS: [(&str, &str); 27] = [
("chest", "minecraft:chest#main"),
("double_chest_left", "minecraft:double_chest_left#main"),
@@ -888,6 +936,7 @@ impl BlockEntityRenderer {
closed_chests,
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
}
}
fn kind(&self, state: u32) -> &Kind {
@@ -922,11 +971,81 @@ impl BlockEntityRenderer {
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let base = Mat4::from_translation(origin);
let packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
self.sign_text(scene, *pos, *state, origin, packed, out);
continue;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, *pos, kind, base, packed, lights, false, true, out);
}
}
/// A sign's text from the cache, laid out again when its data, light,
/// state, outline or font changed.
fn sign_text(
&self,
scene: &Scene,
pos: [i32; 3],
state: u32,
origin: Vec3,
packed: u8,
out: &mut Vec<Vertex>,
) {
let Some(data) = (scene.sign)(pos) else {
return;
};
let near = (data.front.glowing || data.back.glowing) && sign_text_near(pos, scene.camera);
let Ok(mut cache) = self.sign_texts.lock() else {
return;
};
let now = scene.game_time;
if (now - cache.swept).abs() >= SIGN_TEXT_KEEP {
cache.swept = now;
cache
.signs
.retain(|_, e| (now - SIGN_TEXT_KEEP..=now).contains(&e.used));
}
let glyphs = scene.glyphs;
let signs = &mut cache.signs;
if !signs.get(&pos).is_some_and(|e| {
(e.state, e.packed, e.near, e.glyphs) == (state, packed, near, glyphs)
&& e.data == *data
}) {
let mut vertices = signs.remove(&pos).map_or_else(Vec::new, |e| e.vertices);
vertices.clear();
self.render_one(
scene,
pos,
self.kind(state),
Mat4::IDENTITY,
packed,
diffuse_lights(scene.nether),
false,
true,
&mut vertices,
);
signs.insert(
pos,
SignTextEntry {
state,
data: data.clone(),
packed,
near,
glyphs,
used: now,
vertices,
},
);
}
let Some(entry) = signs.get_mut(&pos) else {
return;
};
entry.used = now;
out.extend(entry.vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
}
/// One block entity at `pos` drawn under `base` with the given packed light
/// (the parity fixture's entry point).
pub fn render_block(
@@ -1158,13 +1277,7 @@ impl BlockEntityRenderer {
m *= Mat4::from_translation(Vec3::new(0.0, 1.0 / 3.0, 0.046_666_667))
* Mat4::from_scale(s * 0.010_416_667);
}
let near = DVec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
)
.distance_squared(scene.camera)
< 256.0;
let near = sign_text_near(pos, scene.camera);
let calls = sign_text_calls(scene, data, text, m, packed, near);
match scene.texts {
Some(sink) => sink.borrow_mut().extend(calls),
@@ -1173,11 +1286,25 @@ impl BlockEntityRenderer {
}
}
Kind::Chest {
block,
texture,
part,
y_rot,
..
} => {
// ChestRenderer.getChestMaterial: normal and trapped chests use the christmas
// sprites on 24-26 December. Copper and ender chests keep theirs.
let texture = if matches!(block.as_str(), "chest" | "trapped_chest")
&& extended_christmas()
{
match part {
ChestPart::Single => "entity/chest/christmas",
ChestPart::Left => "entity/chest/christmas_left",
ChestPart::Right => "entity/chest/christmas_right",
}
} else {
*texture
};
let model = match part {
ChestPart::Single => "chest",
ChestPart::Left => "double_chest_left",
@@ -1930,6 +2057,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -2130,6 +2258,128 @@ mod tests {
assert_eq!(out[2].uv, [0.5, 0.5]);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
models: HashMap::new(),
kinds: vec![
Kind::Sign {
hanging: false,
wall: false,
angle: 180.0,
},
Kind::Sign {
hanging: true,
wall: true,
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let text = |line: &str, glowing| crate::signs::SignText {
lines: [line.into(), "two".into(), String::new(), "four".into()],
color: 3,
glowing,
};
let first_text = crate::signs::SignData {
front: text("one", false),
..Default::default()
};
let changed = crate::signs::SignData {
front: text("changed", false),
..Default::default()
};
let glowing = crate::signs::SignData {
back: text("glow", true),
..changed.clone()
};
let wide = std::cell::Cell::new(6.0);
let glyph = |c: char| {
let u = f32::from(c as u8 % 16) / 16.0;
let rect = Rect {
u0: u,
v0: 0.0,
u1: u + 0.0625,
v1: 0.0625,
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
|_| Some(&changed),
|_| Some(&glowing),
);
let mut scene = Scene {
camera: DVec3::new(3.25, 70.5, -4.75),
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &first_sign,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
renderer.render(scene, [(pos, state)].iter(), &mut cached);
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
let mut original = Vec::new();
renderer.render_one(
scene,
pos,
renderer.kind(state),
Mat4::from_translation((corner - scene.camera).as_vec3()),
world.light(pos[0], pos[1], pos[2]),
diffuse_lights(false),
false,
true,
&mut original,
);
assert!(!cached.is_empty());
assert_eq!(cached.len(), original.len());
for (a, b) in cached.iter().zip(&original) {
assert!((Vec3::from_array(a.pos) - Vec3::from_array(b.pos)).length() < 1e-5);
assert_eq!((a.uv, a.color, a.light), (b.uv, b.color, b.light));
}
cached.len()
};
let (pos, other) = ([1, 70, 2], [-3, 64, 9]);
let first = check(&scene, pos, 0);
// The camera moves: the same text, moved.
scene.camera.x += 0.5;
check(&scene, pos, 0);
// New text, glowing text near and far, another state, other glyphs.
scene.sign = &changed_sign;
assert_ne!(check(&scene, pos, 0), first);
scene.sign = &glowing_sign;
check(&scene, pos, 0);
scene.camera.z -= 20.0;
check(&scene, pos, 0);
check(&scene, pos, 1);
check(&scene, other, 1);
wide.set(5.0);
scene.glyphs = 1;
check(&scene, pos, 1);
// Signs out of view for a while are dropped.
scene.game_time = 2 * SIGN_TEXT_KEEP;
check(&scene, pos, 0);
assert_eq!(renderer.sign_texts.lock().unwrap().signs.len(), 1);
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
@@ -2190,6 +2440,7 @@ mod tests {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
+1 -1
View File
@@ -4,8 +4,8 @@
//! `BrewingStandBlockEntity.serverTick`.
pub use crate::chunk::block_entity::BrewingStand;
use crate::{
containers::Providers,
items::{ItemStack, Items},
providers::Providers,
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
+21
View File
@@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option:
pose
}
/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's
/// projection after the bob: a skew about a tilted axis, from a portal's or
/// Nausea's intensity (already scaled by Distortion Effects) and the angle in
/// degrees. The first-person hand keeps its own projection without it.
pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 {
if intensity <= 0.0 {
return glam::Mat4::IDENTITY;
}
let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04;
skew *= skew;
let axis = glam::Vec3::new(
0.0,
std::f32::consts::SQRT_2 / 2.0,
std::f32::consts::SQRT_2 / 2.0,
);
let angle = angle_degrees * DEG_TO_RAD;
glam::Mat4::from_axis_angle(axis, angle)
* glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0))
* glam::Mat4::from_axis_angle(axis, -angle)
}
#[cfg(test)]
mod tests {
#[test]
+5 -4
View File
@@ -2,8 +2,9 @@
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
pub use crate::chunk::block_entity::CommandBlock;
use crate::{
save::{number, text},
use horizon_client_core::{
nbt_value::{number, text},
scoreboard,
text::Text,
};
use horizon_world::nbt::Tag;
@@ -108,7 +109,7 @@ impl CommandBlock {
last_output: track_output
.then(|| tag.get("LastOutput"))
.flatten()
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
.map(|t| scoreboard::comp_text(&scoreboard::tag_to_json(t))),
update_last_execution,
last_execution: if update_last_execution {
number(tag.get("LastExecution")).unwrap_or(-1)
@@ -164,7 +165,7 @@ impl CommandBlock {
pub fn name(&self) -> Text {
self.custom_name.as_ref().map_or_else(
|| Text::literal("@"),
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|t| scoreboard::comp_text(&scoreboard::tag_to_json(t)),
)
}
}
+119 -25
View File
@@ -15,33 +15,11 @@ pub struct Confirm {
pub buttons: [(f32, f32); 2],
}
impl Confirm {
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
let Choice { lines, buttons, .. } = Choice::new(font, size, title, message, 2, 150.0);
Self {
lines,
buttons: [(bx, by), (bx + 154.0, by)],
buttons: [buttons[0], buttons[1]],
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
@@ -84,3 +62,119 @@ impl Confirm {
}
}
}
/// `ConfirmScreen` with a row of `count` buttons `width` wide, 4 apart: `ConfirmLinkScreen`'s
/// yes, copy and no are three of 100. `Confirm` is its two of 150.
pub struct Choice {
/// Each line and where it is drawn.
pub lines: Vec<(Text, f32, f32)>,
/// The buttons' corners, left to right.
pub buttons: Vec<(f32, f32)>,
pub width: f32,
}
impl Choice {
pub fn new(
font: &Font,
(w, h): (f32, f32),
title: &Text,
message: &Text,
count: usize,
width: f32,
) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let row = width * count as f32 + 4.0 * count.saturating_sub(1) as f32;
let col = tw.max(mw).max(row);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(row), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines,
buttons: (0..count)
.map(|i| (bx + (width + 4.0) * i as f32, by))
.collect(),
width,
}
}
/// The button a click at `cursor` hits, counted from the left.
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option<usize> {
self.buttons
.iter()
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
}
/// The lines and the buttons, over whatever is behind.
pub fn draw(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: &[Text],
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for (&(bx, by), label) in self.buttons.iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hud::Rect;
use std::{collections::HashMap, path::Path};
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
};
Font::load(
Path::new("/nonexistent-horizon-confirm-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
rect,
)
}
#[test]
fn three_buttons_sit_four_apart_in_the_middle() {
let (title, message) = (Text::literal("Open?"), Text::literal("https://x"));
let c = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
let xs: Vec<f32> = c.buttons.iter().map(|b| b.0).collect();
// 3 x 100 + 2 x 4 = 308 wide, centred on 480.
assert_eq!(xs, vec![86.0, 190.0, 294.0]);
assert_eq!(c.pressed_at((191.0, c.buttons[1].1 + 1.0)), Some(1));
assert_eq!(c.pressed_at((188.0, c.buttons[1].1 + 1.0)), None);
// Confirm keeps its two 150-wide buttons where they were.
let two = Confirm::new(&font(), (480.0, 270.0), &title, &message);
assert_eq!(two.buttons[0].0, 88.0);
assert_eq!(two.buttons[1].0, 242.0);
}
}
+3 -283
View File
@@ -5,12 +5,8 @@ pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
use crate::items::ItemStack;
use std::collections::HashMap;
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
@@ -380,283 +376,7 @@ impl BlockEntity {
}
}
}
/// Evaluator for `context_int_provider` / `context_float_provider` values.
pub struct Providers {
ints: HashMap<String, Value>,
floats: HashMap<String, Value>,
}
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
if p.is_dir() {
load_dir(&p, &format!("{prefix}{name}/"), out);
} else if let Ok(bytes) = fs::read(&p)
&& let Ok(v) = serde_json::from_slice(&bytes)
{
out.insert(format!("minecraft:{prefix}{name}"), v);
}
}
}
impl Providers {
pub fn load(root: &Path) -> Self {
let mut ints = HashMap::new();
let mut floats = HashMap::new();
load_dir(
&root.join("data/minecraft/context_int_provider"),
"",
&mut ints,
);
load_dir(
&root.join("data/minecraft/context_float_provider"),
"",
&mut floats,
);
Self { ints, floats }
}
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
if depth > 16 {
return 0.0;
}
match v {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(name) => table
.get(name)
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
Value::Object(o) => {
let f =
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
match o.get("type").and_then(Value::as_str).unwrap_or("") {
"minecraft:div" => {
let r = f("right");
if r == 0.0 {
0.0
} else {
(f("left") / r).trunc()
}
}
"minecraft:mul" => f("left") * f("right"),
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
"minecraft:sub" => f("left") - f("right"),
"minecraft:constant" => f("value"),
"minecraft:conditional" => {
// The only condition in the pinned data: block/fast_cooking.
let condition = o.get("condition").and_then(Value::as_str)
== Some("minecraft:block/fast_cooking");
if condition && fast {
f("on_true")
} else {
f("on_false")
}
}
_ => 0.0,
}
}
_ => 0.0,
}
}
/// A plain `context_int_provider` value or reference (no context conditions).
pub fn int_value(&self, v: &Value) -> i32 {
Self::eval(v, false, &self.ints, 0) as i32
}
/// A plain `context_float_provider` value or reference; `default` when absent.
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
if v.is_null() {
default
} else {
Self::eval(v, false, &self.floats, 0) as f32
}
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
ints: HashMap::new(),
floats: HashMap::new(),
}
}
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
}
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(1.0, |(_, speed)| {
Self::eval(speed, fast, &self.floats, 0) as f32
})
}
}
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl Furnace {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
pub fn tick(
&mut self,
recipes: &Recipes,
items: &Items,
providers: &Providers,
) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
pub use crate::providers::Providers;
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
+25 -3
View File
@@ -355,6 +355,19 @@ impl Creative {
pub fn search_items(&self) -> &[ItemStack] {
&self.search_items
}
/// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose
/// init() makes a fresh search box and a zeroed scrollbar; only the tab
/// persists (its static selectedTab). selectTab on the reopened screen
/// takes the oldTab != tab branch, so it empties and refocuses the box.
pub fn reopen(&mut self) {
self.search.set_value("");
self.release();
self.scroll = 0.0;
self.search.focused = self.tab().search;
if self.search.focused {
self.search.reset_blink();
}
}
/// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults
/// shows for `term`: the whole tab when empty, the tag tree after `#`, else
/// the name tree, both over vanilla's suffix arrays (substring matches,
@@ -575,6 +588,8 @@ pub enum DisplayClick {
Nothing,
/// SWAP: the hotbar slot gets a full stack of the item.
Hotbar(usize, ItemStack),
/// SWAP with button 40 (Key F): the off-hand gets a full stack of the item.
Offhand(ItemStack),
/// THROW: a copy of the item is dropped.
Drop(ItemStack),
}
@@ -596,8 +611,11 @@ pub fn click_display(
match input {
Input::Swap => {
return match shown {
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
None => DisplayClick::Nothing,
Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))),
Some(s) if (0..9).contains(&button) => {
DisplayClick::Hotbar(button as usize, copy(s, max(s)))
}
Some(_) | None => DisplayClick::Nothing,
};
}
Input::Clone => {
@@ -720,11 +738,15 @@ mod tests {
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
Some(stack("ender_pearl", 8))
);
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
// Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones.
assert_eq!(
display(None, 3, Swap).1,
DisplayClick::Hotbar(3, stack("stone", 64))
);
assert_eq!(
display(None, 40, Swap).1,
DisplayClick::Offhand(stack("stone", 64))
);
assert_eq!(
display(None, 0, Throw).1,
DisplayClick::Drop(stack("stone", 1))
+4
View File
@@ -29,6 +29,10 @@ impl EffectInstance {
fn infinite(&self) -> bool {
self.duration == -1
}
/// `MobEffectInstance.endsWithin`.
pub fn ends_within(&self, ticks: i32) -> bool {
!self.infinite() && self.duration <= ticks
}
fn shorter_than(&self, other: &Self) -> bool {
!self.infinite() && (self.duration < other.duration || other.infinite())
}
+10
View File
@@ -263,6 +263,16 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
+15
View File
@@ -130,6 +130,8 @@ pub struct Font {
atlas: (u32, u32),
region: (u32, u32),
cache: RefCell<Cache>,
/// Set anew for each font and each `reset_cache` (see `epoch`).
generation: std::cell::Cell<u64>,
/// "Force Unicode Font": the `uniform` filter option.
pub uniform: bool,
/// "Japanese Glyph Variants": the `jp` filter option.
@@ -559,6 +561,11 @@ impl Region {
Some((ax, ay))
}
}
/// A value no font has had yet (`Font::epoch`).
fn next_generation() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl Font {
/// Build from the atlas holding the bitmap font sheets (`sprites`, of
/// `atlas` size) with `region` the reserved rows.
@@ -603,6 +610,7 @@ impl Font {
.map_or(0, |d| d.as_nanos() as i64),
),
}),
generation: std::cell::Cell::new(next_generation()),
uniform: false,
japanese: false,
};
@@ -661,6 +669,13 @@ impl Font {
}
cache.region.cursor = (0, 0);
cache.region.row = 0;
self.generation.set(next_generation());
}
/// Changes whenever a glyph may come out different: another font, a
/// cache reset (glyphs move in the atlas) or other font options. Text laid
/// out under one epoch can be kept until it changes.
pub fn epoch(&self) -> u64 {
self.generation.get() << 2 | u64::from(self.uniform) << 1 | u64::from(self.japanese)
}
/// The glyph for a character in the default font (FontSet.getGlyph).
pub fn glyph(&self, c: char) -> Glyph {
+173
View File
@@ -0,0 +1,173 @@
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
use crate::{
containers::{Furnace, recipe_used_key},
crafting::Recipes,
items::{ItemStack, Items},
providers::Providers,
};
use std::sync::Arc;
/// The furnace methods that read the recipes.
pub trait FurnaceCooking {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
}
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl FurnaceCooking for Furnace {
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
+284 -69
View File
@@ -222,6 +222,7 @@ use horizon_client::{
entities::{Experience, ExperienceOrb, ItemEntity},
environment::Environment,
first_person::{Hand, SwingAnimation},
furnace::FurnaceCooking,
gfx::{Frame, Renderer},
hud::{HudAtlas, HudBatch, rgba},
hud_layout, hud_overlays,
@@ -273,6 +274,7 @@ use std::{
};
use winit::{
application::ApplicationHandler,
error::EventLoopError,
event::{DeviceEvent, DeviceId, ElementState, MouseButton, MouseScrollDelta, WindowEvent},
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
keyboard::{KeyCode, PhysicalKey},
@@ -585,8 +587,8 @@ struct GameLevel<'w> {
cart_boxes: Vec<([f64; 3], [f64; 3])>,
/// The item frames a comparator reads through a conductor.
item_frames: &'w [horizon_client::entities::ItemFrame],
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
cart_signals: Vec<(minecarts::CartKind, i32)>,
/// The minecarts, in `cart_boxes` order, for a detector rail's comparator.
carts: &'w [minecarts::Minecart],
/// Arrow and thrown trident boxes (wooden buttons).
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
/// The other entities that keep blocks from being placed into them.
@@ -670,15 +672,22 @@ impl EntityObstructionWorld for BuildingObstruction {
}
}
impl GameLevel<'_> {
/// The mobs that trigger plates and tripwires: living entities that don't
/// ignore block triggers (`Entity.isIgnoringBlockTriggers`: bats; armor
/// stands are not in `mobs`).
/// The living entities that trigger plates and tripwires and don't ignore
/// block triggers (`Entity.isIgnoringBlockTriggers`: bats and marker armor
/// stands). Armor stands live apart from `mobs`.
fn mob_trigger_boxes(&self) -> impl Iterator<Item = Aabb> + '_ {
self.blockers
.mobs
.iter()
.filter(|m| !m.removed && m.species() != horizon_client::mobs::Species::Bat)
.map(Mob::bounds)
.chain(
self.blockers
.armor_stands
.iter()
.filter(|s| !s.marker)
.map(|s| s.bounds()),
)
}
/// The non-living entities that trigger plates and tripwires (boats,
/// crystals, primed TNT, falling blocks, item entities, experience orbs and
@@ -730,24 +739,36 @@ impl GameLevel<'_> {
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
);
let mut container = None;
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
let cart = Aabb::new(
for ((lo, hi), cart) in self.cart_boxes.iter().zip(self.carts) {
let bounds = Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
);
if !area.intersects(cart) {
if !area.intersects(bounds) {
continue;
}
match kind {
match cart.kind {
minecarts::CartKind::CommandBlock => return 0,
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
container = container.or(Some(signal));
// Counted here, for the cart a comparator reads, not for
// every cart on every block tick.
container = container.or_else(|| Some(self.cart_signal(cart)));
}
_ => {}
}
}
container.unwrap_or(0)
}
/// A container minecart's comparator signal: its open menu's slots while
/// someone has it open, else its own.
fn cart_signal(&self, cart: &minecarts::Minecart) -> i32 {
let key = (i32::MAX - 15, minecarts::CART_KEY_Y, cart.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &cart.items,
};
minecarts::Minecart::container_signal(slots, self.items)
}
/// Every entity box that blocks building: the players who aren't
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
/// armor stand, whose box is empty, doesn't).
@@ -1177,6 +1198,8 @@ struct WorldRest {
end_environment: Environment,
behaviours: Behaviours,
player_arm: Option<horizon_client::mobs::Part>,
/// The slim model's right arm (3 pixels wide).
player_arm_slim: Option<horizon_client::mobs::Part>,
player_type: Option<Arc<horizon_client::mobs::MobType>>,
/// `SpinAttackEffectModel` (Riptide's spinning boxes).
spin_attack: Option<horizon_client::mobs::Part>,
@@ -1249,8 +1272,12 @@ impl Resources {
fn threads(&self) -> usize {
self.core.threads
}
fn player_arm(&self) -> &Option<horizon_client::mobs::Part> {
&self.rest().player_arm
fn player_arm(&self, slim: bool) -> &Option<horizon_client::mobs::Part> {
if slim {
&self.rest().player_arm_slim
} else {
&self.rest().player_arm
}
}
fn player_type(&self) -> &Option<Arc<horizon_client::mobs::MobType>> {
&self.rest().player_type
@@ -1766,6 +1793,8 @@ struct Game<'a> {
creaking: creaking::CreakingState,
/// Monster spawners' display mobs (`game/spawner.rs`).
spawners: spawner::SpawnerState,
/// Reused candidate buffers for natural spawning.
spawn_scratch: natural_spawning::SpawnScratch,
trident_model: Option<horizon_client::mobs::Part>,
arrow_model: Option<horizon_client::mobs::Part>,
tnts: Vec<tnt::PrimedTnt>,
@@ -2304,12 +2333,19 @@ impl Game<'_> {
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
// Prefer the selected slot, then the hotbar, then the main inventory.
// getSlotWithRemainingSpace: the selected slot, the off hand, then
// the hotbar and the main inventory.
let order: Vec<usize> = std::iter::once(self.selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != self.selected))
.collect();
for &i in &order {
if let Some(slot) = &mut self.inventory[i]
let slot = if i == shield::OFFHAND_SLOT {
&mut self.offhand
} else {
&mut self.inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(&stack)
&& slot.count < max
{
@@ -2841,7 +2877,6 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2885,7 +2920,7 @@ impl Game<'_> {
let mut level = GameLevel {
cart_boxes,
item_frames: &self.item_frames,
cart_signals,
carts: &self.carts,
arrow_boxes,
blockers,
dropped: &self.dropped,
@@ -2912,7 +2947,13 @@ impl Game<'_> {
behaviours: self.res.behaviours(),
items: self.res.items(),
recipes: self.res.recipes(),
loot: horizon_client::structure_blocks::loot_data(&self.options.resources),
// A client rolls no loot (LootTable.getRandomItems needs a
// ServerLevel): it doesn't load the tables on its game thread.
loot: if self.remote.is_some() {
None
} else {
horizon_client::structure_blocks::loot_data(&self.options.resources)
},
registry: self.pack.assets.registry,
rules,
difficulty: i32::from(self.ui.difficulty),
@@ -3522,6 +3563,9 @@ impl Game<'_> {
state,
kind: "dispense",
} => self.dispense_from(pos, state)?,
LevelEvent::BlockEvent {
pos, kind: "golem", ..
} => self.check_golem_spawn([pos.x, pos.y, pos.z])?,
LevelEvent::BlockEvent {
pos,
kind: "command_block",
@@ -3655,6 +3699,7 @@ impl Game<'_> {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
self.anger_piglins(true);
if self.container_opener_event(key, true) {
self.container_edge_sound(key, true);
}
@@ -3982,12 +4027,36 @@ impl Game<'_> {
// library: the block entity's side effects (containers spill), neighbour
// and shape updates, and the destroy event (particles, sound).
let creative = self.creative;
// TripWireBlock.playerWillDestroy: shears disarm the string first (flags
// 260), so taking it down does not trip the hook.
if &*facts.name == "minecraft:tripwire"
&& self.held().is_some_and(|s| &*s.item == "minecraft:shears")
&& let Some(disarmed) = self.with_props(state, &[("disarmed", "true")])
{
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
}
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
// with its bees and honey level.
if creative
&& self.ui.rules.bool("block_drops")
&& let Some((h, (honey, _))) = &hive
&& (!h.bees.is_empty() || honey.is_some_and(|l| l > 0))
&& let Some(stack) = self.picked_stack(pos, true)
{
let at = pos.map(f64::from);
self.spawn_item(stack, at, dispenser::new_item_motion(), 10);
}
let correct_tool =
!facts.requires_tool || self.res.items().correct_for_drops(self.held(), &facts.name);
let removed = self.with_level(|b, level, ticks| {
b.player_destroy_block(
level,
ticks,
BlockPos::new(pos[0], pos[1], pos[2]),
creative,
correct_tool,
)
});
if removed {
@@ -4016,11 +4085,14 @@ impl Game<'_> {
self.pop_resource(pos, d);
}
}
// BeehiveBlock.playerDestroy: silk touch keeps the bees in.
if let Some((hive, (honey, facing))) = hive
&& !silk
{
self.hive_broken_by_player(pos, hive, honey, facing);
// BeehiveBlock.playerDestroy: silk touch keeps the bees in, so
// the trigger counts them; otherwise they are out already.
if let Some((hive, (honey, facing))) = hive {
let bees = if silk { hive.bees.len() as i64 } else { 0 };
if !silk {
self.hive_broken_by_player(pos, hive, honey, facing);
}
self.trigger_bee_nest_destroyed(broken, held.as_ref(), bees);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
@@ -4214,6 +4286,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_redstone_ore(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
}
match self.use_respawn_anchor(hit.pos, held.as_ref())? {
respawn_anchor::AnchorUse::Charged => {
self.swing_arm();
@@ -4223,9 +4299,30 @@ impl Game<'_> {
respawn_anchor::AnchorUse::Pass => anchor_passed = true,
respawn_anchor::AnchorUse::Other => {}
}
// CeilingHangingSignBlock.useItemOn: a hanging sign aimed at the
// underside passes, so the next sign chains below instead of
// opening the editor (signs here carry no click commands).
let chains_sign = hit.face == [0, -1, 0]
&& held
.as_ref()
.is_some_and(|s| s.item.ends_with("_hanging_sign"))
&& {
let name = &self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
name.ends_with("_hanging_sign") && !name.contains("_wall_")
}
&& matches!(
self.world
.block_entities
.get(&(hit.pos[0], hit.pos[1], hit.pos[2])),
Some(BlockEntity::Sign(_))
);
// TRY_WITH_EMPTY_HAND: only the main hand's turn uses the block
// itself (useWithoutItem).
if !anchor_passed && self.hand == Hand::Main {
if !anchor_passed && !chains_sign && self.hand == Hand::Main {
let face = match hit.face {
[0, 1, 0] => Direction::Up,
[0, -1, 0] => Direction::Down,
@@ -5259,8 +5356,13 @@ impl Game<'_> {
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::HoneySlide {
sound, particles, ..
pos,
sound,
particles,
} => {
if self.world.game_time % 20 == 0 {
self.trigger_honey_slide([pos.x, pos.y, pos.z]);
}
if sound {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
@@ -5589,6 +5691,16 @@ impl Game<'_> {
rotation.turn(xo * zoom, yo * zoom);
self.yaw = rotation.y_rot;
self.pitch = rotation.x_rot;
// Entity.turn ends in AbstractBoat.onPassengerTurned: clampRotation
// keeps the view within 105 degrees of the boat at every turn, not
// only at the tick, so it never swings past and back.
if let Some(b) = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
{
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
}
}
}
/// `Minecraft.pause`: a single-player game with a pausing screen open.
@@ -5624,8 +5736,10 @@ impl Game<'_> {
if self.lan_acting.is_some() {
return;
}
// MouseHandler.grabMouse never captures an inactive window.
if capture && !self.focused {
// MouseHandler.grabMouse never captures an inactive window, and vanilla
// grabs only on `setScreen(null)`: the pack prompt and server dialogs
// are screens over the world that keep the pointer.
if capture && (!self.focused || self.server_packs.prompt.is_some() || self.dialog_open()) {
return;
}
// The container screen's press state belongs to the screen: a screen
@@ -5649,6 +5763,8 @@ impl Game<'_> {
let warp = |window: &Window| {
let _ = window.set_cursor_position(centre);
};
let scale = HudBatch::new(size).scale;
let was_grabbed = self.captured;
if capture {
self.mouse.set_ignore_first_move();
// macOS warps by re-associating the mouse with the pointer,
@@ -5679,13 +5795,23 @@ impl Game<'_> {
if self.focused {
warp(window);
}
let scale = HudBatch::new(size).scale;
self.cursor = (size[0] as f32 / 2.0 / scale, size[1] as f32 / 2.0 / scale);
} else {
let _ = window.set_cursor_grab(CursorGrabMode::None);
}
self.captured = false;
}
// MouseHandler.grabMouse and releaseMouse: the mouse position is
// the window's centre whenever the pointer's grab state changes, so
// a screen opened from gameplay starts with the cursor in the middle
// and the game starts with it away from the screen's last spot.
if let Some(pos) = horizon_client::mouse::mouse_position_after_grab_change(
capture,
was_grabbed,
size,
scale,
) {
self.cursor = pos;
}
window.set_cursor_visible(!self.captured);
}
/// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end
@@ -5694,17 +5820,22 @@ impl Game<'_> {
fn apply_cursor_icon(&mut self) {
use horizon_client::widgets::Cursor;
let wanted = horizon_client::widgets::take_cursor();
if wanted == self.cursor_icon {
let effective = if self.settings.allow_cursor_changes {
wanted
} else {
Cursor::Arrow
};
if effective == self.cursor_icon {
return;
}
if let Some(window) = &self.window {
window.set_cursor(match wanted {
window.set_cursor(match effective {
Cursor::Arrow => winit::window::CursorIcon::Default,
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
});
}
self.cursor_icon = wanted;
self.cursor_icon = effective;
}
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
fn capture_loaded_world(&mut self) {
@@ -5724,30 +5855,31 @@ impl Game<'_> {
/// highlight (its count yellow when the slot caps it), and hides while
/// it is the drag's only slot.
fn draw_slot_item(
&mut self,
&self,
batch: &mut HudBatch,
drag: Option<&DragView>,
slot: usize,
stack: Option<ItemStack>,
stack: &Option<ItemStack>,
x: f32,
y: f32,
) {
let carried;
let (stack, capped) = match drag.and_then(|d| d.slots.get(&slot)) {
Some(None) => return,
Some(Some((count, capped))) => {
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
let carried = self
carried = self
.carried
.clone()
.map(|c| ItemStack { count: *count, ..c });
(carried, *capped)
(carried.as_ref(), *capped)
}
None => (stack, false),
None => (stack.as_ref(), false),
};
if let Some(stack) = stack {
self.draw_item_model(batch, &stack, x, y);
self.draw_item_model(batch, stack, x, y);
let text = capped.then(|| format!("\u{a7}e{}", stack.count));
self.draw_item_decorations(batch, &stack, x, y, text);
self.draw_item_decorations(batch, stack, x, y, text);
}
}
/// `extractCarriedItem`: the carried stack at the cursor, showing what a
@@ -6101,7 +6233,14 @@ impl Game<'_> {
// Screen.extractBlurredBackground blurs the world and HUD, while the
// screen's background, widgets, toasts and debug overlay stay sharp.
batch.blur_at = batch.vertices.len() as u32;
match self.screen {
// PackConfirmScreen takes the open screen's place (it comes back once
// the prompt is answered): the screen under the prompt isn't drawn.
let screen = if self.server_packs.prompt.is_some() {
Screen::Playing
} else {
self.screen
};
match screen {
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
// PauseScreen(false): only its title, at the top.
Screen::Paused => {
@@ -6141,9 +6280,11 @@ impl Game<'_> {
// ToastManager.extractRenderState: F1 hides the toasts too.
self.draw_toasts(&mut batch, !show_hud);
self.draw_pack_toasts(&mut batch, !show_hud);
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
if self.server_packs.prompt.is_none() {
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
}
batch
}
fn creative_inventory_open(&self) -> bool {
@@ -6356,7 +6497,7 @@ impl Game<'_> {
);
}
let stack = self.slot_view(i);
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
}
fn draw_inventory(&mut self, batch: &mut HudBatch) {
@@ -6611,9 +6752,12 @@ impl Game<'_> {
let catalog = self.res.items();
let max = |s: &ItemStack| catalog.get(&s.item).map_or(64, |i| i.max_stack);
match click_display(&mut self.carried, shown, max, button, input) {
DisplayClick::Hotbar(i, stack) => self.inventory[i] = Some(stack),
DisplayClick::Hotbar(i, stack) if i < self.inventory.len() => {
self.inventory[i] = Some(stack);
}
DisplayClick::Offhand(stack) => self.offhand = Some(stack),
DisplayClick::Drop(stack) => self.player_drop(stack),
DisplayClick::Nothing => {}
_ => {}
}
}
/// Several clicks on one menu instance (a quick craft drag is three).
@@ -7962,7 +8106,12 @@ impl Game<'_> {
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
// DimensionType.ambientLight: the_nether 0.1, the_end 0.25.
let ambient = match self.dim {
Dim::Nether => 0.1,
Dim::End => 0.25,
Dim::Overworld => 0.0,
};
let darken = self.sky_darken();
let a = alpha;
let lerp = |p: Vec3, q: Vec3| {
@@ -8026,6 +8175,7 @@ impl Game<'_> {
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
// Block entities outside the view frustum are skipped (with a
// block of margin for models larger than their block).
@@ -8347,7 +8497,9 @@ impl Game<'_> {
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64)> = None;
for (i, m) in self.mobs.iter().enumerate() {
if !m.alive() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
// shares its root vehicle and is never the hit.
if !m.alive() || Some(m.id) == self.riding {
continue;
}
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
@@ -10456,14 +10608,18 @@ impl Game<'_> {
if hand == Hand::Off {
continue;
}
let Some(arm) = self.res.player_arm().clone() else {
let slim = self.player_model.as_ref().is_some_and(|m| m.slim);
let Some(arm) = self.res.player_arm(slim).clone() else {
continue;
};
// The player's own skin, as the inventory's model shows it.
let sprites = &self.gui.atlas.sprites;
let Some(r) = self
.gui
.atlas
.sprites
.get("entity/player/wide/steve")
.player_model
.as_ref()
.and_then(|m| m.skin.as_deref())
.and_then(|skin| sprites.get(skin))
.or_else(|| sprites.get("entity/player/wide/steve"))
.copied()
else {
continue;
@@ -10898,7 +11054,11 @@ impl Game<'_> {
// Creative players skip the monster check (`!this.isCreative()`).
let monsters = !self.creative
&& self.mobs.iter().any(|m| {
m.hostile()
// `Monster.class` entities that `isPreventingPlayerRest`: a
// zombified piglin only while it is angry.
horizon_client::mobs::ai::is_monster(m.species())
&& (m.species() != horizon_client::mobs::Species::ZombifiedPiglin
|| (m.extra.anger > 0 && m.ai.target.is_some()))
&& m.alive()
&& (m.position.x - position.x).abs() < 8.0
&& (m.position.y - position.y).abs() < 5.0
@@ -11008,7 +11168,7 @@ impl Game<'_> {
let selected = self.creative_screen.selected;
// Only what is drawn of the tabs: their stacks stay where they are.
let tabs = self.creative_screen.tab_headers();
let tab = tabs[selected.min(tabs.len() - 1)].clone();
let tab = &tabs[selected.min(tabs.len() - 1)];
let tab_sprite = |t: &horizon_client::creative::Tab, on: bool| {
format!(
"gui/sprites/container/creative_inventory/tab_{}_{}_{}",
@@ -11044,8 +11204,8 @@ impl Game<'_> {
r.v1 = r.v0 + (r.v1 - r.v0) * Creative::HEIGHT / r.h as f32;
batch.rect_uv(ox, oy, Creative::WIDTH, Creative::HEIGHT, r, u32::MAX);
}
let (tx, ty) = Creative::tab_position(&tab);
batch.sprite(&self.gui.atlas, &tab_sprite(&tab, true), ox + tx, oy + ty);
let (tx, ty) = Creative::tab_position(tab);
batch.sprite(&self.gui.atlas, &tab_sprite(tab, true), ox + tx, oy + ty);
for t in &tabs {
let (tx, ty) = Creative::tab_position(t);
let stack = ItemStack {
@@ -11063,7 +11223,7 @@ impl Game<'_> {
}
let atlas = &self.gui.atlas;
if !tab.inventory {
let title = self.creative_tab_title(&tab);
let title = self.creative_tab_title(tab);
batch.text(
atlas,
&title,
@@ -11142,15 +11302,14 @@ impl Game<'_> {
let drag = self.drag_view();
for c in 0..9 {
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
let stack = self.inventory[c].clone();
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
self.draw_slot_item(batch, drag.as_ref(), c, &self.inventory[c], x, y);
if self.cursor.0 >= x
&& self.cursor.0 < x + 16.0
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = stack;
hovered = self.inventory[c].clone();
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -11659,6 +11818,11 @@ impl Game<'_> {
} else {
self.bob_matrix(camera_alpha as f32)
};
let spin = if title {
glam::Mat4::IDENTITY
} else {
self.spinning_matrix(camera_alpha as f32)
};
let terrain_distance = self.terrain_distance();
let distance = (terrain_distance * 16) as f32;
let depth_far =
@@ -11666,7 +11830,7 @@ impl Game<'_> {
self.view_frustum = self
.renderer
.as_ref()
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, depth_far));
.map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far));
// Keep the buffer local while builders need mutable access to the game.
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
@@ -11749,12 +11913,24 @@ impl Game<'_> {
pitch,
fov_degrees,
bob,
spin,
sky,
distance,
detail_distance: (self.streaming.radius * 16) as f32,
depth_far,
underwater,
title,
sky_blocked: self.potions.effects.get("blindness").is_some()
|| self.potions.effects.get("darkness").is_some()
|| matches!(
horizon_client::fog::fog_type(
&self.world,
self.res.facts(),
[camera.x, camera.y, camera.z],
self.forward(),
),
horizon_client::fog::FogType::Lava | horizon_client::fog::FogType::PowderSnow
),
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
end_sky_sprite: self
@@ -12056,7 +12232,8 @@ impl Game<'_> {
KeyCode::KeyL if self.screen == Screen::Playing => self.open_advancements(false),
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
KeyCode::Slash if self.screen == Screen::Playing => self.open_chat("/"),
KeyCode::F1 => self.toggle_hide_gui(),
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
KeyCode::F2 => self.take_screenshot(),
KeyCode::F5 if self.screen == Screen::Playing => self.cycle_camera(),
@@ -12067,11 +12244,12 @@ impl Game<'_> {
Screen::Playing => {
// key.inventory while riding a tamed mount opens its inventory.
if !self.open_ridden_mount_menu() {
self.screen = if self.creative {
Screen::Creative
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
} else {
Screen::Inventory
};
self.screen = Screen::Inventory;
}
self.capture(false);
}
}
@@ -12651,13 +12829,16 @@ impl ApplicationHandler for Game<'_> {
self.left = false;
self.right = false;
self.capture(false);
// Screens (books, signs) stay open instead of pausing.
// Gui.setPauseScreen: an open screen (books, signs, the
// pack prompt, a server dialog) stays instead of pausing.
if self.screen == Screen::Playing
&& self.player.is_some()
&& !self.options.no_pause
&& self.settings.file.bool("pauseOnLostFocus")
&& self.book.is_none()
&& self.sign_edit.is_none()
&& self.server_packs.prompt.is_none()
&& !self.dialog_open()
{
self.screen = Screen::Paused;
}
@@ -12916,6 +13097,12 @@ impl ApplicationHandler for Game<'_> {
}
self.extras_mouse(button, pressed);
self.mod_mouse_key(button, pressed);
if pressed
&& self.settings.keys.get("key.smoothCamera")
== horizon_client::keybinds::mouse_name(button)
{
self.smooth_camera = !self.smooth_camera;
}
for mapped in self.settings.keys.translate_mouse_all(button) {
match mapped {
Err(b) => self.play_mouse(b, pressed)?,
@@ -13827,6 +14014,7 @@ impl<'a> Game<'a> {
ominous_spawners: Vec::new(),
creaking: Default::default(),
spawners: Default::default(),
spawn_scratch: Default::default(),
trident_model: prepared.trident_model,
arrow_model: prepared.arrow_model,
tnts: Vec::new(),
@@ -14511,6 +14699,8 @@ fn load_world_rest(
player_type: perspective::player_type(root),
player_arm: horizon_client::mobs::load_model(root, "minecraft:player#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
player_arm_slim: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
spin_attack: horizon_client::mobs::load_model(root, "minecraft:spin_attack#main"),
})
}
@@ -14648,6 +14838,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: horizon_client::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
@@ -14655,6 +14846,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
@@ -14856,8 +15048,26 @@ fn write_report(game: &Game<'_>) -> Result<()> {
Ok(())
}
/// The window system ended the event loop with a failure (on Linux, a compositor dropping the
/// connection after a protocol error) while fullscreen was on: the next start opens windowed, so a
/// fullscreen the system can't show doesn't crash every launch (#2904).
fn leave_fullscreen_after(
result: &std::result::Result<(), EventLoopError>,
settings: &mut Settings,
) {
if matches!(result, Err(EventLoopError::ExitFailure(_))) && settings.fullscreen {
settings.fullscreen = false;
settings.save(&Game::options_path());
log::warn!(
"the window system closed the game in fullscreen; the next start opens windowed"
);
}
}
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
EventLoop::new()?.run_app(&mut game)?;
let result = EventLoop::new()?.run_app(&mut game);
leave_fullscreen_after(&result, &mut game.settings);
result?;
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
@@ -15214,7 +15424,12 @@ fn run_loading(
error: None,
};
let mut app = App::Loading(Box::new(loading));
EventLoop::new()?.run_app(&mut app)?;
let result = EventLoop::new()?.run_app(&mut app);
match &mut app {
App::Ready(game) => leave_fullscreen_after(&result, &mut game.settings),
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
}
result?;
horizon_client::discord_presence::stop();
match app {
App::Ready(mut game) => {
+174 -3
View File
@@ -408,6 +408,14 @@ impl Game<'_> {
equipment[4] = slot(&m.extra.legs);
equipment[5] = slot(&m.extra.feet);
}
// A wolf's armor is its body slot, with its durability.
if m.species() == horizon_client::mobs::Species::Wolf {
equipment[6] = m.extra.head.as_ref().map(|id| {
let mut armor = ItemStack::new(id, 1);
armor.damage = m.extra.body_damage.max(0) as u32;
item_view(&armor)
});
}
EntityView {
kind: m.kind.id.clone(),
position: [m.position.x, m.position.y, m.position.z],
@@ -719,6 +727,32 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `KilledTrigger` (`player_killed_entity`): the entity killed and the
/// killing blow's damage source (none: any `killing_blow` fails).
pub(super) fn trigger_killed(&mut self, target: &EntityView, blow: Option<&adv::DamageView>) {
let me = self.player_entity();
let target = target.clone();
let blow = blow.cloned();
self.trigger("minecraft:player_killed_entity", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&target),
..pctx
};
let blow_ok = c["killing_blow"].is_null()
|| blow
.as_ref()
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& blow_ok
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
@@ -869,9 +903,6 @@ impl Game<'_> {
stack: &ItemStack,
target: &EntityView,
) {
if self.lan_acting.is_some() {
return;
}
let me = self.player_entity();
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
@@ -993,6 +1024,42 @@ impl Game<'_> {
block_ok && state_ok && adv::conditions(&c["player"], &pctx, w)
});
}
/// `SlideDownBlockTrigger`: match the actual block and its state.
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
let me = self.player_entity();
self.trigger("minecraft:slide_down_block", move |c, w| {
let ctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
});
}
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
let me = self.player_entity();
let projectile = projectile.clone();
self.trigger("minecraft:target_hit", move |c, w| {
let player = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let target = adv::Entities {
this: Some(&projectile),
..player
};
adv::conditions(&c["player"], &player, w)
&& adv::range_i(&c["signal_strength"], i64::from(strength))
&& adv::conditions(&c["projectile"], &target, w)
});
}
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity();
@@ -1007,6 +1074,110 @@ impl Game<'_> {
adv::conditions(&c["location"], &ctx, w)
});
}
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
/// not hit (`bystander`), each tested from this player's position.
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
let me = self.player_entity();
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
self.trigger("minecraft:lightning_strike", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let bctx = adv::Entities {
this: Some(&bolt),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["lightning"], &bctx, w)
&& (c["bystander"].is_null()
|| viewers.iter().any(|v| {
adv::conditions(
&c["bystander"],
&adv::Entities {
this: Some(v),
..pctx
},
w,
)
}))
});
}
/// `ChanneledLightningTrigger`: each victim condition matched by some
/// mob the bolt hit, from the player who channelled it.
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
let me = self.player_entity();
let victims = victims.to_vec();
self.trigger("minecraft:channeled_lightning", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& c["victims"].as_array().into_iter().flatten().all(|want| {
victims.iter().any(|v| {
adv::conditions(
want,
&adv::Entities {
this: Some(v),
..pctx
},
w,
)
})
})
});
}
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
let me = self.player_entity();
self.trigger("minecraft:spear_mobs", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& c["count"].as_u64().is_none_or(|n| stabbed as u64 >= n)
});
}
/// `BeeNestDestroyedTrigger`: the nest's block state, the tool that broke
/// it and the bees left inside.
pub(super) fn trigger_bee_nest_destroyed(
&mut self,
state: horizon_world::registry::BlockStateId,
tool: Option<&ItemStack>,
bees: i64,
) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
let tool = tool.map(item_view).unwrap_or_default();
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let state_ok = c["state"]
.as_object()
.into_iter()
.flatten()
.all(|(k, v)| definition.properties.get(k).map(String::as_str) == v.as_str());
adv::id_matches(&c["blocks"], "block", &definition.name, w)
&& state_ok
&& adv::item(&c["item"], &tool, w)
&& adv::range_i(&c["num_bees_inside"], bees)
&& adv::conditions(&c["player"], &pctx, w)
});
}
/// A trigger with only the player condition (slept_in_bed, started_riding).
pub(super) fn trigger_player(&mut self, name: &str) {
let me = self.player_entity();
+24 -7
View File
@@ -89,6 +89,16 @@ impl Game<'_> {
}
}
}
/// The UUID of the player an allay likes (`LIKED_PLAYER`).
fn allay_liked_uuid(&self, i: usize) -> [i32; 4] {
// An allay saved before its player was kept belongs to the host.
self.mobs[i]
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1])
}
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
/// found by UUID among the host and the guests of the level (the window
/// is a guest on the server thread), when they are not a spectator and
@@ -98,13 +108,7 @@ impl Game<'_> {
if !m.extra.tame {
return None;
}
// An allay saved before its player was kept belongs to the host.
let uuid = m
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1]);
let uuid = self.allay_liked_uuid(i);
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
@@ -159,6 +163,19 @@ impl Game<'_> {
];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
// AllayAi.throwItem: the liked player hears of the block below the deposit.
let below = [
target.x.floor() as i32,
(target.y - 1.0).floor() as i32,
target.z.floor() as i32,
];
let uuid = self.allay_liked_uuid(i);
let stack = ItemStack::new(&item, 1);
self.for_each_player(|g| {
if g.acting_uuid() == uuid && !g.spectator() {
g.trigger_block("minecraft:allay_drop_item_on_block", below, &stack);
}
});
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
+35 -6
View File
@@ -16,6 +16,21 @@ use horizon_core::{
};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
/// colour; any other layer stays white, dyed or not. The result is in the
/// renderer's byte order (R and B swapped).
fn layer_color(undyed: Option<u32>, stack: Option<&ItemStack>) -> u32 {
let dyed = stack
.and_then(|s| s.data())
.and_then(|d| d.dyed_color)
.map(|c| c as u32 & 0xFF_FFFF);
undyed.map_or(0xFFFF_FFFF, |undyed| {
let c = dyed.unwrap_or(undyed);
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
})
}
/// Armor stand models and the humanoid layers of every equipment asset.
pub(super) struct ArmorStandAssets {
pub main: Option<Part>,
@@ -179,9 +194,7 @@ impl Game<'_> {
) else {
continue;
};
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
let color = layer_color(*undyed, stack);
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(r) = stack
@@ -695,9 +708,7 @@ impl Game<'_> {
else {
continue;
};
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
let color = layer_color(*undyed, Some(stack));
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
@@ -993,3 +1004,21 @@ mod burn_drop_tests {
assert_eq!(drops, vec![kept]);
}
}
#[cfg(test)]
mod layer_color_tests {
use super::*;
#[test]
fn a_dyed_stack_overrides_the_undyed_leather_colour() {
let mut dyed = ItemStack::new("minecraft:leather_helmet", 1);
dyed.data_mut().dyed_color = Some(0x1E_90FF);
// Dyed blue, R and B swapped into the renderer's order, opaque.
assert_eq!(layer_color(Some(0xA06540), Some(&dyed)), 0xFFFF_901E);
// An undyed stack keeps the asset's brown.
let plain = ItemStack::new("minecraft:leather_helmet", 1);
assert_eq!(layer_color(Some(0xA06540), Some(&plain)), 0xFF40_65A0);
// No dye and no undyed colour: white.
assert_eq!(layer_color(None, Some(&plain)), 0xFFFF_FFFF);
// A layer that isn't dyeable stays white even on a dyed stack.
assert_eq!(layer_color(None, Some(&dyed)), 0xFFFF_FFFF);
}
}
+9 -1
View File
@@ -563,13 +563,21 @@ impl Game<'_> {
};
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
let view = f64::from(self.settings.render_distance * 16);
for (k, e) in &self.world.block_entities {
let BlockEntity::Beacon(b) = e else { continue };
// `getBeamSections` is empty while Levels is 0.
if b.levels == 0 || b.sections.is_empty() {
continue;
}
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
let distance = (base - camera).truncate().length() as f32;
// `BeaconRenderer.shouldRender`: horizontal distance under render distance × 16.
let offset = base - camera;
let horizontal = glam::DVec2::new(offset.x, offset.z).length();
if horizontal >= view {
continue;
}
let distance = horizontal as f32;
let scale = if scoping {
1.0
} else {
@@ -7,6 +7,7 @@ use super::Game;
use horizon_client::{
block_entity_anim::{
BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker,
closer_to_center_than,
},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
@@ -49,11 +50,16 @@ impl Game<'_> {
}
Some(Ticker::Bell) => {
let gathered = self.bell_gather(pos);
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
let rung =
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
// The server's copy tells the living entities within 32 blocks.
if rung && self.remote.is_none() {
self.bell_heard(pos, time);
}
}
Some(Ticker::Gateway) => {
let initial = self.gateway_loaded(pos);
@@ -200,6 +206,26 @@ impl Game<'_> {
}
out
}
/// `BellBlockEntity.updateEntities`: every gathered entity alive within 32
/// blocks of the bell's centre gets `HEARD_BELL_TIME` (only villagers
/// have a brain that reads it).
fn bell_heard(&mut self, pos: [i32; 3], now: i64) {
let nearby = self
.be_anims
.bells
.get(&pos)
.and_then(|b| b.nearby.clone())
.unwrap_or_default();
for m in &mut self.mobs {
if m.alive()
&& !m.removed
&& nearby.contains(&u64::from(m.id))
&& closer_to_center_than(pos, [m.position.x, m.position.y, m.position.z], 32.0)
{
horizon_client::mobs::villager_brain::heard_bell(m, now);
}
}
}
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
/// room to rise (`level.noCollision` of the half-open lid box).
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
@@ -9,6 +9,7 @@ use horizon_client::{
Result,
block_entities::{Pos, Ticking},
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
furnace::FurnaceCooking,
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
@@ -23,12 +24,16 @@ impl Game<'_> {
let states = self.ticking_states();
self.world.block_entities.set_states(states);
}
self.forget_gone_hoppers();
// Only lingering cooldowns are dropped; a hopper that ticks checks
// its own block. Once a second is soon enough for the rest.
if self.world.game_time % 20 == 0 {
self.forget_gone_hoppers();
}
let ticking = self.block_ticking_area();
let mut spawners = Vec::new();
let mut trial_spawners = Vec::new();
self.item_columns = Some(Default::default());
for slot in self.world.block_entity_tickers() {
for slot in self.world.block_entity_tickers().iter().copied() {
let pos = slot.0;
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if !self.world.block_entities.is_live(slot)
+20 -3
View File
@@ -76,9 +76,11 @@ fn view_pages(data: Option<&StackData>) -> Vec<Text> {
.iter()
.enumerate()
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
horizon_client::entity_nbt::name_text(tag)
}
// A component object, or a component list (`ComponentSerialization`
// takes both; servers behind ViaVersion send the list form).
Some(
tag @ (horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::List(_)),
) => horizon_client::entity_nbt::name_text(tag),
_ => Text::literal(page),
})
.collect()
@@ -137,6 +139,21 @@ impl Game<'_> {
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
ClickEvent::RunCommand(command) => self.click_command(&command),
ClickEvent::SuggestCommand(command) => {
// defaultHandleClickEvent: the chat screen takes it over.
if self.player.is_some() {
self.close_book();
self.open_chat(&command);
}
}
ClickEvent::CopyToClipboard(value) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
// BookViewScreen.handleClickEvent: the rest is
// Screen.defaultHandleGameClickEvent's.
ClickEvent::ShowDialog(dialog) => self.show_dialog_ref(&dialog),
_ => {}
}
}
+29 -12
View File
@@ -786,6 +786,9 @@ impl Game<'_> {
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
// Screen.defaultHandleGameClickEvent: the dialog opens over the
// screen, and closes back to it.
Some(ClickEvent::ShowDialog(dialog)) => self.show_dialog_ref(&dialog),
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
@@ -808,16 +811,19 @@ impl Game<'_> {
&self,
url: &str,
size: (f32, f32),
) -> horizon_client::confirm_screen::Confirm {
) -> horizon_client::confirm_screen::Choice {
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
let message = Text::literal(url)
.append(Text::literal("\n"))
.append(warning);
horizon_client::confirm_screen::Confirm::new(
// ConfirmLinkScreen.addButtons: yes, copy and no, 100 wide each.
horizon_client::confirm_screen::Choice::new(
&self.gui.atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&message,
3,
100.0,
)
}
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
@@ -827,12 +833,13 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let (yes, no) = (
let labels = [
Text::translate("gui.yes", &[]),
Text::translate("chat.copy", &[]),
Text::translate("gui.no", &[]),
);
];
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
.draw(batch, atlas, self.cursor, &labels);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
@@ -843,15 +850,20 @@ impl Game<'_> {
}
}
pub(super) fn link_prompt_click(&mut self) {
let Some(url) = &self.link_prompt else {
let Some(url) = self.link_prompt.clone() else {
return;
};
let answer = self
.link_prompt_layout(url, self.gui_size())
.answer_at(self.cursor);
if let Some(yes) = answer {
let pressed = self
.link_prompt_layout(&url, self.gui_size())
.pressed_at(self.cursor);
if let Some(button) = pressed {
self.audio.play_button_click();
self.answer_link_prompt(yes);
if button == LINK_COPY {
// ConfirmLinkScreen.copyToClipboard, then the callback's no.
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&url);
}
self.answer_link_prompt(button == LINK_YES);
}
}
/// `Screen.clickCommandAction`: run a clicked command as the player
@@ -859,7 +871,7 @@ impl Game<'_> {
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
/// commands take it. (Vanilla asks first when the command fails to
/// parse; here the server answers it.)
fn click_command(&mut self, command: &str) {
pub(super) fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
return;
@@ -992,6 +1004,7 @@ impl Game<'_> {
spawn_mobs: r.bool("spawn_mobs"),
spawn_monsters: r.bool("spawn_monsters"),
fire_spread_radius_around_player: r.int("fire_spread_radius_around_player"),
spread_vines: r.bool("spread_vines"),
}
}
/// Apply rules mirrored in other state (natural regeneration, difficulty).
@@ -1210,3 +1223,7 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// `ConfirmLinkScreen`'s buttons, left to right: yes, copy, no.
const LINK_YES: usize = 0;
const LINK_COPY: usize = 1;
+6
View File
@@ -39,6 +39,11 @@ pub(super) struct CombatTracker {
pub(super) last_hurt_mob: Option<(u32, i64)>,
/// The mob whose projectile is hitting the player now.
pub(super) projectile_owner: Option<u32>,
/// The display name of the projectile hitting the player now: the
/// `DamageSource.directEntity` the death message names when no owner is
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
/// cause).
pub(super) projectile_direct: Option<Text>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
@@ -208,6 +213,7 @@ impl Game<'_> {
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.projectile_direct = None;
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
+1 -1
View File
@@ -1851,7 +1851,7 @@ impl Game<'_> {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob(&short, pos, true);
self.spawn_mob_spawned(&short, pos);
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
} else {
match short.as_str() {
@@ -227,7 +227,7 @@ impl Game<'_> {
}
/// Whether the level in place has a running task, which the server pumps
/// between its ticks (`pump_pregen`).
/// between its ticks (`pump_chunks`).
pub(in crate::game) fn pregen_runs_here(&self) -> bool {
self.cmds
.pregen
@@ -243,11 +243,19 @@ impl Game<'_> {
/// Keep the workers fed until `until`: collect what they finished and
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
// And the players' chunks while they load, as vanilla's server runs
// its chunk tasks while it waits for the next tick
// (`MinecraftServer.waitUntilNextTick`): once a tick, a few jobs per
// worker (`schedule_generation`) capped joining a world at about 120
// columns a second with two workers, the workers idle most of the
// time. Not while a respawn search waits for a chunk: its area is
// only asked for by the tick's own `update_world`.
let players = !paused && self.spawn_wait.is_empty();
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
+229 -19
View File
@@ -277,6 +277,19 @@ impl Game<'_> {
pub(super) fn spawn_mob(&mut self, kind: &str, at: [f64; 3], persistent: bool) -> Option<u32> {
self.spawn_mob_for(kind, at, persistent, false)
}
/// `SummonCommand` and `SpawnEggItem`: vanilla spawns the mob, but it
/// never calls `Mob.setPersistenceRequired` for it, so the mob is not
/// persistent: the despawn rule holds it like any other mob, and only
/// its NBT can make it persistent.
pub(super) fn spawn_mob_spawned(&mut self, kind: &str, at: [f64; 3]) -> Option<u32> {
// The spawn is not a natural one (no jockeys, the patrol leader roll
// stays), but the mob is not persistent.
let id = self.spawn_mob_for(kind, at, true, false)?;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.persistent = false;
}
Some(id)
}
/// `spawn_mob` for a patrol or raid (`EntitySpawnReason.PATROL` / `EVENT`),
/// which never rolls a patrol leader.
pub(super) fn spawn_mob_for(
@@ -334,7 +347,7 @@ impl Game<'_> {
}
};
let id = self
.spawn_mob(kind, at, true)
.spawn_mob_spawned(kind, at)
.ok_or_else(|| format!("unknown or unsupported mob {kind}"))?;
if baby && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.extra.age = if m.species() == Species::Sniffer {
@@ -456,6 +469,7 @@ impl Game<'_> {
biomes: self.res.biomes(),
mob_griefing: self.ui.rules.bool("mob_griefing"),
mob_drops: self.ui.rules.bool("mob_drops"),
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
@@ -732,6 +746,7 @@ impl Game<'_> {
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_item_sense();
self.villager_pickups();
for m in dead {
self.mob_died(&m);
@@ -789,14 +804,30 @@ impl Game<'_> {
// takes the experience (before the loot is dropped). It takes what
// would drop, whoever killed the mob.
self.sculk_mob_died(m);
let mut catalyst_xp = 0;
let consumed =
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
if horizon_client::mobs::drops::drops_experience(&dying) {
catalyst_xp = if horizon_client::mobs::drops::drops_experience(&dying) {
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
} else {
0
}
};
catalyst_xp
});
// CatalystListener: with experience to take, the player who last hurt
// the mob gets KILL_MOB_NEAR_SCULK_CATALYST.
if consumed && catalyst_xp > 0 {
let who = match killer {
Some(Some(conn)) => Some(super::commands::Target::Guest(conn)),
Some(None) if self.lan_acting.is_none() => Some(super::commands::Target::Player),
_ => None,
};
if let Some(who) = who {
self.for_player(who, |g| {
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
});
}
}
if by_player {
// A guest's kill is theirs: their statistics, scores and
// advancements (LivingEntity.die credits lastHurtByPlayer).
@@ -856,7 +887,42 @@ impl Game<'_> {
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
self.trigger_killed(&view, blow.as_ref());
}
/// A mob's damage source as advancement predicates see it: its type, the
/// causing entity, and the projectile when one dealt it.
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
let source = match d.attacker {
Some(Who::Player) => Some(self.player_entity()),
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|o| o.id == id)
.map(|o| self.mob_entity(o)),
None => None,
};
let projectile = match d.kind {
DamageKind::Arrow => Some("minecraft:arrow"),
DamageKind::Fireball => Some("minecraft:fireball"),
_ => None,
};
let direct = match projectile {
Some(kind) => Some(horizon_client::advancements::EntityView {
kind: kind.into(),
position: d.from.unwrap_or_default(),
..Default::default()
}),
None => source.clone(),
};
horizon_client::advancements::DamageView {
kind: damage_type(d.kind, matches!(d.attacker, Some(Who::Player))).to_owned(),
// DamageSource.isDirect: the causing entity is the direct one.
is_direct: projectile.is_none(),
direct,
source,
..Default::default()
}
}
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
/// world's random sequence, equipment by drop chance, species extras and
@@ -1193,14 +1259,18 @@ impl Game<'_> {
owner,
} => self.add_fangs(at, yaw, delay, owner),
MobEvent::Shoot(..) => {}
MobEvent::ExplosionSource { mob, charged } => {
MobEvent::ExplosionSource { mob, charged, name } => {
self.combat.explosion_source = Some(mob);
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.note_named_attacker("minecraft:explosion", mob, name);
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius, species) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let noted = self.combat.explosion_source.is_some();
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
.iter()
@@ -1211,7 +1281,7 @@ impl Game<'_> {
.map(|m| m.id)
.next()
});
if let Some(id) = source_mob {
if !noted && let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
@@ -1279,8 +1349,10 @@ impl Game<'_> {
MobEvent::Hiss { mob } => {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
let at = m.center();
let sound = horizon_client::mobs::finalize::variant_sound(m, "hiss")
.unwrap_or("entity.cat.hiss");
self.audio
.play("minecraft:entity.cat.hiss", Some(at), 1.0, 1.0);
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
}
}
MobEvent::Tamed { .. } | MobEvent::Buck { .. } => self.mount_event(&e),
@@ -1296,6 +1368,26 @@ impl Game<'_> {
}
}
},
MobEvent::Bonemeal { pos } => {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
if self.with_level(|b, level, ticks| b.grow_crop(level, ticks, at)) {
self.level_events
.push(horizon_world::level::LevelEvent::LevelEvent {
id: 1505,
pos: at,
data: 15,
});
let _ = self.process_level_events();
}
}
MobEvent::RingBell { bell } => {
let pos = BlockPos::new(bell[0], bell[1], bell[2]);
let rang = self
.with_level(|b, level, ticks| b.bell_attempt_to_ring(level, ticks, pos));
if rang {
let _ = self.process_level_events();
}
}
MobEvent::ChestOpen { chest, open } => self.golem_chest_open(chest, open),
MobEvent::ChestSlot { chest, slot, stack } => {
self.golem_chest_slot(chest, slot, stack)
@@ -1857,6 +1949,9 @@ impl Game<'_> {
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
{
child.extra.age = -24000;
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
// A llama's baby was drawn in the parent's tick, with the
// experience.
if let Some(o) = llama {
@@ -1952,8 +2047,12 @@ impl Game<'_> {
}
// Offspring inherit a parent's variant (pigs/cows/chickens) or mixed wool.
child.extra.variant = p.extra.variant;
// Fox.getBreedOffspring: either parent's variant.
if p.species() == Species::Fox && !coin {
// Fox, Pig, Cow and Chicken.getBreedOffspring: either parent's variant.
if matches!(
p.species(),
Species::Fox | Species::Pig | Species::Cow | Species::Chicken
) && !coin
{
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
}
if p.species() == Species::Sheep {
@@ -2083,6 +2182,10 @@ impl Game<'_> {
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
// The projectile is the direct entity: named when it has no owner.
if let Some(name) = self.combat.projectile_direct.take() {
self.note_direct_entity(damage_type, name);
}
// DamageSource.scalesWithDifficulty: a mob (or its projectile's
// owner) caused the hit, not a player.
let living_cause = attacker.or(owner).is_some();
@@ -2559,6 +2662,7 @@ impl Game<'_> {
let name = match sound {
ArrowSound::Splash => "minecraft:entity.generic.splash",
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
ArrowSound::CrossbowHit => "minecraft:item.crossbow.hit",
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
ArrowSound::TridentHit => "minecraft:item.trident.hit",
@@ -2666,10 +2770,25 @@ impl Game<'_> {
let hit = self.with_level(|b, level, ticks| {
b.target_projectile_hit(level, ticks, block, location, face, arrow)
});
if hit.is_some() {
if let Some(strength) = hit {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
let kind = if p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
{
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let projectile = horizon_client::advancements::EntityView {
kind: kind.to_owned(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.trigger_target_hit(&projectile, strength);
}
}
}
@@ -2685,6 +2804,26 @@ impl Game<'_> {
Some(Who::Mob(id)) => Some(id),
_ => None,
};
self.combat.projectile_direct = match hit {
Hit::Entity {
who: Who::Player, ..
} => {
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
let id = if spectral {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
Some(Text::translate(
&format!("entity.{}", id.replace(':', ".")),
&[],
))
}
_ => None,
};
let at = match hit {
Hit::Entity { at, .. } | Hit::Block { at, .. } => at,
Hit::None => return Ok(()),
@@ -3254,7 +3393,16 @@ impl Game<'_> {
}
{
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
let used = self.held().cloned();
if self.interact_leash(index, &item) {
if let Some(stack) = &used {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity(
"minecraft:player_interacted_with_entity",
stack,
&target,
);
}
self.swing_arm();
return Ok(true);
}
@@ -3828,6 +3976,11 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
if baby_turtle && !self.mobs[index].is_baby() {
self.handle_mob_events(vec![MobEvent::GiftLoot {
@@ -3871,6 +4024,9 @@ impl Game<'_> {
}
}
if let Some(stack) = armor_drop {
// Mob.shearItem: PLAYER_SHEARED_EQUIPMENT with the armor and the wolf.
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
@@ -4045,7 +4201,7 @@ impl Game<'_> {
};
let parent = &self.mobs[index];
if !parent.alive()
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
@@ -4060,15 +4216,11 @@ impl Game<'_> {
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
let Some(id) = self.spawn_mob(&kind, at, true) else {
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
@@ -4106,7 +4258,7 @@ impl Game<'_> {
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
if self.spawn_mob(&kind, at, true).is_none() {
if self.spawn_mob_spawned(&kind, at).is_none() {
return Ok(false);
}
self.swing_arm();
@@ -4424,6 +4576,63 @@ impl Game<'_> {
self.wind_charge_geometry(a, c, packed, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
std::sync::OnceLock::new();
let model = BULLET.get_or_init(|| {
horizon_client::mobs::load_model(
&self.options.resources,
"minecraft:shulker_bullet#main",
)
});
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core.
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
@@ -4546,6 +4755,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::SonicBoom => "minecraft:sonic_boom",
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
K::Cramming => "minecraft:cramming",
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
+1 -1
View File
@@ -163,7 +163,7 @@ impl Game<'_> {
}
/// A `show_dialog` click event's dialog: a registry id or the dialog itself.
fn show_dialog_ref(&mut self, dialog: &Tag) {
pub(super) fn show_dialog_ref(&mut self, dialog: &Tag) {
match dialog {
Tag::String(id) => {
let id = id.to_string_lossy();
+9 -1
View File
@@ -1083,7 +1083,7 @@ impl Game<'_> {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
self.trigger_killed(view, None);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
@@ -2230,12 +2230,20 @@ impl Game<'_> {
.filter(|p| p.alive && !p.creative && !p.spectator)
.collect();
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
// DragonSittingFlamingPhase.end discards the flame cloud.
let flame_over = self
.dragon
.as_ref()
.is_some_and(|d| !matches!(d.phase, Phase::Flaming { .. }));
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
if c.from_dragon && flame_over {
return false;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -71,10 +71,18 @@ impl Game<'_> {
Some(r) => self.settings.render_distance.min(r.view_distance),
None => self.settings.render_distance,
};
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
entity_view_scale(distance, self.settings.entity_distance)
}
}
/// Entity.setViewScale: `Mth.clamp(renderDistance / 8, 1, 2.5) *
/// entityDistanceScaling`. Every entity's draw distance is scaled by it, so
/// a small mob is drawn nearer than a big one and a longer render distance
/// draws both further away.
pub(super) fn entity_view_scale(render_distance: i32, entity_distance: f32) -> f64 {
(f64::from(render_distance) / 8.0).clamp(1.0, 2.5) * f64::from(entity_distance)
}
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
@@ -135,3 +143,41 @@ impl Game<'_> {
self.entity_in_view(camera, position, range, lo, hi)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `Entity.setViewScale`: the entity distance option times the render
/// distance's boost — 1 at eight chunks, 1.5 at twelve, 2.5 at twenty.
#[test]
fn the_view_scale_follows_the_render_distance() {
assert_eq!(entity_view_scale(4, 1.0), 1.0);
assert_eq!(entity_view_scale(8, 1.0), 1.0);
assert_eq!(entity_view_scale(12, 1.0), 1.5);
assert_eq!(entity_view_scale(20, 1.0), 2.5);
assert_eq!(entity_view_scale(32, 1.0), 2.5);
// The entity distance option scales it down.
assert_eq!(entity_view_scale(12, 0.5), 0.75);
}
/// `Entity.shouldRenderAtSqrDistance`: a chicken's box (0.4 by 0.7 by
/// 0.4) is drawn up to 48 blocks away at the vanilla defaults, not the
/// 32 blocks a missing view scale leaves (#2273).
#[test]
fn a_chicken_is_drawn_forty_eight_blocks_away() {
let range = default_range(glam::DVec3::new(0.4, 0.7, 0.4), entity_view_scale(12, 1.0));
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
fn a_broken_bounding_box_falls_back_to_one_block() {
let range = default_range(
glam::DVec3::new(f64::NAN, 0.7, 0.4),
entity_view_scale(12, 1.0),
);
assert_eq!(range, 96.0);
}
}
+25 -1
View File
@@ -1,11 +1,13 @@
//! Field of view (`Camera.tickFov`, `calculateFov`,
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin
//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`,
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
use super::Game;
use horizon_client::{
camera::{
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
spinning_effect_matrix,
},
fog::{FogType, fog_type},
hud::HudBatch,
@@ -26,6 +28,28 @@ impl Game<'_> {
pub(super) fn fov_effect_scale(&self) -> f32 {
self.settings.file.double("fovEffectScale") as f32
}
/// `options.screenEffectScale` (Distortion Effects).
pub(super) fn screen_effect_scale(&self) -> f32 {
self.settings.file.double("screenEffectScale") as f32
}
/// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect.
pub(super) fn nausea_blend(&self, partial: f32) -> f32 {
if self.potions.effects.get("nausea").is_none() {
return 0.0;
}
let fx = &self.screen_fx;
fx.o_nausea + partial * (fx.nausea - fx.o_nausea)
}
/// The spin `GameRenderer.renderLevel` multiplies into the level's
/// projection: the larger of the portal and Nausea intensities, scaled by
/// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`.
pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 {
let fx = &self.screen_fx;
let portal = fx.o_portal + partial * (fx.portal - fx.o_portal);
let scale = self.screen_effect_scale();
let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale);
spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed)
}
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
fn fov_player(&self) -> Option<FovPlayer> {
let p = self.player.as_ref()?;
+3 -1
View File
@@ -429,7 +429,9 @@ impl Game<'_> {
if let Some(id) = map {
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
let depth = if f.invisible { 0.5 } else { 0.4375 };
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
// A glow frame's map is 15728850 = 0xF000D2 (sky coordinate 240,
// block coordinate 210), not full bright.
let item_light = if f.glow { 240 | 210 << 8 } else { packed };
let m = pose
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z(
+4 -4
View File
@@ -82,11 +82,11 @@ fn civil(days: i64) -> (i64, u32, u32) {
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
/// year and time letters item models use (UTC: no time zone database).
/// year and time letters item models use, in the system time zone
/// (`LocalTime` without a `time_zone`).
pub(super) fn local_time(pattern: &str) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let now = chrono::Local::now();
let secs = now.timestamp() + i64::from(now.offset().local_minus_utc());
let (y, mo, d) = civil(secs.div_euclid(86_400));
let s = secs.rem_euclid(86_400);
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
+11 -1
View File
@@ -2607,6 +2607,9 @@ impl Game<'_> {
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
// LivingEntity.aiStep: a Riptide spin counts down on the server's
// copy too, or it would stay in the spin pose (eye 0.4) for good.
w.player.count_down_server_spin();
// Player.tick ends with updatePlayerPose: a swimmer, or a player
// crawling under a block, takes the short box its moves are checked with.
let _ = w.update_player_pose();
@@ -2706,8 +2709,15 @@ impl Game<'_> {
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
MotionEvent::HoneySlide {
sound, particles, ..
pos,
sound,
particles,
} => {
if self.world.game_time % 20 == 0 {
self.as_guest(host, conn, |game| {
game.trigger_honey_slide([pos.x, pos.y, pos.z])
});
}
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
else {
continue;
@@ -7,6 +7,7 @@
use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
furnace::FurnaceCooking,
items::ItemStack,
lan::{guest::Guest, menu::Slots},
menu::{Env, Input, Kind, Menu},
+3 -3
View File
@@ -408,9 +408,9 @@ impl Game<'_> {
}
*next += self.clock.tick_duration();
if *next > now {
// A `/pregen` task or force-loaded chunks keep the workers fed
// until the next tick.
self.pump_pregen(*next);
// The chunks loading (the players', force-loaded ones, a
// `/pregen` task) keep the workers fed until the next tick.
self.pump_chunks(*next, paused);
let now = Instant::now();
if *next > now {
std::thread::sleep(*next - now);
+79 -11
View File
@@ -4,7 +4,8 @@
//! `thunderHit` (damage and burning, charged creepers, zombified piglins,
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
//! flash, the sounds and the branching additive bolt.
use super::Game;
use super::{Game, commands::Target};
use horizon_client::advancements::EntityView;
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
@@ -18,6 +19,13 @@ pub(super) struct Bolt {
visual_only: bool,
/// Mooshrooms and copper golems change once per bolt.
converted: Vec<u32>,
/// The mobs it has struck (`hitEntities`).
hit: Vec<u32>,
/// Blocks it set on fire (`blocksSetOnFire`).
fires: i32,
/// The player who channelled it (`LightningBolt.cause`): `Some(None)` for
/// the host, `Some(Some(conn))` for a guest.
pub cause: Option<Option<u32>>,
}
impl Game<'_> {
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
@@ -29,10 +37,13 @@ impl Game<'_> {
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
return;
}
let (_, mut chunks) = self.spawning_chunks(&self.level_players());
let mut scratch = std::mem::take(&mut self.spawn_scratch);
self.spawning_chunks(&self.level_players(), &mut scratch);
let levels = self.tickets.levels();
chunks.retain(|&(x, z)| levels.random_ticking(x, z));
for (cx, cz) in chunks {
scratch
.candidates
.retain(|&(x, z)| levels.random_ticking(x, z));
for (cx, cz) in scratch.candidates.drain(..) {
if self.world.random.next_int_bound(100_000).unwrap_or(1) != 0 {
continue;
}
@@ -77,6 +88,7 @@ impl Game<'_> {
];
self.spawn_bolt(at, trap);
}
self.spawn_scratch = scratch;
}
/// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the
/// lightning rod points of interest within 128 blocks of `center` whose
@@ -206,6 +218,9 @@ impl Game<'_> {
seed,
visual_only,
converted: Vec::new(),
hit: Vec::new(),
fires: 0,
cause: None,
});
}
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
@@ -224,11 +239,16 @@ impl Game<'_> {
seed,
visual_only: true,
converted: Vec::new(),
hit: Vec::new(),
fires: 0,
cause: None,
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
/// wherever `BaseFireBlock.getState` fire can survive.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
/// wherever `BaseFireBlock.getState` fire can survive. Returns how many
/// blocks it set on fire.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) -> i32 {
let mut set = 0;
let base = [
at[0].floor() as i32,
at[1].floor() as i32,
@@ -253,8 +273,10 @@ impl Game<'_> {
});
if let Some(fire) = fire {
let _ = self.level_set(p, fire);
set += 1;
}
}
set
}
/// `LightningBolt.tick` for every bolt.
pub(super) fn tick_bolts(&mut self) {
@@ -278,7 +300,7 @@ impl Game<'_> {
if !b.visual_only {
// Only Normal and Hard start fires with the strike itself.
if self.ui.difficulty >= 2 {
self.bolt_fire(b.at, 4);
b.fires += self.bolt_fire(b.at, 4);
}
self.strike_rod(b.at);
}
@@ -293,6 +315,7 @@ impl Game<'_> {
b.life -= 1;
if b.life < 0 {
if b.flashes == 0 {
self.lightning_strike_triggers(b);
return false;
}
if b.life < -self.world.random.next_int_bound(10).unwrap_or(0) {
@@ -300,7 +323,7 @@ impl Game<'_> {
b.life = 1;
b.seed = self.world.random.next_long();
if !b.visual_only {
self.bolt_fire(b.at, 0);
b.fires += self.bolt_fire(b.at, 0);
}
}
}
@@ -310,7 +333,11 @@ impl Game<'_> {
}
self.flash = 2;
if !b.visual_only {
self.thunder_hit(b);
let struck = self.thunder_hit(b);
if let Some(cause) = b.cause {
let who = cause.map_or(Target::Player, Target::Guest);
self.for_player(who, |g| g.trigger_channeled_lightning(&struck));
}
}
}
true
@@ -355,8 +382,41 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
let _ = self.process_level_events();
}
/// `thunderHit` for entities within 3 blocks (up to 9 above).
fn thunder_hit(&mut self, b: &mut Bolt) {
/// `LightningStrikeTrigger` at the bolt's discard: every player within 256
/// blocks, with the mobs in its 30-block box that it did not hit.
fn lightning_strike_triggers(&mut self, b: &Bolt) {
let at = b.at;
let bolt = EntityView {
kind: "minecraft:lightning_bolt".into(),
position: at,
blocks_set_on_fire: b.fires,
..Default::default()
};
let viewers: Vec<EntityView> = self
.mobs
.iter()
.filter(|m| m.alive() && !b.hit.contains(&m.id))
.filter(|m| {
(m.position.x - at[0]).abs() <= 15.0
&& (m.position.z - at[2]).abs() <= 15.0
&& m.position.y >= at[1] - 15.0
&& m.position.y <= at[1] + 21.0
})
.map(|m| self.mob_entity(m))
.collect();
self.for_each_player(|g| {
let Some(p) = g.player.as_ref().map(|p| p.position) else {
return;
};
let d2 = (p.x - at[0]).powi(2) + (p.y - at[1]).powi(2) + (p.z - at[2]).powi(2);
if d2 < 256.0 * 256.0 {
g.trigger_lightning_strike(&bolt, &viewers);
}
});
}
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
let at = b.at;
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
@@ -364,6 +424,13 @@ impl Game<'_> {
&& p[1] >= at[1] - 3.0
&& p[1] <= at[1] + 9.0
};
let struck: Vec<(u32, EntityView)> = self
.mobs
.iter()
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
.map(|m| (m.id, self.mob_entity(m)))
.collect();
b.hit.extend(struck.iter().map(|(id, _)| *id));
let damage = Damage {
kind: DamageKind::Generic,
attacker: None,
@@ -461,6 +528,7 @@ impl Game<'_> {
!inside([e.position.x, e.position.y, e.position.z])
|| e.stack.item.contains("netherite")
});
struck.into_iter().map(|(_, view)| view).collect()
}
/// Simulation-order render inputs; seeded geometry lives in the renderer.
pub(super) fn bolt_instances(&self) -> Vec<horizon_client::gfx::lightning::Bolt> {
+14 -6
View File
@@ -595,6 +595,18 @@ impl Game<'_> {
/// `HopperBlockEntity.pushItemsTick` of the hopper at `pos`, if its block
/// is still a hopper (`BlockEntityType.isValid`).
pub(super) fn tick_hopper(&mut self, pos: Pos) {
let time = self.world.game_time;
let waiting = {
let state = self.hoppers.entry(pos).or_default();
state.cooldown -= 1;
state.ticked = time;
state.cooldown > 0
};
if waiting {
return;
}
// Most ticks only advance the cooldown: the block is checked when the
// hopper is about to move, not on every tick it waits.
let hopper = self
.res
.facts()
@@ -606,14 +618,10 @@ impl Game<'_> {
self.hoppers.remove(&pos);
return;
}
let time = self.world.game_time;
let state = self.hoppers.entry(pos).or_default();
state.cooldown -= 1;
state.ticked = time;
if state.cooldown <= 0 {
if let Some(state) = self.hoppers.get_mut(&pos) {
state.cooldown = 0;
self.hopper_try_move_items(pos);
}
self.hopper_try_move_items(pos);
}
/// Drops the cooldowns of hoppers whose block entity is gone.
pub(super) fn forget_gone_hoppers(&mut self) {
+1
View File
@@ -739,6 +739,7 @@ impl Game<'_> {
return false;
}
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.audio
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
let target = Tag::compound([(
+2 -17
View File
@@ -202,7 +202,7 @@ impl Minecart {
self.velocity.x.hypot(self.velocity.z)
}
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
pub(super) fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
let mut total = 0.0f32;
for s in slots.iter().flatten() {
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
@@ -1029,22 +1029,6 @@ impl Game<'_> {
}
best
}
/// Each cart's kind and comparator signal, for the detector rail
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
/// counts the items in that menu.
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
self.carts
.iter()
.map(|c| {
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &c.items,
};
(c.kind, Minecart::container_signal(slots, self.res.items()))
})
.collect()
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -1475,6 +1459,7 @@ impl Game<'_> {
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
self.res
.block_entity_render()
@@ -95,7 +95,9 @@ impl Game<'_> {
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() {
// The tables come from the world resources, which may still be
// loading behind the title on a slow start.
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
+11 -1
View File
@@ -259,6 +259,7 @@ impl Game<'_> {
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
Species::Donkey => "minecraft:entity.donkey.eat",
Species::Mule => "minecraft:entity.mule.eat",
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
_ => "minecraft:entity.horse.eat",
};
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
@@ -337,7 +338,16 @@ impl Game<'_> {
}
// The nautilus plays its dash sound itself.
if equine::is_equine(m.species()) {
let sound = "minecraft:entity.horse.jump";
// playJumpSound: Donkey and Mule have their own; a skeleton
// horse in water splashes.
let sound = match m.species() {
Species::Donkey => "minecraft:entity.donkey.jump",
Species::Mule => "minecraft:entity.mule.jump",
Species::SkeletonHorse if m.fluids.in_water => {
"minecraft:entity.skeleton_horse.jump_water"
}
_ => "minecraft:entity.horse.jump",
};
let at = m.center();
self.audio.play(sound, Some(at), 0.4, 1.0);
}
+16 -5
View File
@@ -81,10 +81,13 @@ impl Game<'_> {
let viewer = self.player_team();
let mut tags = Vec::new();
let players = self.other_players();
let named = self
.mobs
.iter()
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
let named = self.mobs.iter().filter_map(|m| {
let name = m.custom_name.as_ref()?;
// The tag's 64 block distance is measured to the entity: copy the
// name only for a mob the frame can show it on.
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
(at.distance_squared(camera) < 64.0 * 64.0).then(|| (m, name.clone(), false))
});
let players = players.into_iter().map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
@@ -328,7 +331,15 @@ impl Game<'_> {
) {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
// The camera's facing (Camera.rotation()), turned round in third-person
// front view, so the text reads the right way from where it is seen.
let (view_yaw, view_pitch) =
if self.camera_type == super::perspective::CameraType::ThirdPersonFront {
(self.yaw + 180.0, -self.pitch)
} else {
(self.yaw, self.pitch)
};
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
let white = self.gui.atlas.font.white();
@@ -24,6 +24,15 @@ use horizon_core::{
use horizon_world::registry::BlockStateId;
use std::collections::HashMap;
/// Reused buffers for one pass over the spawning chunks: the candidate chunks
/// and the set that keeps each one once. The buffers keep their capacity
/// between ticks.
#[derive(Default)]
pub(super) struct SpawnScratch {
pub(super) candidates: Vec<(i32, i32)>,
pub(super) seen: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
}
/// A player in the level in place, as spawning and despawning see it.
pub(super) struct LevelPlayer {
pub(super) position: Vec3,
@@ -445,9 +454,17 @@ impl<'a> Game<'a> {
/// chunks (within 8 chunks of a player, `getSpawnCandidateChunks`) that
/// are ticking and within 128 blocks of a non-spectator. Also returns how
/// many chunks the tracker holds (`getNaturalSpawnChunkCount`).
pub(super) fn spawning_chunks(&self, players: &[LevelPlayer]) -> (i32, Vec<(i32, i32)>) {
let mut candidates: Vec<(i32, i32)> = Vec::with_capacity(289 * players.len());
let mut seen = horizon_client::fast_hash::FxHashSet::default();
pub(super) fn spawning_chunks(
&self,
players: &[LevelPlayer],
scratch: &mut SpawnScratch,
) -> i32 {
let candidates = &mut scratch.candidates;
candidates.clear();
candidates.reserve(289 * players.len());
let seen = &mut scratch.seen;
seen.clear();
seen.reserve(289 * players.len());
for p in players {
let (pcx, pcz) = (
p.position.x.floor() as i32 >> 4,
@@ -471,7 +488,7 @@ impl<'a> Game<'a> {
.iter()
.any(|p| close_enough_for_spawning(p, (cx, cz)))
});
(count, candidates)
count
}
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
/// the categories under their global caps (creatures every 400 ticks),
@@ -482,8 +499,9 @@ impl<'a> Game<'a> {
if players.is_empty() {
return;
}
let (spawnable, mut chunks) = self.spawning_chunks(&players);
for &key in &chunks {
let mut scratch = std::mem::take(&mut self.spawn_scratch);
let spawnable = self.spawning_chunks(&players, &mut scratch);
for &key in &scratch.candidates {
*self.world.inhabited.entry(key).or_insert(0) += 1;
}
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
@@ -524,12 +542,12 @@ impl<'a> Game<'a> {
Vec::new()
};
// Util.shuffle.
for i in (1..chunks.len()).rev() {
for i in (1..scratch.candidates.len()).rev() {
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
chunks.swap(i, j);
scratch.candidates.swap(i, j);
}
if !categories.is_empty() {
for chunk in chunks {
for chunk in scratch.candidates.drain(..) {
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
let spawned = l.game.finalize_spawned(r, &mut pending);
if let Some(s) = &spawned {
@@ -551,6 +569,7 @@ impl<'a> Game<'a> {
}
self.world.random = random;
self.add_spawned(pending, false);
self.spawn_scratch = scratch;
}
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
/// freshly generated chunk, from the biome at its corner column's top,
@@ -273,6 +273,7 @@ impl Game<'_> {
s.horizon.hdr,
s.horizon.hdr_paper_white as f32,
s.horizon.hdr_peak as f32,
&Self::options_path().with_file_name(horizon_client::graphics::HDR_MARKER),
);
}
// Window.updateFullscreenIfChanged: only a changed option asks the
+8
View File
@@ -45,6 +45,14 @@ pub fn open_folder(dir: &Path) {
}
/// `Util.getPlatform().openUri`: the system browser on the link.
pub fn open_uri(uri: &str) {
// URLs need Windows' registered protocol handler, not Explorer's path
// handling. ShellExecute also preserves the URL as one Unicode value,
// without interpolating its query string into a command interpreter.
#[cfg(windows)]
if let Err(error) = open::that_detached(uri) {
log::warn!("Horizon: could not open URI {uri}: {error}");
}
#[cfg(not(windows))]
open_folder(Path::new(uri));
}
/// The resource root to load from, given the vanilla root, the selection and
@@ -158,7 +158,10 @@ pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
ambient_interval: 80,
flags: Default::default(),
model: Arc::new(model),
layers: Default::default(),
// PlayerModel's slim arms, for slim skins.
layers: horizon_client::mobs::load_model(root, "minecraft:player_slim#main")
.map(|slim| [("player_slim#main".to_owned(), Arc::new(slim))].into())
.unwrap_or_default(),
texture: "entity/player/wide/steve".into(),
brain: Brain::Passive,
anim: Anim::Humanoid { zombie_arms: false },
+4 -6
View File
@@ -960,7 +960,8 @@ impl Game<'_> {
let amount =
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
if (*id == "instant_damage") != inverted {
m.hurt(amount(6), Some(at), &mut self.world.random);
let harm = m.potion_harm(amount(6));
m.hurt(harm, Some(at), &mut self.world.random);
} else {
m.health = (m.health + amount(4)).min(m.kind.max_health);
}
@@ -1228,11 +1229,8 @@ impl Game<'_> {
};
let m = &mut self.mobs[i];
if harm {
m.hurt(
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
Some([at.x, at.y, at.z]),
&mut self.world.random,
);
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);
} else {
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
.min(m.kind.max_health);
+79 -28
View File
@@ -213,7 +213,8 @@ pub(super) struct Remote {
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
/// Inventory (then the off hand) as last synchronised, for creative-mode
/// slot updates.
synced: Vec<Option<ItemStack>>,
decode_errors: u32,
clock: Option<i32>,
@@ -510,7 +511,7 @@ impl Game<'_> {
death_message: None,
show_death_screen: true,
player_type,
synced: vec![None; 36],
synced: vec![None; 37],
decode_errors: 0,
clock: None,
clock_rate: 1.0,
@@ -699,7 +700,6 @@ impl Game<'_> {
return;
}
let text = content.plain();
log::info!("[chat] {text}");
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = (source != horizon_client::chat::ChatSource::Player)
@@ -3086,6 +3086,20 @@ impl Game<'_> {
.collect();
Some((players, r.online_mode, upside_down))
}
/// The signed-in account's `textures`, once the services answered.
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
use horizon_client::skins::{self, NameOrId, Resolve};
let auth = horizon_client::auth::current()?;
let state = self
.skin_profiles
.borrow_mut()
.state(&NameOrId::Id(auth.uuid));
let Resolve::Done(Some(profile)) = state else {
return None;
};
let (value, signature) = profile.textures()?;
Some(skins::unpack(&value, signature, &skins::TEXTURE_URIS))
}
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
/// never in the tab list: resolve each profile's textures and give the
/// level's player models their skin.
@@ -3094,12 +3108,23 @@ impl Game<'_> {
return;
};
let local = r.profile;
let entries: Vec<(u128, horizon_client::skins::Textures)> = r
let mut entries: Vec<(u128, horizon_client::skins::Textures)> = r
.skin_textures
.iter()
.map(|(uuid, textures)| (*uuid, textures.clone()))
.collect();
// Minecraft.profileFuture: the signed-in account's own profile, fetched from the
// services, gives the local player its skin where the server sends none (single
// player's offline-mode server).
if !entries.iter().any(|(u, t)| *u == local && t.skin.is_some())
&& let Some(own) = self.own_textures()
{
entries.retain(|(u, _)| *u != local);
entries.push((local, own));
}
let mut resolved: Vec<(u128, String)> = Vec::new();
// PlayerSkin.model of each resolved skin: the textures' `slim`.
let mut slim = std::collections::HashSet::new();
for (uuid, textures) in entries {
// PlayerInfo.createSkinLookup: only the local player's own skin
// may be unsigned.
@@ -3110,10 +3135,22 @@ impl Game<'_> {
})
};
if let Some(skin) = skin {
if textures.slim {
slim.insert(uuid);
}
resolved.push((uuid, skin));
}
}
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
// PlayerInfo.getSkin: the downloaded skin and its model, else the
// profile's default skin (DefaultPlayerSkin.get), slim or wide.
let skin_of = |skins: &HashMap<u128, String>, uuid: u128| match skins.get(&uuid) {
Some(skin) => (skin.clone(), slim.contains(&uuid)),
None => {
let skin = horizon_client::tab_list::default_skin(uuid);
(skin.to_owned(), skin.contains("/slim/"))
}
};
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
@@ -3122,27 +3159,26 @@ impl Game<'_> {
.map(|(id, uuid)| (*id as u32, *uuid))
.collect::<Vec<_>>(),
r.skins.clone(),
r.skins.get(&r.profile).cloned(),
r.profile,
)
}) else {
return;
};
for (id, uuid) in level {
let Some(skin) = skins.get(&uuid) else {
continue;
};
let (skin, is_slim) = skin_of(&skins, uuid);
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:player")
{
mob.skin = Some(skin.clone());
mob.skin = Some(skin);
mob.slim = is_slim;
}
}
if let Some(skin) = own
&& let Some(mob) = self.player_model.as_mut()
{
if let Some(mob) = self.player_model.as_mut() {
let (skin, is_slim) = skin_of(&skins, own);
mob.skin = Some(skin);
mob.slim = is_slim;
}
// ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
// mannequin's DATA_PROFILE `textures` property resolves like a
@@ -3192,7 +3228,7 @@ impl Game<'_> {
(m.id, textures)
})
.collect();
let mut resolved: Vec<(u32, Option<String>)> = Vec::new();
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new();
for (id, textures) in mannequins {
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
@@ -3200,15 +3236,17 @@ impl Game<'_> {
downloads.state(kind, url)
})
};
resolved.push((id, skin));
let slim = skin.is_some() && textures.slim;
resolved.push((id, skin, slim));
}
for (id, skin) in resolved {
for (id, skin, slim) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin")
{
mob.slim = slim && skin.is_some();
mob.skin = skin;
}
}
@@ -3592,6 +3630,7 @@ impl Game<'_> {
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
// Gui.tick: the titles, the action bar and the held item's name.
self.tick_hud();
self.tick_subtitles();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let Some(player) = self.player.as_mut() else {
@@ -4307,12 +4346,14 @@ impl Game<'_> {
{
out.push(packet);
}
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot).
// Creative inventory edits are sent slot by slot (SetCreativeModeSlot);
// `synced[36]` is the off hand.
let mut creative_slots = Vec::new();
if self.creative {
for i in 0..36 {
if self.inventory[i] != r.synced[i] {
r.synced[i] = self.inventory[i].clone();
for i in 0..37 {
let stack = self.inventory.get(i).unwrap_or(&self.offhand);
if *stack != r.synced[i] {
r.synced[i] = stack.clone();
creative_slots.push(i);
}
}
@@ -4321,8 +4362,13 @@ impl Game<'_> {
self.send(packet);
}
for i in creative_slots {
let slot = if i < 9 { 36 + i } else { i } as i16;
let item = self.to_network(&self.inventory[i]);
// InventoryMenu's slots: the hotbar is 36..=44, the off hand 45.
let slot = match i {
0..9 => 36 + i,
36 => 45,
_ => i,
} as i16;
let item = self.to_network(self.inventory.get(i).unwrap_or(&self.offhand));
self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item,
@@ -4331,7 +4377,8 @@ impl Game<'_> {
}
/// Inventory as the server last described it.
fn remote_synced(&mut self) {
let inventory = self.inventory.clone();
let mut inventory = self.inventory.clone();
inventory.push(self.offhand.clone());
if let Some(r) = self.remote.as_mut() {
r.synced = inventory;
}
@@ -5055,8 +5102,11 @@ impl Game<'_> {
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
let remaining = consumable.ticks() - ticks as i32;
if consumable.particles && consumable.should_emit(remaining) {
self.eat_particles(&stack, 5);
if consumable.should_emit(remaining) {
// `emit_consume` plays the eat sound and spawns the bites (when the
// item has consume particles), as `tick_used_item` does on the
// player's own server.
self.emit_consume(&consumable, &stack, 5);
}
self.using = Some((slot, ticks + 1));
}
@@ -5337,11 +5387,12 @@ impl Game<'_> {
KeyCode::KeyE => {
// The inventory opens without telling the server.
self.recipe_ui.reset_screen();
self.screen = if self.creative {
Screen::Creative
if self.creative {
self.screen = Screen::Creative;
self.creative_screen.reopen();
} else {
Screen::Inventory
};
self.screen = Screen::Inventory;
}
self.craft_size = 2;
self.capture(false);
Ok(true)
+15 -4
View File
@@ -108,7 +108,11 @@ impl Game<'_> {
rain,
thunder,
skybox: self.dim == Dim::Overworld,
blindness: None,
blindness: self
.potions
.effects
.get("blindness")
.map(|effect| (effect.duration, effect.duration == -1)),
darkness: self.darkness_fog(),
darken_world: self
.boss_overlay
@@ -140,7 +144,8 @@ impl Game<'_> {
} else {
63.0
};
camera.y < horizon
self.dim == Dim::Overworld
&& camera.y < horizon
&& fog_type(
&self.world,
self.res.facts(),
@@ -209,8 +214,8 @@ impl Game<'_> {
percent_frozen: p.percent_frozen(),
partial,
portal: [fx.o_portal, fx.portal],
nausea: 0.0,
screen_effect_scale: 1.0,
nausea: self.nausea_blend(partial),
screen_effect_scale: self.screen_effect_scale(),
};
let mut scope = self.scope_scale;
let gui_overlays =
@@ -247,6 +252,12 @@ impl Game<'_> {
let brightness = self.eye_brightness(e);
let inside_portal = self.client_portal_effect();
self.screen_fx.tick(brightness, inside_portal);
let nausea = self
.potions
.effects
.get("nausea")
.map(|e| !e.ends_within(60));
self.screen_fx.tick_spinning(nausea);
// LocalPlayer water vision and AtmosphericFogEnvironment rain fog.
let in_water =
fog_type(&self.world, self.res.facts(), e, self.forward()) == FogType::Water;
+40 -1
View File
@@ -623,6 +623,18 @@ impl Game<'_> {
SculkKind::Shrieker => sculk::shrieker_can_listen(event),
_ => sculk::is_vibration(event),
};
// VibrationSystem.User.isValidVibration: a player sneaking past a
// sensor, whose step it ignores, is AVOID_VIBRATION (sensors only;
// a shrieker cannot trigger it).
if listens
&& matches!(kind, SculkKind::Sensor | SculkKind::Calibrated)
&& ctx.source == Some(Who::Player)
&& !ctx.spectator
&& ctx.stepping_carefully
&& sculk::ignored_when_sneaking(event)
{
self.trigger_player("minecraft:avoid_vibration");
}
if !sculk::valid_vibration(listens, event, ctx) {
return;
}
@@ -693,12 +705,15 @@ impl Game<'_> {
fn warden_listen(&mut self, index: usize, event: &'static str, pos: [f64; 3], ctx: &Context) {
let now = self.world.game_time;
let (occludes, world) = (&self.sculk.tables.occludes, &self.world);
warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
let avoided = warden::listen(&mut self.mobs[index], now, event, pos, ctx, |a, b| {
sculk::is_occluded(a, b, |p| {
let state = world.block(p.x, p.y, p.z);
occludes.get(state.0 as usize).copied().unwrap_or(false)
})
});
if avoided {
self.trigger_player("minecraft:avoid_vibration");
}
}
// ---- ticking ----
@@ -1577,6 +1592,30 @@ impl Game<'_> {
}
}
/// `RedStoneOreBlock.useItemOn`: a right-click lights the ore whatever the
/// hand holds. False (PASS) when the hand holds a block item, which goes on
/// to be placed; true (SUCCESS) otherwise.
pub(super) fn use_redstone_ore(
&mut self,
pos: [i32; 3],
held: Option<&horizon_client::items::ItemStack>,
) -> Result<bool> {
if !matches!(
&*self.block_name(pos),
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
) {
return Ok(false);
}
self.attack_block(pos)?;
let block_item = held.is_some_and(|s| {
self.res
.items()
.get(&s.item)
.is_some_and(|i| i.block.is_some())
});
Ok(!block_item)
}
fn tick_mob_sculk_events(&mut self) {
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
.mobs
@@ -269,6 +269,8 @@ impl Game<'_> {
open.requests.push((p.id, p.url, p.hash));
open.required = p.required;
open.prompt = text;
// Gui.setScreen: KeyMapping.releaseAll, so no walking behind the prompt.
self.keys.clear();
self.capture(false);
}
/// `handleResourcePackPop`: one pack, or all of them.
+31 -22
View File
@@ -10,6 +10,7 @@ use horizon_client::{
Mob, Who,
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerSpawn},
spawning,
trial_spawner::TrialState,
},
particles::{ParticleOptions, ParticleType},
};
@@ -64,14 +65,13 @@ impl Game<'_> {
.unwrap_or_default();
self.spawners.entity_types = Some(types);
}
/// The server tick of the spawner at `key`, if its block is still a
/// spawner. Its client tick comes after every block entity's
/// (`client_tick_spawners`).
/// The server tick of the spawner at `key`. Its client tick comes after
/// every block entity's (`client_tick_spawners`).
///
/// The caller only reaches this for a `BlockEntity::Spawner`, and changing
/// the block removes or replaces that entity, so the block is not read
/// again here.
pub(super) fn tick_spawner(&mut self, key: Pos) -> Result<()> {
let state = self.world.block(key.0, key.1, key.2);
if &*self.res.facts().get(state).name != "minecraft:spawner" {
return Ok(());
}
let Some(BlockEntity::Spawner(mut spawner)) = self.world.block_entities.take(&key) else {
return Ok(());
};
@@ -373,12 +373,11 @@ impl Game<'_> {
/// clicked block has no spawner.
pub(super) fn use_spawn_egg_on_spawner(&mut self, pos: [i32; 3], kind: &str) -> Option<bool> {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Spawner(_))
) {
return None;
}
let trial = match self.world.block_entities.get(&key) {
Some(BlockEntity::Spawner(_)) => false,
Some(BlockEntity::TrialSpawner(_)) => true,
_ => return None,
};
if !self.ui.rules.bool("spawner_blocks_work") {
self.system_message(horizon_client::text::Text::translate(
"advMode.notEnabled.spawner",
@@ -386,16 +385,26 @@ impl Game<'_> {
));
return Some(false);
}
let mut events = Vec::new();
let mut random = std::mem::replace(
&mut self.world.random,
horizon_core::random::LegacyRandom::new(0),
);
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
s.set_entity_id(kind, &mut random, &mut events);
if trial {
// TrialSpawnerBlockEntity.setEntityId: overrideEntityToSpawn, which
// ends in setState(INACTIVE).
if let Some(BlockEntity::TrialSpawner(t)) = self.world.block_entities.get_mut(&key) {
t.override_entity(kind);
}
let ominous = self.trial_spawner_block(key).is_some_and(|(_, o)| o);
let _ = self.set_trial_spawner_state(key, TrialState::Inactive, ominous);
} else {
let mut events = Vec::new();
let mut random = std::mem::replace(
&mut self.world.random,
horizon_core::random::LegacyRandom::new(0),
);
if let Some(BlockEntity::Spawner(s)) = self.world.block_entities.get_mut(&key) {
s.set_entity_id(kind, &mut random, &mut events);
}
self.world.random = random;
let _ = self.apply_spawner_events(key, events, &HashMap::new());
}
self.world.random = random;
let _ = self.apply_spawner_events(key, events, &HashMap::new());
// sendBlockUpdated(3) and the player's block_change game event.
self.spawner_block_updated(key);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
+14 -1
View File
@@ -44,8 +44,16 @@ impl Game<'_> {
}
/// `ServerPlayer.getKnownMovement` of the acting player: what they moved
/// in the last tick. A guest's comes with their moves.
/// in the last tick. A guest's comes with their moves. A rider's is its
/// mount's move (`getKnownSpeed`: the vehicle's, or the move the driver's
/// client sent for it), so a charge on horseback carries the horse's speed.
fn known_player_movement(&self) -> Vec3 {
if let Some(i) = self
.riding
.and_then(|v| self.mobs.iter().position(|m| m.id == v))
{
return self.mob_speed(i).scale(1.0 / 20.0);
}
if self.lan_acting.is_some() {
return self.known_movement;
}
@@ -193,6 +201,9 @@ impl Game<'_> {
}
let who = actor.map_or(OWN, |conn| conn as i32);
self.kinetic_hit_feedback(who, &kinetic, [feet.x, feet.y, feet.z]);
// SPEAR_MOBS_TRIGGER: the living entities stabbed in this use.
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
self.trigger_spear_mobs(stabbed);
}
}
@@ -239,6 +250,8 @@ impl Game<'_> {
self.mobs[index].knockback(0.4, xd, zd);
let (strength, _) = self.extra_knockback(&kind, 0.0);
self.mobs[index].knockback(f64::from(strength), xd, zd);
// ...and the attacker stops sprinting.
self.stop_sprint_after_extra_knockback();
}
let dismounted = dismounts && self.mobs[index].extra.mount.is_some();
if dismounted {
+1
View File
@@ -7,6 +7,7 @@ use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shu
use horizon_client::{
block_entity_item, block_entity_nbt,
containers::{Banner, BlockEntity},
furnace::FurnaceCooking,
item_data,
items::ItemStack,
save,
+19 -9
View File
@@ -1643,7 +1643,7 @@ impl Game<'_> {
continue;
};
let stack = self.slot_mut(i).clone();
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
@@ -2059,9 +2059,16 @@ impl Game<'_> {
);
}
}
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
for (key, fallback) in [
// ItemStack.OP_NBT_WARNING: `BlockItem` and `SpawnEggItem`
// `.shouldPrintOpWarning` for a player with game master permission;
// a nested projectile has no player. The first line is bold.
if !nested
&& self.ui.registries.permission >= 2
&& (item_info.is_some_and(|i| i.block.is_some())
&& block_entity_item::op_warning(stack)
|| block_entity_item::spawn_egg_op_warning(stack))
{
for (i, (key, fallback)) in [
("item.op_warning.line1", "Warning:"),
(
"item.op_warning.line2",
@@ -2071,12 +2078,15 @@ impl Game<'_> {
"item.op_warning.line3",
"Do not use unless you know the exact contents!",
),
] {
push(
&mut lines,
self.translate(key, fallback),
]
.into_iter()
.enumerate()
{
let text = horizon_client::text::Text::legacy(&self.translate(key, fallback));
lines.push((
if i == 0 { text.bold() } else { text },
rgba(255, 85, 85, 255),
);
));
}
}
lines
+4 -2
View File
@@ -137,7 +137,9 @@ impl Game<'_> {
horizon_world::nbt::Tag::Int(horizon_world::DATA_VERSION),
),
]);
let _ = horizon_client::save::write_dat(&self.random_sequences_path(), tag);
if let Err(e) = horizon_client::save::write_dat(&self.random_sequences_path(), tag) {
log::warn!("the villager random sequences were not written: {e}");
}
}
/// `AbstractVillager.addOffersFromTradeSet` with the world's random
/// sequence of the set, at the merchant's position.
@@ -783,7 +785,7 @@ impl Game<'_> {
for i in slots {
let (sx, sy) = self.slot_position(i);
let stack = self.slot_mut(i).clone();
self.draw_slot_item(batch, drag.as_ref(), i, stack, ox + sx, oy + sy);
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
if let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1) {
let (sx, sy) = self.slot_position(slot);
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more