Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5bb7be3765 Merge main into feat/lua-mods (Cargo.toml members one per line; stability.md server-mods row from main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:24 +01:00
Ludovic Marie 4af2c671c7 Merge pull request #2634 from ASOwnerYT/claude/1515-end-portal-entities-643979
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): items and mobs go through End portals. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @ASOwnerYT!
2026-10-27 08:59:20 +01:00
Ludovic Marie e92c9ea743 Merge pull request #2610 from DanielGodriNeto/turbo/pr-thread-cpu
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): Windows thread CPU time: checked and clippy-clean for x86_64-pc-windows-msvc from the Mac mini; not run on Windows. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @DanielGodriNeto!
2026-10-27 08:59:16 +01:00
Ludovic Marie 9635fbb0a9 Merge pull request #2605 from socksprox/fix/client-effects-cleared-on-join
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): a client's effects are forgotten on join; the client keeps ticking effects until the server removes them, as vanilla (lan_e2e effects passes). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:59:12 +01:00
Ludovic Marie b409d5f624 Merge pull request #2585 from RoyalPixelsDE/fix/fox-rabbit-goat-fields
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): foxes, rabbits, goats and horses keep their state (includes #2541). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:59:09 +01:00
Ludovic Marie 9502716a07 Merge pull request #2704 from ludovic111/test/mod-content-shared-assets
🤖 Agent: verify passed at 80751276f (7 min 38 s, in the range of the last merges: 5-7.5 min). Parity scope selected no harness. Test-only change: the 5 full-content-root mod_content tests are #[ignore]d, and pr_ready runs them when the diff touches what they load. Ready to merge.
2026-10-27 08:56:10 +01:00
Ludovic Marie 723ab2aa50 Merge pull request #2613 from socksprox/t3/anticheat-parity-elytra-swap-noslow
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): movement, offhand swap, eat gating and farmland push (includes #2564; lan_e2e eating and helduse pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:56:06 +01:00
Ludovic Marie 4d5ee970d7 Merge pull request #2536 from Murilinho145SG/feat/launcher-wayland-controls
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): Wayland window controls: launcher clippy and tests pass on macOS and the Linux build type-checks; playhorizon lacks the xcb/xkbcommon libraries to link and run it. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Murilinho145SG!
2026-10-27 08:55:12 +01:00
Ludovic Marie b4db58ab8e Merge pull request #2413 from mahirsn/fix/set-held-slot-echo
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): held-slot answer as vanilla. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @mahirsn!
2026-10-27 08:55:09 +01:00
Ludovic Marie 3833886df3 Merge pull request #2320 from pqhaz3925/fix/server-book-pages
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): server books: page indicator and component-list pages. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @pqhaz3925!
2026-10-27 08:55:04 +01:00
Ludovic Marie 104ef68ee6 Merge pull request #2315 from rdbtCVS/t3/reject-occupied-horse-mount
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): an occupied horse gives no inventory, feeding or ride (lan_e2e occupied_horse, riding, mounts pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:54:59 +01:00
Ludovic Marie 31ebe6db6c Merge pull request #2478 from kacpherek/feat/far-view-mod-api
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: mods can now turn on the game's own distant terrain, and Far View is an official mod. I merged main into the branch. render.set-far-terrain is now API level 7, because main added levels 4 to 6 (world, recipes, browser) in the meantime. The horizon-mods sandbox tests (32), the far-view wasm build, and clippy with the mods feature all pass. Without the mods feature, the LOD path is unchanged.
2026-10-27 08:54:55 +01:00
Ludovic Marie 9e12caaabc Merge pull request #2312 from rdbtCVS/t3/preserve-release-issue-wrapping
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): release notes keep wrapped known issues. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:48 +01:00
Ludovic Marie 23aa18322c Merge pull request #2308 from rdbtCVS/t3/recipe-book-settings-persistence
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): recipe book settings persist on the server. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:44 +01:00
Ludovic Marie 617665a176 Merge pull request #2304 from senjinthedragon/hdr-sun-mask
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): HDR marks only the sun and moon discs (off by default; no HDR display here). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @senjinthedragon!
2026-10-27 08:51:40 +01:00
Ludovic Marie 2036f0f517 Merge pull request #2292 from senjinthedragon/hdr-pq
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): HDR output builds and validates; HDR stays off by default (no HDR display here to see it). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @senjinthedragon!
2026-10-27 08:51:36 +01:00
Ludovic Marie f770f15616 Merge pull request #2272 from rdbtCVS/rdbt/fix-1705-closed-stdout-d4113cff
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): server logging survives a closed stdout. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:32 +01:00
Ludovic Marie 0b95d39d86 Merge pull request #2607 from DanielGodriNeto/turbo/pr-relight
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the relight cache: light parity harnesses and the randomized equivalence test pass; the speed-up is the author's measurement (relight -70% in a microbenchmark, no FPS change), not re-measured here. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @DanielGodriNeto!
2026-10-27 08:46:47 +01:00
Ludovic Marie 3de1a56f1f Merge pull request #2606 from socksprox/t3/fix-zombie-stuck-flowing-water
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): jumpOutOfFluid needs a dry box and no collidable entity (Entity.isFree). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:46:43 +01:00
Ludovic Marie 9dc425d357 Merge pull request #2603 from socksprox/fix/shulker-teleport-and-look
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): shulker placement, push and look rules, with no_collision counting only canBeCollidedWith entities. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:46:39 +01:00
Ludovic Marie b16ab9e8c4 Merge pull request #2565 from Louistuis/fix/turtle-feet
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): turtles walk at their own pace. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Louistuis!
2026-10-27 08:46:35 +01:00
Ludovic Marie b2edfb2c5b Merge pull request #2564 from Louistuis/fix/eat-held-after-full
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): USE_ITEM_COMPLETE finishes the use; eating on the client (lan_e2e eat_full, eating, helduse, consume-release pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Louistuis!
2026-10-27 08:46:31 +01:00
Ludovic Marie 644cca2a10 Merge pull request #2619 from RoyalPixelsDE/fix/light-edge-from-protos
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the last finished ring is lit against decorated neighbours (our follow-up #2699 carries a neighbour-check fix). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:43:03 +01:00
Ludovic Marie 07dd22212e Merge pull request #2543 from RoyalPixelsDE/fix/minecart-first-tick
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): minecarts save HasTicked and Fire. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:42:10 +01:00
Ludovic Marie 2d3b53a785 Merge pull request #2541 from RoyalPixelsDE/fix/animal-variants
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): farm animals and frogs keep their variant; sound variants saved. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:42:05 +01:00
Ludovic Marie 1e3c9c9ef7 Merge pull request #2317 from socksprox/fix/entity-view-scale
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): vanilla view scale for entity render distance. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:42:02 +01:00
Ludovic Marie d930105423 Merge pull request #2302 from socksprox/feat/server-links
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): pause menu server links. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:41:58 +01:00
Ludovic Marie a6b61e9a3c Merge pull request #2297 from ESHAYAT102/fix/cushion-break-particles
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): cushion break particles reach multiplayer players. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @ESHAYAT102!
2026-10-27 08:41:55 +01:00
Ludovic MarieandClaude Opus 5.5 80751276f3 test: run the full content-root tests only where they matter
The five mod_content tests that load the whole content root took minutes of
every `cargo test -p horizon-client --lib` on prepared data. They are now
#[ignore]d; pr_ready runs them when the diff touches what they load, and CI's
verify job runs them filtered with --ignored on its existing build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:37:40 +01:00
Ludovic MarieandClaude Opus 5.5 23743968e1 Merge main into feat/far-view-mod-api
render.set-far-terrain moves to API level 7: main added levels 4 to 6
(world, recipes, browser) since this branch started.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:24:26 +01:00
Ludovic Marie 6af60956ec Merge pull request #2637 from ludovic111/feat/launcher-language-home
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 08:01:36 +01:00
Ludovic Marie 8c1fa65f35 Merge pull request #2636 from ludovic111/feat/launcher-themes
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:51:55 +01:00
Ludovic Marie bc1549a438 Merge pull request #2635 from ludovic111/feat/launcher-language
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:42:16 +01:00
Ludovic Marie b6f18dc8f3 Merge pull request #2632 from ludovic111/feat/launcher-skins
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:32:21 +01:00
Ludovic Marie 504c9daf20 Merge pull request #2631 from ludovic111/feat/launcher-accounts
🤖 Agent: launcher-features-1: base main after #2627 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:21:08 +01:00
Ludovic Marie 5a6bf9705c Merge pull request #2696 from ludovic111/perf/parity-solo-window-harnesses
🤖 Agent: game-window harnesses run alone on 4-CPU machines (playhorizon runner); CI-like mix 8 of 8 on playhorizon.
2026-10-27 07:14:40 +01:00
Ludovic Marie 8f0797f258 Merge pull request #2627 from ludovic111/feat/launcher-linux-toggles
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:11:29 +01:00
Ludovic Marie 8ecf5d6e47 Merge remote-tracking branch 'origin/main' into perf/parity-solo-window-harnesses 2026-10-27 07:03:34 +01:00
Ludovic Marie c1ebf5d1b0 Merge pull request #2626 from ludovic111/feat/launcher-instance-commands
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:00:52 +01:00
Ludovic Marie 9ad735b9d7 Merge pull request #2625 from ludovic111/feat/launcher-shortcuts
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:51:21 +01:00
Ludovic Marie 6f19f06634 Merge pull request #2350 from kacpherek/fix/mining-hit-sound-level-events
🤖 Agent: verified against vanilla 26.3 (mining hit sounds now come from the level events, once per hit as MultiPlayerGameMode/LevelRenderer do). I merged main into the branch (one conflict in level_particles.rs, both functions kept). Tested on a tree of main + this PR + #2279: the 33 harnesses its files reach passed, and two real games over LAN (host in-frame and on its server thread) heard 5-7 stone hits per block, with digging, cracks, instant digging, death-while-mining, respawn and hurt checks passing. Thanks @kacpherek!
2026-10-27 06:48:05 +01:00
Ludovic MarieandClaude Opus 5.5 83ee5c303b parity: game-window harnesses run alone on 4-CPU machines
On playhorizon's runner (CPUs 4-7, 4 harness slots, frames drawn on the CPU)
remote_fireworks still failed in #2215's job after #2589 and #2629: a
screenshot took ~1.5 s (58 ticks for two) beside three other harnesses. The
harnesses in parity_hosts.json's new solo list run alone there, after the
others; bigger machines are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:46:49 +01:00
Ludovic Marie c79b5be2e4 Merge #2564
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:40:59 +01:00
Ludovic Marie 89225ec081 Merge pull request #2279 from kacpherek/fix/2276-dead-hurt-tilt
🤖 Agent: verified against vanilla 26.3: LivingEntity.baseTick counts hurtTime down for a dead or riding player too, so the hurt tilt no longer restarts every frame and shakes the camera on the server-thread path. Tested on a tree of main + #2350 + this PR: the 33 harnesses its files reach passed, and the LAN hurt, death-while-mining and respawn checks passed with the host in-frame and on its server thread. Thanks @kacpherek!
2026-10-27 06:39:59 +01:00
Ludovic Marie 7b11005688 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 40f8b9600d Shulker canStayAt: only collidable entities block the shell
Level.noCollision(shulker, box) keeps the entities whose
canBeCollidedWith is true for the shulker: living shulkers and adult
happy ghasts (boats and minecarts too, outside the mob world). Players
and other mobs never stop a shulker from opening or landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:02 +01:00
Ludovic Marie 6044c6ff88 Merge pull request #2623 from ludovic111/feat/launcher-hide-on-play
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:30:43 +01:00
Ludovic MarieandClaude Opus 5.5 ab099b2701 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:28:26 +01:00
Ludovic MarieandClaude Opus 5.5 6cb5e4954e An occupied horse gives no inventory or feeding either
AbstractHorse.mobInteract (and Horse/ZombieHorse/AbstractChestedHorse)
hand an isVehicle horse to Mob.mobInteract before the inventory, the
feeding and the ride, so the passenger check goes before all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:51 +01:00
Ludovic Marie d74d39cb05 Merge pull request #2622 from ludovic111/feat/launcher-console
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:21:26 +01:00
Ludovic MarieandClaude Opus 5.5 2ba33bd146 jumpOutOfFluid: only entities a mob collides with block the hop
EntityGetter.getEntityCollisions keeps the entities that canBeCollidedWith
the mob: among mobs that is a living shulker, not every mob nearby.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:20 +01:00
Ludovic MarieandClaude Opus 5.5 4f6e4e72b1 Keep a client's effects until the server removes them
26.3's client LivingEntity.tickEffects only runs tickClient on each effect;
the server's ClientboundRemoveMobEffectPacket removes one. The join reset
stays. The AGENTS.md wording is the maintainer's to change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:20:42 +01:00
Ludovic MarieandClaude Opus 5.5 73aef0ce8e Merge main into fix/mining-hit-sound-level-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:17:53 +01:00
Ludovic MarieandClaude Opus 5.5 39c8a2aa0e launcher: Home and the sidebar in French and Russian
The second part of the launcher's translation: Home's banner, rows, crash notice and
Join a friend, the instance row and its actions, the sidebar's headings and instance
states, the partner label, play times and "last played" dates, and the resources line.
Play's label clears its icon in wider languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:13:43 +01:00
asownerytandClaude Opus 5.5 21fd3a6b06 Turn End portal travellers as their teleport does
Checked against the decompiled 26.3 sources: EndPortalBlock's transition
out of the End is Relative.ROTATION plus the respawn data's yaw and pitch,
so a mob or item turns by the world spawn's angles instead of keeping its
own. Into the End it faces west and keeps its pitch, and calculateAbsolute
clamps the pitch to +-90 either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:13:36 +13:00
Ludovic MarieandClaude Opus 5.5 8beaa57331 launcher: an accent colour and a picture behind Home
Settings > Launcher picks an accent (violet by default, blue, teal, green, amber, red, pink)
and the player's own picture behind Home. An accent turns the violet tokens to its hue and
adjusts their lightness until text on the Play button and the accent on every background read
as well as the violet's. The picture is checked, copied under a name of its hash and shown
cropped under the banner's veils. CLI: settings accent NAME, settings background PATH|default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:07:38 +01:00
Ludovic MarieandClaude Opus 5.5 b3709b9766 launcher: French and Russian, following the system's language
A string table per language (launcher/lang/<code>.json, English text to its translation)
and tr()/trf() in i18n.rs. The window follows the system's language unless Settings >
Launcher > Language picks one; a missing string shows in English. This first part
translates the top bar, the account switch and the Settings page's account, update and
launcher rows; buttons, segments, section titles and page headers translate through their
widgets. CLI: settings language system|en|fr|ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:58:31 +01:00
Ludovic MarieandClaude Opus 5.5 b2199f5a0e launcher: a skin and a cape for each offline name
Settings > Account's Skin... shows the front of an offline name's skin and cape, takes a
skin PNG (64x64 or 64x32) and a cape PNG (64x32 or 22x17), and picks classic or slim arms.
The launcher keeps copies in skins/ and gives the game HORIZON_OFFLINE_SKIN,
HORIZON_OFFLINE_SKIN_MODEL and HORIZON_OFFLINE_CAPE when that name plays. CLI and MCP:
account skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:48:35 +01:00
asownerytandClaude Opus 5.5 dfb28e42d0 Send items and mobs through End portals
The non-player portal path only knew Nether portals, so a cow or a dropped
item standing in an end_portal block never travelled (#1515). It now also
finds End portal blocks (EndPortalBlock.entityInside, its 6/16..12/16
entity-inside shape met along the move), and an entity through one waits as
an arrival with a fixed destination (getPortalDestination): the End's spawn
point above a rebuilt obsidian platform, facing west, or out of the End the
top of the Overworld's shared spawn column. Motion and pitch are kept, the
portal cooldown applies, and saved arrivals keep their End plans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:46:07 +13:00
Ludovic MarieandClaude Opus 5.5 f1f34edb80 launcher: offline names and a quick switch of who plays
The top bar's account chip opens a menu of every Microsoft account, the
offline names the player keeps and the game's own name; picking one plays
as it. Settings > Account adds and forgets offline names, which reach the
game as --name. CLI and MCP: account offline [NAME], account select
(accounts or offline names), account forget-offline. Share log strips the
offline names too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:37:07 +01:00
Ludovic MarieandClaude Opus 5.5 9bc8e12ad1 launcher: GameMode, MangoHud and dedicated GPU switches on Linux
The Launch panel shows, per instance, a switch for each one this computer
has: GameMode when gamemoderun is on the PATH, MangoHud when mangohud is,
and the dedicated graphics card with two cards (DRI_PRIME, or NVIDIA's
render offload variables with its driver). They wrap the game like the
wrapper does, outermost first, and do nothing where the tool is gone.
CLI and MCP: instance launch --gamemode/--mangohud/--dedicated-gpu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:28:42 +01:00
Ludovic MarieandClaude Opus 5.5 7115c708e1 launcher: pre-launch command, wrapper and environment per instance
Home's Instance row gets Launch...: a shell command run in the instance's
folder before the game (which starts only if it succeeds), a wrapper the
game runs through, and environment variables, kept in instances.json.
The game stays one process: /bin/sh runs the command and execs the
wrapper and game through "$@" on Unix, cmd /C on Windows. The window's
Play and `play` both use them. CLI and MCP: instance launch (confirm
needed over MCP). The Instance row's actions move under its name, and
editing an instance that shares its name with an import works again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:22:34 +01:00
Ludovic MarieandClaude Opus 5.5 82c8398c6b launcher: desktop shortcut that plays an instance
Home's Instance row gets Shortcut / Remove shortcut, and the CLI and MCP
instance shortcut [instance] [--remove]. The shortcut runs
horizon-launcher play --instance <id>: a .desktop file on Linux (and in
the applications menu), a script app with Horizon's icon on macOS, and a
Shell Link written from Microsoft's published MS-SHLLINK layout on
Windows. HORIZON_LAUNCHER_DESKTOP points it at a scratch folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:02:20 +01:00
Ludovic MarieandClaude Opus 5.5 cde33a8e42 launcher: minimise or hide the window while the game runs
Settings > While playing: Stay (the default), Minimise, or Hide (macOS).
The window goes away when the game starts and comes back to the front
when it closes, with the run's play time, errors and log. CLI and MCP:
settings while-playing stay|minimize|hide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:51:54 +01:00
Ludovic MarieandClaude Opus 5.5 0ccf3e110b launcher: live game console with search, follow, copy and share log
Home's Console shows the game's output of this run as it comes, with a
search, Follow, Copy and Open logs folder. Share log uploads it to mclo.gs
only after the player confirms, with the home folder, user name and
account names taken out first, and can delete the paste again. CLI:
share-log and unshare-log (MCP tools, the second needs confirm).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:44:53 +01:00
FusselTVandClaude Opus 5.5 d601715126 Streaming: the last finished ring is lit against decorated neighbours
A chunk was lit only once all eight neighbours were finished, so the
outermost finished ring of a loaded area (a /forceload square, a /pregen
band's edge, a player's last ring) stayed unlit: saved as FULL with no
light at all, and with no generation animals, which wait for light.
Vanilla's LIGHT step needs its neighbours only at INITIALIZE_LIGHT
(ChunkPyramid), so its last FULL ring is lit against decorated chunks.

A light job now reads a neighbour that will not finish (past the area)
from its decorated proto chunk (ChunkGenerator::light_view). A chunk
saved unlit is saved again once lit, and one whose light changes when a
neighbour finishes later is saved again. `execute if loaded` needs the
chunk lit, as vanilla's needs it ENTITY_TICKING.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:08:53 +01:00
socksproxandDevin e70ead5bfe Multiplayer parity: four client/server movement and use divergences
- A remote client no longer tramples farmland or turtle eggs locally:
  BlockLandingWorld.is_server_level/is_client_side now reflect the
  player's client_side flag, as vanilla's fallOn gates trampling and
  entity events on `level instanceof ServerLevel`. The server's block
  change and teleport drive the change, so a player no longer ends up
  inside the grown block (phasing through when farmland turns to dirt).
- key.swapOffhand, key.drop and a mount's inventory key on a server now
  wait for the tick's input phase instead of sending a packet straight
  from the window event, like the click queue: no action packet leaks
  between CLIENT_TICK_END and a later pong.
- remote_use_item treats a refused consume like vanilla's
  InteractionResult.FAIL: a full player who right-clicks food no longer
  starts a use the server will refuse (the eat/noslow desync), the off
  hand gets its turn, and no use cooldown starts.
- The host's "moved too quickly" check subtracts the guest's
  server-simulated velocity like vanilla's expectedDist term, so a
  fast legitimate arrival (e.g. landing with elytra) isn't flagged.

Co-Authored-By: Devin <[email protected]>
2026-10-27 02:51:55 +01:00
DanielGodriNetoandClaude 9ae37fcb50 feat(threads): measure thread CPU time on Windows from cycle counts
thread_cpu() returned None on Windows. Implement it from thread cycle counts,
inside horizon-threads so horizon-client keeps forbid(unsafe_code).

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:40:34 -03:00
socksproxandDevin b39c4b8dd4 mobs: jumpOutOfFluid wants a dry, entity-free box
Entity.isFree is noCollision(this, box) && !containsAnyLiquid(box); the
check only looked at block collisions, so a mob pressed against a wall by
water flow hopped out where vanilla refuses, and it ignored other
collidable entities entirely.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:36:53 +01:00
Ludovic MarieandClaude Opus 5.5 bad95e4f6a horizon-mod-lua: string.rep and table.concat keep bytes that are not UTF-8
Lua strings are byte strings. Both converted their arguments with
to_str_lossy, so a byte such as \255 (binary values a mod keeps in
horizon.storage, for one) came back as U+FFFD's three bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:52 +01:00
DanielGodriNetoandClaude 8866e618b5 perf(light): cache touched sections during relight
Relight now runs over a Region that clones each touched section's Arc once,
copies its light on first write and writes back only sections whose light
changed. The increase loop reads state and light in one lookup. Output is
identical; a randomized border test checks it against a full relight.

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:31:59 -03:00
socksproxandDevin 7732ce986f Drop a client's remembered effects when it joins a world
A rejoining player kept the previous session's effect list, so the HUD
could show effects the server no longer had (nothing applied in combat,
no icon was earned). Vanilla's new LocalPlayer starts empty and gets its
effects from UpdateMobEffect; match that, and let the client-side tick
drop effects whose duration ran out, as its removeIf does.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:31:11 +01:00
Ludovic Marie b03d1411d1 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-27 02:24:19 +01:00
Devin 42ef865c34 Give shulkers vanilla placement, push and look rules
A shulker used to check only that its cell was air and the support
block had a full collision box. canStayAt now mirrors vanilla: a moving
piston in the shell's own cell does not force a teleport, the support's
face toward the shell must be sturdy, and the fully opened shell's box
must be clear of blocks and entities. teleportSomewhere likewise checks
the target cell with noCollision and refuses faces tagged
minecraft:shulker_does_not_teleport_to, emits level event 2016 (whose
particles now render), and forces a position sync.

The shell easing toward its peek now pushes collidable entities out of
the swept box (onPeekAmountChange / MoverType.SHULKER) through a new
MobEvent::Shift, so standing on a shulker lifts you instead of wedging
you inside the lid.

ShulkerLookControl is ported too: the head yaw is computed in the
attach face's plane (new LookKind::Shulker and look_yaw) instead of the
generic world-yaw, and hurtByTarget now ignores damage from other
shulkers as toIgnoreDamage does.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:11:05 +01:00
socksproxandDevin (SWE-2 High) e8891849a4 AGENTS.md: name posters by GitHub handle, never a real name
The "names its own person" rule was ambiguous about which identity an
agent should attribute a post to; handles are the public identity.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:43:34 +01:00
socksproxandDevin ee0af20e16 Cap the view scale's render distance by the server's
streaming.radius reverts to the client setting in apply_settings, so
an options change on a server with a lower view distance raised every
entity's render range past vanilla's. min(render_distance,
remote.view_distance) is Options.getEffectiveRenderDistance.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:27:41 +01:00
FusselTVandClaude Opus 5.5 b1450f1ef2 Mobs: foxes, rabbits, goats and horses keep their state
finalizeSpawn picks a fox's type (red or snow), a rabbit's type, a goat's
screaming flag and horns and a horse's max health, but the save and the
load did not keep them: a snow fox came back red, every rabbit brown, a
goat that had lost a horn grew it back, and a new horse was saved with
the type's health (53) above the max health it rolled.

- Fox, Rabbit and Goat.addAdditionalSaveData: Type, RabbitType,
  IsScreamingGoat, HasLeftHorn and HasRightHorn are saved from the model
  and read back (an unknown rabbit type is brown, horns default to
  present, as vanilla reads them).
- The fields Horizon does not model (a fox's Trusted, Sleeping, Sitting,
  Crouching, a rabbit's MoreCarrotTicks, a horse's EatingHaystack and
  Bred) keep a loaded value or get vanilla's default.
- A loaded horse or llama gets its health clamped to its max health, as
  LivingEntity.setHealth does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:17 +01:00
Louis NordbøandClaude Opus 5.5 64cd55cf74 Finish a use on the server's USE_ITEM_COMPLETE, and eat on the client too
On a server client (single player's server thread, LAN guests, dedicated
servers) the window ended a consumable's use on its own timer and never ate
the food: its FoodData waited for the server's next ClientboundSetHealth.
With the button still held, the next startUseItem found the food bar below
20 and began another bite, so the eating animation went on after the bar
was full (#2471).

Vanilla's client never completes a use by itself: LivingEntity.updateUsingItem
completes it only on the server, and ServerPlayer.completeUsingItem first
sends the player entity event 9 (USE_ITEM_COMPLETE). Player.handleEntityEvent(9)
then runs completeUsingItem on the client, where Consumable.onConsume calls
the FoodProperties listener on both sides: the client's FoodData eats at once.

- The server sends event 9 to the acting guest when a consumable, a potion or
  a spyglass use completes. Like the guest's packets, the events are queued
  during their turn (kept across nested turns) and sent with their entity id.
- The client keeps using until that event, then shows the last crumbs, eats
  the food (the next ClientboundSetHealth replaces it) and starts the item's
  use_cooldown, which ItemStack.use only starts at once for an item with no
  use time.
- What vanilla's client checks so that a use can't outlive the server's:
  MultiPlayerGameMode.useItem passes on an item cooling down (chorus fruit
  held through its cooldown), LivingEntity.updatingUsingItem stops when the
  hand no longer holds the item the use began with (ItemStack.isSameItem),
  a respawn is a new player with no use, and Consumable.startConsuming
  starts none for a consumable with no use time.
- ItemCooldowns tick where Player.tick ticks them: after LivingEntity.tick's
  use on the server and after Minecraft.handleKeybinds on the client. Both
  sides ticked them first, so a guest's entry ended a tick later on the
  server than on their client, whose held retry was refused and never ended.

lan_e2e's new eat_full check eats melon slices up to a full bar with the
button still held: before, a new bite had started (use ticks 26, 2, 9, 15);
now nothing is in use. Held on two chorus fruits, both are eaten across the
cooldown. consume-release holds its completed use a little longer, as the
use now ends with the server's event.

Fixes #2471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:59:03 +01:00
Louis NordbøandClaude Opus 5.5 5b5ebfcce3 Walk turtles at their own pace so their feet stop flailing (#1776)
Turtles used the default MoveControl, which sets the speed straight to
speedModifier * movement speed (0.25 on a stroll). Vanilla gives them
TurtleMoveControl: updateSpeed halves the speed every tick on land (never
below 0.06), and in water lifts the turtle 0.005 and cuts the speed away
from home (0.08) or as a baby (0.06); tick then eases the speed an eighth
of the way to the wanted one, faces the wanted point with the body, lifts
towards it and never jumps. On land that settles at 0.08375, so a
strolling turtle covered about nine times the ground per tick that
vanilla's does (the input is speed squared). TurtleModel swings the legs
by 8 * walkSpeed at walkPos * 5, so the walk animation fed by that motion
spun the feet erratically.

This only fixes the move control. Swimming still differs from vanilla:
Turtle.travelInWater (moveRelative(0.1F), 0.9 drag, sinking 0.005 without
a target unless heading home within 20 blocks of it), TurtlePathNavigation
and the turtle goal set are not ported yet; turtles keep the generic
fluid travel and goals for now.

Vanilla: Turtle$TurtleMoveControl.tick / updateSpeed, Turtle.<init>,
TurtleModel.setupAnim, LivingEntity.updateWalkAnimation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:50:24 +01:00
Ludovic Marie a24a6fa26c Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 23:40:35 +01:00
Murillo DeolinoandCodex faeff96cf9 Add Wayland launcher window controls
Co-Authored-By: Codex <[email protected]>
2026-10-26 18:59:32 -03:00
FusselTVandClaude Opus 5.5 a55c825467 Minecarts save HasTicked and Fire 0
AbstractMinecart saves Entity.firstTick as HasTicked, and a cart's fire
stays 0: Entity.move sets it to -getFireImmuneTicks(), which is 0 for
everything but players. A mineshaft's chest minecart was saved without
HasTicked and with Fire -1; vanilla's generated chunk saves it with
HasTicked 1 and Fire 0, and a cart that has ticked with HasTicked 0 and
Fire 0.

The mineshaft writes both as the proto chunk saves a new cart, and a
cart keeps first_tick from its save until its first tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
FusselTVandClaude Opus 5.5 6c716f457b Mobs: farm animals and frogs keep their variant; sound variants are saved
finalizeSpawn picks the variant of a pig, cow, chicken or frog and the
sound variant of a pig, cow, chicken, cat or wolf, but the save wrote
neither for these: pigs, cows, chickens and frogs came back temperate
after a reload, and every cat and wolf was saved with the classic sounds
whatever it rolled. Chickens also lost their egg timer.

Every mob with a variant component now saves and loads `variant`
through finalize::species_variant_component / variant_index (the cat and
wolf code used its own copy). The sound variant is kept as an index into
the species' registry (None is the default, classic) and is saved and
read back; Chicken also saves `EggLayTime`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
Ludovic Marie 6d9bb375c5 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 21:43:18 +01:00
Kacpherek ae3737b1c2 Mod API: render.set-far-terrain, and the official Far View mod
A mod can now turn on the game's distant terrain (LOD) with
render.set-far-terrain: a distance of 64 to 1024 chunks and Far Detail's
Reduced. The game builds and draws the tiles as it does for the LOD
Distance setting, which wins while it is on. One far terrain shows at a
time, the first loaded mod's. API level 4.

mods/official/far-view is the official mod on it: Distance (256 chunks by
default) and Far detail on its settings page, kept in its storage.
2026-10-26 20:34:08 +01:00
mahirsn 766bfbcdfa fix(client): answer the server's held slot like vanilla
ClientboundSetHeldSlotPacket only changes the selected hotbar slot (and ignores slots outside the hotbar), so the next tick sends ServerboundSetCarriedItemPacket as vanilla does. Each login starts again from slot 0, as handleLogin's new MultiPlayerGameMode and LocalPlayer do.
2026-10-26 21:48:55 +03:00
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Ludovic Marie 10c9d8440c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 19:13:17 +01:00
Kacpherek 7f5e3384b3 Play mining hit sounds once, from the server's destroy level events
The server now sends level events 2019 and 2020 every tick a player digs
(ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler:
a crack on the face, plus the hit sound for 2020. A remote client plays only
the first hit itself and no longer adds its own sound every fourth tick, so
the hit was heard twice, out of phase, for every block.
2026-10-26 19:07:11 +01:00
Pavel MikhailovinandClaude Opus 5.5 76d748caf8 Server books: positional page indicator and component-list pages
The page indicator only replaced bare %s, but 26.3's en_us template is
"Page %1$s of %2$s"; it now goes through format_java like other
translations (BookViewScreen.getPageNumberMessage).

Pages sent as a component list (Hypixel through ViaVersion) were dropped
by item_data::read_text, which only took a string or a compound, so the
book opened blank. ComponentSerialization.CODEC also takes a list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:43:35 +03:00
socksproxandDevin 2c8b933be4 Apply vanilla's view scale to entity render distance
Entity.shouldRenderAtSqrDistance scales the bounding box's mean edge
by 64 * viewScale, and LevelExtractor sets viewScale to the entity
distance option times clamp(renderDistance / 8, 1, 2.5). Horizon used
the option alone, so above render distance 8 entities vanished sooner
than vanilla: a chicken at 32 blocks instead of 48 at the default 12.
RemotePlayer's box also counts ten times.

Fixes #2273

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:53 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol f661443fa9 Read LAN passenger fixture output through the host
Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:22:50 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 442d52a038 Release notes: preserve wrapped known issues
Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-26 17:19:04 +00:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 004ad993d9 Reject boarding a horse with an existing mob passenger
Fixes #1759

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:16:15 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 42a57b8286 Merge main and keep both recipe book regressions
Preserve the settings persistence regression alongside main's independent recipe-target check.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:15:59 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol de141bb7cb Fix recipe book settings persistence on the server
Send open/filter changes to the authoritative player's recipe book and cover packet settings plus save/reopen in both single-player modes.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:11:51 +00:00
SenjinandClaude Opus 5.5 8526305faa HDR: mark only the sun's and moon's discs, not their whole quads
The celestial pass wrote alpha 0 over each quad, so HDR output lifted a
square of bright daytime sky around the sun. It now draws premultiplied
(one, one), which looks the same, and its alpha is how much a texel adds;
the blend takes that off the frame's alpha, so only the discs shine. The
dim End sky, drawn by the same pass, stays unmarked. fs_celestial keeps
its own blend for lightning and additive entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:04:55 +01:00
socksproxandDevin b37528f5d6 Pause menu: server links
ClientboundServerLinksPacket (configuration and play) decoded and kept,
the pause menu gains its "Server Links..." button when a server sends
links, opening the server_links dialog screen: scrollable link buttons,
ConfirmLinkScreen on click (chatLinks/chatLinksPrompt honoured), and the
custom-screens warning with its disconnect. A dedicated server sends its
`bug-report-link` property as the BUG_REPORT link, like vanilla.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 17:51:54 +01:00
Eshayat Al-WasiuandCodex 2a98f584df Send cushion breaking particles to nearby multiplayer clients
Co-Authored-By: Codex <[email protected]>
2026-10-26 22:40:40 +06:00
Ludovic MarieandClaude Opus 5.5 7f6d556e68 docs: Lua mods' page goes to horizon-site with the rest of website/
docs/mods.md keeps the reference, plus what a capability missing from the features does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:09:10 +01:00
Ludovic MarieandClaude Opus 5.5 d33f547bf7 Merge origin/main into feat/mods-lua
website/ moved to its own repo (#2286): this branch's changes to make-a-mod.md and nav.json go there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:55 +01:00
SenjinandClaude Opus 5.5 cfd2dc8392 HDR: present HDR10 (PQ) where the surface offers it, scRGB otherwise
HDR10 is what games usually present and what the display receives: a
compositor composes in it or converts to it anyway. The NVIDIA driver
offers it on Wayland next to scRGB. A 10-bit swapchain also halves the
last pass's bandwidth; the frame before it is 8-bit, so nothing is lost.

hdr.wgsl converts the same linear light to BT.2020 (BT.2087) and encodes
it with the ST 2084 curve. HORIZON_HDR_OUTPUT=pq or scrgb allows only
that one, for comparing them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:45:57 +01:00
Kacpherek 6f4c2cfbbc Count the hurt tilt down while the player is dead or riding 2026-10-26 16:16:19 +01:00
Ludovic Marie 2e7283d01c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 16:10:40 +01:00
rdbtCVSandOpenAI GPT-6.1 Sol 9e71837df9 Keep server logging from panicking on a closed stdout pipe
Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 14:56:15 +00:00
Ludovic Marie 9a7f955a77 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
#	docs/api/stability.md
2026-10-26 11:54:53 +01:00
Ludovic Marie 6b7617c277 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 09:44:13 +01:00
Ludovic MarieandClaude Opus 5.5 7c5efff307 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:31:10 +01:00
Ludovic MarieandClaude Opus 5.5 1f6e498238 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:48:21 +01:00
Ludovic MarieandClaude Opus 5.5 32f6079134 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 ce1db1b0c3 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:16 +01:00
Ludovic Marie ad3ddeb43f Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/horizon-mods/tests/sandbox.rs
2026-10-26 01:43:14 +01:00
Ludovic Marie 34f4d3b451 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 00:25:27 +01:00
Ludovic MarieandClaude Opus 5.5 27de895fdb Mods in Lua: horizon-mod-lua, a Lua 5.4 interpreter built into the mod
A mod's code can be a Lua script. The crate horizon-mod-lua embeds
piccolo (pure Rust Lua, MIT) in the wasm guest with the script, so a Lua
mod is an .hmod like any other: same sandbox, capabilities, fuel and
checks. `horizon_mod_lua::lua_mod!("main.lua")` is the whole Rust side.

- The script runs once at load and registers commands with
  horizon.command; hooks are globals (on_tick, on_frame, on_command,
  on_chat). Capabilities are tables of `horizon` (game, chat, hud,
  storage), each behind a cargo feature so unused imports aren't linked.
- piccolo's stdlib lacks them: string.format (C flags, width,
  precision), string.rep, table.insert/remove/concat.
- The sandbox has no entropy: getrandom 0.2 and 0.3 get a custom
  SplitMix64 backend (cfg getrandom_backend="custom", set by
  cargo horizon-mod build).
- Example coords-hud-lua: coords-hud line for line. The sandbox tests
  run the same checks on both, and measure a tick of each.
- cargo horizon-mod new --template lua.
- Docs: docs/mods.md, website "Write it in Lua", changelog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:32:50 +01:00
152 changed files with 10411 additions and 694 deletions

No files matched your search

+3
View File
@@ -72,6 +72,9 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
+2 -1
View File
@@ -11,7 +11,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
@@ -66,6 +66,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
Generated
+122 -2
View File
@@ -477,6 +477,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "coords-hud-lua"
version = "0.1.0"
dependencies = [
"horizon-mod-lua",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -930,6 +937,13 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "far-view"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1093,6 +1107,30 @@ dependencies = [
"slab",
]
[[package]]
name = "gc-arena"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
dependencies = [
"allocator-api2",
"gc-arena-derive",
"hashbrown 0.14.5",
"sptr",
]
[[package]]
name = "gc-arena-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1257,6 +1295,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1461,6 +1509,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
@@ -2737,6 +2795,21 @@ dependencies = [
"indexmap",
]
[[package]]
name = "piccolo"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
dependencies = [
"ahash",
"allocator-api2",
"anyhow",
"gc-arena",
"hashbrown 0.14.5",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2864,6 +2937,15 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
@@ -2964,6 +3046,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3443,6 +3546,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sptr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3560,6 +3669,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -5221,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5262,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+3
View File
@@ -30,15 +30,18 @@ members = [
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/coords-hud-lua",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
+54 -8
View File
@@ -1,7 +1,7 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -26,6 +26,8 @@ use std::{
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
/// The Lua runtime from the same repository, with the template's capability.
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -42,7 +44,7 @@ fn main() -> ExitCode {
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
let (sdk, lua) = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
(
format!("{{ path = {:?} }}", p.display().to_string()),
format!(
"{{ path = {:?}, features = [\"chat\"] }}",
lua.display().to_string()
),
)
}
None => SDK_GIT.to_string(),
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
.replace("{lua}", &lua)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
// The code template's mod, written in Lua.
let lua_files = [
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
("main.lua", include_str!("../template/lua/main.lua")),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
"lua" => &lua_files,
other => return Err(format!("no template {other}: code, content or lua")),
};
for &(file, text) in files {
let file = fill(file);
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
// takes a custom source only with this cfg.
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
cmd.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
);
cmd.current_dir(root).args([
"build",
"--lib",
@@ -386,6 +412,26 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_lua_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
let path = dir.join("hello-lua");
new(&[
path.display().to_string(),
"--template".into(),
"lua".into(),
])
.unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
assert!(script.contains("horizon.command(\"hello\")"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -424,7 +470,7 @@ mod tests {
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
"python".into()
])
.is_err()
);
@@ -0,0 +1,17 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# The Lua interpreter, built into the mod with main.lua. Its features are the
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
# the two lists the same.
[dependencies]
horizon-mod-lua = {lua}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the Lua runtime targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,12 @@
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
-- Commands come from horizon.command; hooks are global functions:
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
@@ -0,0 +1,2 @@
// The mod is main.lua: this builds it into the mod with its interpreter.
horizon_mod_lua::lua_mod!("main.lua");
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
use Species as S;
let s = m.species();
let named = |reg: &finalize::VariantRegistry, i: u8| {
format!(
"minecraft:{}",
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
let name = i.map_or("classic", |i| {
reg.names.get(i as usize).map_or("?", |n| n.as_str())
)
});
format!("minecraft:{name}")
};
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
match s {
@@ -5,6 +5,10 @@
//!
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::recipes::RecipeBookChangeSettings,
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
_ => BookType::Smoker,
}
.index();
book.open[i] = op["open"].as_bool().unwrap_or(false);
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
let packet = RecipeBookChangeSettings {
book_type: i as i32,
open: op["open"].as_bool().unwrap_or(false),
filtering: op["filtering"].as_bool().unwrap_or(false),
};
let mut wire = Writer::new();
RecipeBookChangeSettings::write(&packet, &mut wire);
extra = format!(
" packet={}",
wire.data
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
book.open[decoded.book_type as usize] = decoded.open;
book.filtering[decoded.book_type as usize] = decoded.filtering;
}
"save" => {
let tag = book.write();
+11 -8
View File
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
}
}
impl crate::light::LightWorld for ChunkMap {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
self.columns.state(x, y, z)
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
self.columns.section(cx, s, cz)
}
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
self.columns.light(x, y, z)
}
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
self.columns.set_light(x, y, z, packed);
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
self.columns.set_section_light(cx, s, cz, light);
}
}
@@ -106,7 +103,13 @@ mod tests {
for column in map.values_mut() {
Arc::make_mut(column).light_loaded = true;
}
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
crate::light::LightWorld::set_section_light(
&mut map,
0,
0,
0,
crate::chunk::Light::Uniform(0),
);
assert_eq!(inserted, map.membership_revision());
map.remove(&(0, 0));
map.insert((1, 0), chunk);
+15 -2
View File
@@ -219,9 +219,22 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
+204 -24
View File
@@ -244,7 +244,51 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
let species = m.species();
if let Some((_, variant)) =
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
{
map.insert("variant".into(), string(&variant));
}
if species == crate::mobs::Species::Chicken {
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
}
let sounds = crate::mobs::finalize::sound_variants(species);
if !sounds.is_empty() {
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
let sound = sound.copied().unwrap_or("classic");
map.insert(
"sound_variant".into(),
string(&format!("minecraft:{sound}")),
);
}
match id {
// Fox.addAdditionalSaveData; Horizon models only the type.
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
for (key, value) in [
("Trusted", Tag::List(Vec::new())),
("Sleeping", Tag::Byte(0)),
("Sitting", Tag::Byte(0)),
("Crouching", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
}
}
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
map.entry(key.into()).or_insert(value);
@@ -260,10 +304,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:wolf" => {
if let Some(owner) = m.extra.owner {
@@ -273,10 +313,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
@@ -311,6 +347,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
for key in ["EatingHaystack", "Bred"] {
map.entry(key.into()).or_insert(Tag::Byte(0));
}
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
}
@@ -800,6 +840,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
// with the type's and saved before it drops to the one it rolled. (Only
// the base is read here, so the clamp would cut a leader zombie's bonus.)
if crate::mobs::equine::is_equine(m.species()) {
m.health = m.health.min(m.attrs.max_health);
}
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -935,20 +981,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
}
if m.species() == crate::mobs::Species::Chicken
&& let Some(t) = number(tag.get("EggLayTime"))
{
m.extra.egg_time = t as i32;
}
if let Some(v) = text(tag.get("sound_variant")) {
let v = v.trim_start_matches("minecraft:");
let sounds = crate::mobs::finalize::sound_variants(m.species());
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
}
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
v @ (0..=5 | 99) => v as u8,
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
_ => {}
}
if m.species() == crate::mobs::Species::Cat {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
.iter()
.position(|n| *n == v)
.unwrap_or(0) as u8;
}
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
@@ -959,14 +1030,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
.iter()
.position(|(n, _)| *n == v)
.unwrap_or(0) as u8;
}
}
if matches!(
m.species(),
@@ -2567,6 +2630,123 @@ mod sheep_nbt_tests {
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
#[test]
fn farm_animals_keep_their_variant_and_sound_variant() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
types["minecraft:pig"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
);
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:classic")
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:mini")
);
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
let mut hen = Mob::new(
3,
types["minecraft:chicken"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
// The sound variants are indices into the registries finalizeSpawn draws from.
use crate::mobs::{Species, finalize};
let registries = finalize::SpawnRegistries::load(&root);
for (species, registry) in [
(Species::Pig, &registries.pig_sound),
(Species::Cow, &registries.cow_sound),
(Species::Chicken, &registries.chicken_sound),
(Species::Cat, &registries.cat_sound),
(Species::Wolf, &registries.wolf_sound),
] {
assert_eq!(
finalize::sound_variants(species),
registry.names,
"{species:?}"
);
}
}
#[test]
fn foxes_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
};
let mut fox = new("minecraft:fox");
fox.extra.variant = 1;
let (back, tag) = round_trip(&fox);
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
assert_eq!(number(tag.get("Sleeping")), Some(0));
assert_eq!(back.extra.variant, 1);
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
assert_eq!(number(tag.get("RabbitType")), Some(4));
assert_eq!(back.extra.variant, 4);
let mut goat = new("minecraft:goat");
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
let (back, tag) = round_trip(&goat);
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
assert_eq!(
(back.extra.screaming, back.extra.lost_horns),
(true, [false, true])
);
// A goat saved without horn flags has both, as vanilla reads it.
let bare = Tag::compound([
("id", string("minecraft:goat")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
assert_eq!(
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
[false; 2]
);
}
}
#[cfg(test)]
+104 -26
View File
@@ -157,6 +157,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
@@ -508,6 +509,12 @@ enum Screen {
Friends,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
ServerLinks,
/// ConfirmLinkScreen for a server link (game/server_links.rs).
ConfirmLink,
/// DialogScreen.WarningScreen: the note about custom screens.
DialogWarning,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
@@ -1675,6 +1682,7 @@ struct Game<'a> {
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
/// Block events (position, b0, b1, state) and entity events (id, event,
/// position) for the guests.
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
@@ -1698,6 +1706,9 @@ struct Game<'a> {
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
/// sent with their entity id, which the guest's turn does not hold.
guest_entity_events: Vec<i8>,
/// `broadcastToAdmins` lines a guest's command queued, sent once it
/// returns: the line, whether operators get it and whether the console
/// logs it.
@@ -1732,6 +1743,8 @@ struct Game<'a> {
restore_player_state: bool,
stats_ui: stats_screen::StatsUi,
adv_ui: advancements_screen::AdvUi,
/// The Server Links screens' state (game/server_links.rs).
links_ui: server_links::LinksUi,
camera_type: perspective::CameraType,
fangs: fangs::FangState,
/// The local player as a model (third person, the inventory).
@@ -2990,6 +3003,16 @@ impl Game<'_> {
data,
} => self.sculk_charge_event(pos, data),
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
// LevelEventHandler 2019 and 2020: the crack on a face of the
// block being mined, with its hit sound for 2020.
LevelEvent::LevelEvent {
id: id @ (2019 | 2020),
pos,
data,
} => {
let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize];
self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020);
}
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
self.spawner_burst([pos.x, pos.y, pos.z])
}
@@ -3009,6 +3032,11 @@ impl Game<'_> {
pos,
data,
} => self.dragon_egg_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent {
id: 2016,
pos,
data,
} => self.shulker_teleport_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent {
id: 2017,
pos,
@@ -4202,6 +4230,7 @@ impl Game<'_> {
if &*stack.item == "minecraft:spyglass" {
// SpyglassItem.USE_DURATION, then finishUsingItem.
if ticks + 1 >= 1200 {
self.send_use_item_complete();
self.stop_spyglass();
self.using = None;
} else {
@@ -4223,6 +4252,7 @@ impl Game<'_> {
}
if ticks >= duration {
self.using = None;
self.send_use_item_complete();
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
self.finish_consuming(&info, consumable, &stack);
@@ -4241,6 +4271,17 @@ impl Game<'_> {
_ => {}
}
}
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
/// for everyone around, the digger included (`levelEvent(null, ...)`).
fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
let direction = horizon_core::geometry::Direction::ALL
.iter()
.position(|d| d.normal() == face)
.unwrap_or(1);
self.level_event_at(id, pos, direction as i32);
}
fn tick_mining(&mut self) -> Result<()> {
if self.destroy_delay > 0 {
self.destroy_delay -= 1;
@@ -4284,6 +4325,11 @@ impl Game<'_> {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness < 0.0 {
// The server still counts its destroy ticks on an unbreakable block.
let m = self.mining.as_mut().ok_or("mining")?;
m.ticks += 1;
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
return Ok(());
}
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
@@ -4314,26 +4360,15 @@ impl Game<'_> {
} else {
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
};
let hit_sound = facts.sounds.clone();
self.swing_arm();
let m = self.mining.as_mut().ok_or("mining")?;
m.progress += progress;
m.ticks += 1;
let crack = (m.pos, hit.face);
if m.ticks % 4 == 1 {
let c = [
f64::from(m.pos[0]) + 0.5,
f64::from(m.pos[1]) + 0.5,
f64::from(m.pos[2]) + 0.5,
];
self.audio.play(
&hit_sound.hit,
Some(c),
(hit_sound.volume + 1.0) / 8.0,
hit_sound.pitch * 0.5,
);
// An instant break at START sends no destroy events.
if m.progress < 1.0 || m.ticks > 1 {
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
}
self.crack_particle(crack.0, crack.1, state);
let m = self.mining.as_mut().ok_or("mining")?;
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
@@ -5098,6 +5133,9 @@ impl Game<'_> {
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::ServerLinks
| Screen::ConfirmLink
| Screen::DialogWarning
| Screen::CommandBlock
) || self.book.is_some()
|| self.win_screen_pauses())
@@ -5581,6 +5619,9 @@ impl Game<'_> {
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
@@ -11375,6 +11416,10 @@ impl Game<'_> {
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
Screen::Dead => {}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
// the Server Links screen they came from.
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
Screen::Advancements => self.close_advancements(),
Screen::WorldOptions => self.world_options_escape(),
Screen::CommandBlock => self.close_command_block(),
@@ -11443,8 +11488,10 @@ impl Game<'_> {
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
// (the server swaps the hands and sends both slots back; spectators can't).
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
if !self.spectator() {
self.remote_swap_hands();
if !self.spectator()
&& let Some(r) = self.remote.as_mut()
{
r.pending_keys.push(key);
}
}
KeyCode::KeyF if self.screen == Screen::Playing => {
@@ -11541,11 +11588,28 @@ impl Game<'_> {
}
Ok(())
}
/// LOD Distance as far terrain uses it (`Graphics::lod_distance_on`).
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
/// Distance when it is on, else a mod's far terrain
/// (`render.set-far-terrain`).
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
if g.lod_distance > 0 {
return (g.lod_distance, reduced);
}
match self.mod_far_terrain() {
Some(f) => (f.distance, reduced || f.reduced),
None => (0, reduced),
}
}
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
fn lod_distance(&self) -> u32 {
let (distance, _) = self.far_terrain_choice();
match &self.renderer {
Some(renderer) => self.settings.horizon.lod_distance_on(&renderer.info),
None => self.settings.horizon.lod_distance.min(1024),
Some(renderer) => {
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
}
None => distance.min(1024),
}
}
/// Only enabled LOD worlds extend the renderer's range, never simulation.
@@ -11566,7 +11630,7 @@ impl Game<'_> {
}
}
fn prepare_lod(&mut self) {
let enabled = self.settings.horizon.lod_distance > 0
let enabled = self.far_terrain_choice().0 > 0
&& self.renderer.is_some()
&& self.player.is_some()
&& self.title.is_none()
@@ -11663,6 +11727,7 @@ impl Game<'_> {
{
log::warn!("Horizon: failed to save unloaded chunks: {e}");
}
let (far_distance, far_reduced) = self.far_terrain_choice();
if let Some(renderer) = self.renderer.as_mut() {
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
@@ -11716,12 +11781,10 @@ impl Game<'_> {
}
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
let lod_distance =
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
lod.set_near_loading(self.streaming.busy());
lod.set_reduced_detail(
self.settings.horizon.far_detail
== horizon_client::graphics::FarDetail::Reduced,
);
lod.set_reduced_detail(far_reduced);
// The covered columns change only with the complete ones and
// the render area: rebuilt then, not every frame.
let at = (
@@ -12055,6 +12118,9 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::WorldOptions && d != 0.0 {
self.world_options_scroll(d);
}
if self.screen == Screen::ServerLinks && d != 0.0 {
self.server_links_scroll(d);
}
if self.screen == Screen::Advancements {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
@@ -12246,6 +12312,15 @@ impl ApplicationHandler for Game<'_> {
Screen::WorldOptions if pressed && button == MouseButton::Left => {
self.world_options_click()
}
Screen::ServerLinks if pressed && button == MouseButton::Left => {
self.server_links_click()?
}
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
self.confirm_link_click()
}
Screen::DialogWarning if pressed && button == MouseButton::Left => {
self.dialog_warning_click()?
}
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
@@ -13093,6 +13168,7 @@ impl<'a> Game<'a> {
forced_hit: None,
guest_use: None,
lan_events: Vec::new(),
lan_cushion_breaks: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
@@ -13101,6 +13177,7 @@ impl<'a> Game<'a> {
lan_acting: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
guest_entity_events: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
chat_capture: None,
@@ -13127,6 +13204,7 @@ impl<'a> Game<'a> {
restore_player_state: true,
stats_ui: Default::default(),
adv_ui: Default::default(),
links_ui: Default::default(),
camera_type: Default::default(),
fangs: Default::default(),
player_model: None,
+6
View File
@@ -419,6 +419,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -707,9 +710,12 @@ impl Game<'_> {
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
if !book.is_empty() {
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
v["recipe_book"] = json!({
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
"known": self.recipe_book_known(),
"open": open,
"filtering": filtering,
});
}
v["slot_ids"] = json!(
+1 -1
View File
@@ -862,7 +862,7 @@ impl Game<'_> {
let a = alpha as f32;
let range = super::entity_culling::default_range(
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
self.settings.entity_distance,
self.view_scale(),
);
for b in &self.boats {
let entity_start = out.len();
+6 -3
View File
@@ -1009,9 +1009,12 @@ impl Game<'_> {
}
};
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
let text = tr("book.pageIndicator", "Page %s of %s")
.replacen("%s", &(page + 1).to_string(), 1)
.replacen("%s", &count.max(1).to_string(), 1);
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
// positional ("Page %1$s of %2$s").
let text = horizon_protocol::text::format_java(
&tr("book.pageIndicator", "Page %1$s of %2$s"),
&[(page + 1).to_string(), count.max(1).to_string()],
);
let tw = HudBatch::text_width(atlas, &text);
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
};
@@ -907,10 +907,12 @@ impl Game<'_> {
.coords(c, "pos")
.map(|co| co.block(self.source_position(), self.source_rotation()));
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
return Ok(flag(
dimension == self.dim
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
));
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
let loaded = |p: [i32; 3]| {
let key = (p[0] >> 4, p[2] >> 4);
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
};
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
}
if c.has("dimension") {
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
+10
View File
@@ -19,7 +19,17 @@ use std::convert::Infallible;
type ItemConsumable = Consumable<String, ConsumeEffect>;
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
impl Game<'_> {
/// `ServerPlayer.completeUsingItem`: the player's own client is told
/// first, and completes the use itself (eating the food there too).
pub(super) fn send_use_item_complete(&mut self) {
if self.lan_acting.is_some() {
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
}
}
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
pub(super) fn emit_consume(
&mut self,
@@ -878,6 +878,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
+25 -3
View File
@@ -980,6 +980,21 @@ impl Game<'_> {
}
}
}
MobEvent::Shift { target, delta } => {
let d = Vec3::new(delta[0], delta[1], delta[2]);
match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
p.position = p.position.add_vector(d);
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = m.position.add_vector(d);
}
}
}
}
MobEvent::Wololo { mob } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
// DyeColor.RED.
@@ -3406,7 +3421,7 @@ impl Game<'_> {
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = f64::from(self.settings.entity_distance);
let scale = self.view_scale();
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
@@ -3415,9 +3430,16 @@ impl Game<'_> {
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the entity distance option.
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let range = (w + h + w) / 3.0 * 64.0 * scale;
let edge = (w + h + w) / 3.0
* if &*mob.kind.id == "minecraft:player" {
10.0
} else {
1.0
};
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
+12 -15
View File
@@ -348,11 +348,15 @@ impl Game<'_> {
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let p = c.position;
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
// Guest actions temporarily take self.lan; flush once the hub is back.
// Queue before resolving a sprite: a dedicated server has no renderer.
if self.audio.capture.is_some() {
self.lan_cushion_breaks.push((state, particle_at));
}
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
@@ -361,14 +365,8 @@ impl Game<'_> {
sprite,
tint,
);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
self.particles
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
@@ -455,8 +453,7 @@ impl Game<'_> {
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
+127 -1
View File
@@ -24,6 +24,7 @@ use horizon_client::{
use horizon_protocol::text::translatable;
use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
}
});
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
println!(
// The launcher can close its stdout pipe while the server keeps running.
let _ = writeln!(
std::io::stdout().lock(),
"[{}] [{thread}/{level}]: {message}",
chrono::Local::now().format("%H:%M:%S")
);
@@ -618,6 +621,22 @@ impl Game<'_> {
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
vec![horizon_protocol::packets::configuration::ServerLink {
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
link: props.bug_report_link.clone(),
}]
} else {
log(
"WARN",
&format!("Failed to parse bug link {}", props.bug_report_link),
);
Vec::new()
},
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -1311,3 +1330,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
process::{Command, Stdio},
time::{SystemTime, UNIX_EPOCH},
};
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
fn assert_log_lines(text: &str) {
let expected = [
format!("] [Server thread/INFO]: {LOGIN}"),
format!("] [User Authenticator #1/WARN]: {WARNING}"),
];
assert_eq!(text.lines().count(), expected.len());
for (line, suffix) in text.lines().zip(expected) {
assert!(line.starts_with('['));
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
assert_eq!(&line[9..], suffix);
}
}
#[test]
fn console_logging_survives_closed_stdout() {
if let Some(home) = std::env::var_os(CHILD_HOME) {
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
// Let the parent close the real stdout pipe only after libtest's startup output.
io::stdout().write_all(READY.as_bytes()).unwrap();
io::stdout().flush().unwrap();
io::stdin().read_exact(&mut [0]).unwrap();
std::thread::Builder::new()
.name("Server thread".into())
.spawn(|| {
let captured = capture_log(|| {
log("INFO", LOGIN);
log_from("User Authenticator #1", "WARN", WARNING);
});
assert_eq!(captured, [LOGIN, WARNING]);
horizon_client::logging::flush();
})
.unwrap()
.join()
.unwrap();
// Libtest's final status also writes to stdout; only the game logger is under test.
std::process::exit(0);
}
for closed_stdout in [false, true] {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let home = std::env::temp_dir().join(format!(
"horizon-console-log-test-{}-{unique}",
std::process::id()
));
fs::create_dir(&home).unwrap();
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"game::dedicated::tests::console_logging_survives_closed_stdout",
"--nocapture",
])
.env(CHILD_HOME, &home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
loop {
let mut line = String::new();
assert!(
stdout.read_line(&mut line).unwrap() > 0,
"child was not ready"
);
if line.ends_with(READY) {
break;
}
}
if closed_stdout {
drop(stdout); // No reader remains: the next console write gets EPIPE.
} else {
child.stdout = Some(stdout.into_inner());
}
child.stdin.take().unwrap().write_all(&[1]).unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"closed_stdout={closed_stdout}: {}",
String::from_utf8_lossy(&output.stderr)
);
if !closed_stdout {
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
}
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
fs::remove_dir_all(home).unwrap();
}
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ use horizon_core::{
use std::{collections::HashMap, sync::Arc};
/// ServerLevel.END_SPAWN_POINT.
const END_SPAWN: [i32; 3] = [100, 50, 0];
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
const GATEWAY_RADIUS: f64 = 96.0;
const GATEWAY_Y: i32 = 75;
@@ -60,12 +60,27 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
/// at 20 or more. The render distance is the client setting, capped by
/// the server's view distance when connected
/// (Options.getEffectiveRenderDistance).
pub(super) fn view_scale(&self) -> f64 {
let distance = match &self.remote {
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.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the entity distance option.
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
let edge = (size.x + size.y + size.z) / 3.0;
let edge = if edge.is_nan() { 1.0 } else { edge };
edge * 64.0 * f64::from(view_scale)
edge * 64.0 * view_scale
}
impl Game<'_> {
+3 -2
View File
@@ -311,8 +311,9 @@ impl Game<'_> {
let sprites = &self.gui.atlas.sprites;
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
for f in &self.item_frames {
let entity_start = out.len();
let map = f.item.as_ref().and_then(super::maps::map_id);
+32 -2
View File
@@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3;
use horizon_protocol::{
crypto::offline_uuid,
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
use std::{
collections::HashMap,
@@ -715,6 +716,7 @@ impl Game<'_> {
self.audio.capture = None;
self.lan_events.clear();
self.lan_critical_hits.clear();
self.lan_cushion_breaks.clear();
self.streaming.extra.clear();
if announce {
self.system_message(Text::translate(
@@ -1267,8 +1269,9 @@ impl Game<'_> {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
// (these levelEvent calls name no excluded player), nor
// the digger's own crack and hit sound (2019, 2020).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
None
} else {
self.lan_acting
@@ -1330,6 +1333,33 @@ impl Game<'_> {
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
if let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:block")
{
host.hub.broadcast_near(
at,
32.0,
&C::LevelParticles(play::LevelParticles {
particle: horizon_protocol::entity_data::Particle {
id,
data: Some(horizon_protocol::entity_data::ParticleData::State(
state.0 as i32,
)),
},
override_limiter: false,
always_show: false,
pos: at,
dist: [0.25, 0.0625, 0.25],
max_speed: [0.05; 3],
count: 10,
randomization: 0,
}),
None,
);
}
}
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
+35 -14
View File
@@ -165,6 +165,8 @@ pub(super) struct GuestSwap {
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The entity events queued for that guest.
entity_events: Vec<i8>,
/// The host's count of swings started, kept while the guest acts.
swings: u32,
/// How many projectiles there were: those added since are the guest's.
@@ -389,6 +391,7 @@ impl Game<'_> {
self.lan_acting_uuid.replace(g.uuid),
);
let packets = std::mem::take(&mut self.guest_packets);
let entity_events = std::mem::take(&mut self.guest_entity_events);
let swings = self.swings_started;
let projectiles = self.creatures.projectiles.len();
let modifiers = std::mem::replace(
@@ -449,6 +452,7 @@ impl Game<'_> {
death_cause,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -508,6 +512,7 @@ impl Game<'_> {
combat,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -529,6 +534,17 @@ impl Game<'_> {
for packet in std::mem::replace(&mut self.guest_packets, packets) {
host.hub.send_to(conn, &packet);
}
// ServerPlayer.connection.send: the guest's own entity events.
let entity_id = host.hub.guests[i].entity_id;
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
host.hub.send_to(
conn,
&C::EntityEvent(play::EntityEvent {
id: entity_id,
event,
}),
);
}
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
host.hub.send_to(
@@ -1916,21 +1932,8 @@ impl Game<'_> {
continue;
}
let play = host.play.entry(conn).or_default();
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
play.creative_drops = play.creative_drops.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
let digging = play.digging;
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
@@ -1967,6 +1970,24 @@ impl Game<'_> {
play.digging = None;
}
}
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl), after
// LivingEntity.tick's use: an entry a finished use starts has 20
// ticks left when the next tick's packets are handled, as the
// guest's own client counts.
let play = host.play.entry(conn).or_default();
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
let g = &host.hub.guests[gi];
@@ -13,6 +13,12 @@ impl Game<'_> {
self.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
}
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
/// shell's old and new cells.
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
}
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
@@ -135,6 +141,30 @@ impl Game<'_> {
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for
/// `play_sound`) and one crumb on the face being mined; nothing for air.
pub(super) fn breaking_block_effects(
&mut self,
pos: [i32; 3],
face: [i32; 3],
play_sound: bool,
) {
let state = self.world.block(pos[0], pos[1], pos[2]);
if self.res.facts().is_air(state) {
return;
}
if play_sound {
let sounds = self.res.facts().get(state).sounds.clone();
// Each guest plays it from the level event itself.
self.audio.play_unshared(
&sounds.hit,
Some(pos.map(|v| f64::from(v) + 0.5)),
(sounds.volume + 1.0) / 8.0,
sounds.pitch * 0.5,
);
}
self.crack_particle(pos, face, state);
}
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
/// spell particles in the potion's colour `data`, flung out in a disc.
+37 -3
View File
@@ -90,6 +90,8 @@ pub(super) struct Minecart {
/// Chest minecarts: the loot table rolled into `items` on load, saved
/// again while the items are still that roll.
pub loot: Option<horizon_client::save::RolledLoot>,
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
pub first_tick: bool,
}
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
@@ -186,6 +188,7 @@ impl Minecart {
fuse: -1,
enabled: true,
loot: None,
first_tick: true,
}
}
pub fn bounds(&self) -> Aabb {
@@ -536,6 +539,7 @@ impl Game<'_> {
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
c.first_tick = false;
c.previous = c.position;
c.previous_yaw = c.yaw;
c.hurt_time = (c.hurt_time - 1).max(0);
@@ -1340,9 +1344,8 @@ impl Game<'_> {
return;
};
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
// edge, scaled by the entity distance option.
let range =
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
// edge, scaled by the view scale.
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
for c in &self.carts {
let entity_start = out.len();
let Some(model) = self.cart_models.models.get(&c.kind) else {
@@ -1509,6 +1512,10 @@ impl Minecart {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
@@ -1558,6 +1565,7 @@ impl Minecart {
_ => 0.0,
};
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
@@ -1582,4 +1590,30 @@ mod tests {
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
assert_eq!(display_offset(CartKind::Rideable), 6.0);
}
#[test]
fn a_cart_saves_as_never_ticked_until_it_ticks() {
use horizon_world::nbt::Tag;
let generated = Tag::compound([
("id", Tag::String("minecraft:chest_minecart".into())),
(
"Pos",
Tag::List(
[0.5f64, 40.5, 0.5]
.map(|v| Tag::Double(v.to_bits()))
.to_vec(),
),
),
("HasTicked", Tag::Byte(1)),
("Fire", Tag::Short(0)),
]);
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
cart.first_tick = false;
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
}
}
@@ -31,6 +31,9 @@ impl Game<'_> {
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
None
}
}
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
@@ -51,6 +54,13 @@ pub(super) struct ModMinimap {
pub(super) round: bool,
}
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModFarTerrain {
pub(super) distance: u32,
pub(super) reduced: bool,
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The main hand's stack as `input` shows it; none when it is empty.
@@ -190,4 +200,12 @@ impl Game<'_> {
round: m.round,
})
}
/// The far terrain the first mod drawing one asks for.
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
let f = self.mods.mods.far_terrain()?;
Some(ModFarTerrain {
distance: f.distance,
reduced: f.reduced,
})
}
}
+13
View File
@@ -126,6 +126,19 @@ impl Game<'_> {
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
return false;
}
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
// seat, inventory or feeding to give. Camels seat two and check their
// passengers themselves.
let id = m.id;
if !matches!(species, Species::Camel | Species::CamelHusk)
&& self
.mobs
.iter()
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
{
return false;
}
let m = &self.mobs[index];
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
if !m.is_baby()
&& m.extra.tame
+295 -91
View File
@@ -2,7 +2,9 @@
//! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`,
//! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination`
//! (`PortalForcer` through `horizon_world::level::portal_travel`), and
//! `LocalPlayer.handlePortalTransitionEffect`.
//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
//! the player's End portals are game/end.rs's.
//!
//! Only one dimension is loaded at a time. The player's destination is found
//! once the other dimension has loaded around it. An item or mob that goes
@@ -12,7 +14,7 @@ use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::nether_portal::PortalState;
use horizon_core::{
geometry::{Axis, Vec3},
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
random::RandomSource,
};
@@ -40,6 +42,32 @@ pub(super) struct Plan {
offset: Vec3,
width: f32,
height: f32,
/// Through an End portal: `approximate` is the End's spawn point, or the
/// Overworld's shared spawn.
end: bool,
}
impl Plan {
/// `EndPortalBlock.getPortalDestination` for an entity other than a
/// player in `from`: the End's spawn point, or out of the End the
/// Overworld's shared spawn `world_spawn`.
fn end_portal(from: Dim, world_spawn: BlockPos, width: f32, height: f32) -> Self {
let (dim, approximate) = if from == Dim::End {
(Dim::Overworld, world_spawn)
} else {
let [x, y, z] = super::end::END_SPAWN;
(Dim::End, BlockPos::new(x, y, z))
};
Self {
dim,
approximate,
entry_x_axis: true,
offset: Vec3::ZERO,
width,
height,
end: true,
}
}
}
/// An entity on its way to a dimension that isn't loaded.
@@ -81,6 +109,89 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// Whether a `width` by `height` box moving from `from` to `to` meets the End
/// portal at `pos`: `Entity.collidedWithShapeMovingFrom` against its
/// entity-inside shape, y 6/16..12/16 of the block.
fn touches_end_portal(from: Vec3, to: Vec3, width: f32, height: f32, pos: BlockPos) -> bool {
let half = f64::from(width) / 2.0;
let start = Aabb::new(
Vec3::new(from.x - half, from.y, from.z - half),
Vec3::new(from.x + half, from.y + f64::from(height), from.z + half),
);
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let shape = Aabb::new(
Vec3::new(x, y + 0.375, z),
Vec3::new(x + 1.0, y + 0.75, z + 1.0),
);
start.collided_along_vector(to.subtract(from), &[shape])
}
/// `PositionMoveRotation.calculateAbsolute` for an End portal's transition
/// of an entity facing `[yaw, pitch]`: into the End it faces west and keeps
/// its pitch (`Relative.X_ROT`); out of it both turn by the world spawn's
/// angles (`Relative.ROTATION`). The pitch stays within ±90.
fn end_portal_rotation(into_end: bool, [yaw, pitch]: [f32; 2], spawn: [f32; 2]) -> [f32; 2] {
let (yaw, pitch) = if into_end {
// Direction.WEST.toYRot().
(90.0, pitch)
} else {
(yaw + spawn[0], pitch + spawn[1])
};
[yaw, pitch.clamp(-90.0, 90.0)]
}
/// A waiting entity as `horizon_portal_arrivals.dat` keeps it.
fn arrival_tag(a: &Arrival) -> Tag {
let p = &a.plan;
Tag::compound([
("dimension", Tag::String(p.dim.id().into())),
("x", Tag::Int(p.approximate.x)),
("y", Tag::Int(p.approximate.y)),
("z", Tag::Int(p.approximate.z)),
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
("right", Tag::Double(p.offset.x.to_bits())),
("up", Tag::Double(p.offset.y.to_bits())),
("forward", Tag::Double(p.offset.z.to_bits())),
("width", Tag::Float(p.width.to_bits())),
("height", Tag::Float(p.height.to_bits())),
("end", Tag::Int(i32::from(p.end))),
("entity", a.tag.clone()),
])
}
/// The waiting entity `arrival_tag` wrote.
fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
let int = |k: &str| match a.get(k) {
Some(Tag::Int(v)) => Some(*v),
_ => None,
};
let float = |k: &str| match a.get(k) {
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
_ => None,
};
let double = |k: &str| match a.get(k) {
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
_ => None,
};
let dim = Dim::from_id(&horizon_client::save::text(a.get("dimension"))?);
let (Some(x), Some(y), Some(z), Some(tag)) = (int("x"), int("y"), int("z"), a.get("entity"))
else {
return None;
};
Some(Arrival {
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
offset: Vec3::new(
double("right").unwrap_or(0.5),
double("up").unwrap_or(0.0),
double("forward").unwrap_or(0.0),
),
width: float("width").unwrap_or(0.25),
height: float("height").unwrap_or(0.25),
end: int("end").unwrap_or(0) != 0,
},
tag: tag.clone(),
})
}
impl Game<'_> {
/// The world border's extent.
@@ -146,8 +257,54 @@ impl Game<'_> {
offset,
width,
height,
end: false,
}
}
/// The plan of an item or mob going through the portal block at `entry`:
/// an End portal's fixed destination, else the Nether portal's.
fn entity_plan(
&self,
entry: BlockPos,
at: Vec3,
width: f32,
height: f32,
reset_forward: bool,
) -> Plan {
if !self.is_end_portal(self.world.block(entry.x, entry.y, entry.z)) {
return self.portal_plan(entry, at, width, height, reset_forward);
}
let spawn = self.level.world_spawn.unwrap_or_else(|| {
self.spawn.map_or([0, 64, 0], |s| {
[s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32]
})
});
Plan::end_portal(
self.dim,
BlockPos::new(spawn[0], spawn[1], spawn[2]),
width,
height,
)
}
fn is_end_portal(&self, state: horizon_world::registry::BlockStateId) -> bool {
&*self.res.facts().get(state).name == "minecraft:end_portal"
}
/// Where an item or mob through an End portal lands once `plan`'s level
/// has loaded around it: the bottom centre of the End's spawn point,
/// above the platform built below it (`EndPlatformFeature.createEndPlatform`);
/// out of the End, the top of the shared spawn's column
/// (`Entity.adjustSpawnLocation`).
fn end_portal_exit(&mut self, plan: &Plan) -> Vec3 {
let p = plan.approximate;
let centre = Vec3::new(f64::from(p.x) + 0.5, f64::from(p.y), f64::from(p.z) + 0.5);
if plan.dim == Dim::End {
if let Err(e) = self.end_arrival(centre) {
log::error!("End platform: {e}");
}
return centre;
}
let y = self.motion_top(p.x, p.z);
Vec3::new(centre.x, f64::from(y), centre.z)
}
/// The nether portal blocks of the chunks within `radius` of `center`
/// (the points of interest `PortalForcer` searches): loaded chunks as
/// they are, the others as saved; chunks never generated have none.
@@ -520,9 +677,10 @@ impl Game<'_> {
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
/// and minecarts that moved this tick: the portal blocks their box swept
/// through.
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
/// for the items, mobs and empty boats and minecarts that moved this
/// tick: the portal blocks their box swept through (an End portal's
/// transition time is 0 too).
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
return;
@@ -570,7 +728,7 @@ impl Game<'_> {
.set_inside(pos, ENTITY_DELAY);
}
}
/// The first nether portal block an entity box swept through
/// The first nether or End portal block an entity box swept through
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
fn swept_portal(
&self,
@@ -604,11 +762,9 @@ impl Game<'_> {
bounds,
1_000_000,
|p, _| {
if self
.res
.behaviours()
.portal_axis(self.world.block(p.x, p.y, p.z))
.is_some()
let state = self.world.block(p.x, p.y, p.z);
if self.res.behaviours().portal_axis(state).is_some()
|| (self.is_end_portal(state) && touches_end_portal(from, to, width, height, p))
{
found = Some(p);
return false;
@@ -618,8 +774,8 @@ impl Game<'_> {
);
found
}
/// Whether a section `reach` touches has a nether portal state in its
/// palette: without one, no block there is a portal. Palette answers are
/// Whether a section `reach` touches has a nether or End portal state in
/// its palette: without one, no block there is a portal. Palette answers are
/// kept per chunk across ticks (`portal_sections`); a box over many
/// sections is not checked.
fn may_hold_portal(
@@ -656,9 +812,8 @@ impl Game<'_> {
(z0..=z1).any(|cz| {
chunks.get(&(cx, cz)).is_some_and(|chunk| {
sections.get((cx, cz), chunk, |v| {
behaviours
.portal_axis(horizon_world::registry::BlockStateId(v))
.is_some()
let state = horizon_world::registry::BlockStateId(v);
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
}) & y_mask
!= 0
})
@@ -670,7 +825,7 @@ impl Game<'_> {
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let e = &self.dropped[i];
let plan = self.portal_plan(entry, e.position, 0.25, 0.25, false);
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -686,7 +841,7 @@ impl Game<'_> {
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
let plan = self.portal_plan(entry, m.position, m.width(), m.height(), true);
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
if !self.may_enter(plan.dim) {
return;
}
@@ -716,7 +871,7 @@ impl Game<'_> {
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.portal_plan(entry, b.position, width, height, false);
let plan = self.entity_plan(entry, b.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -741,7 +896,7 @@ impl Game<'_> {
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.portal_plan(entry, c.position, width, height, false);
let plan = self.entity_plan(entry, c.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -754,8 +909,8 @@ impl Game<'_> {
}
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
/// their exit portal (found or built), position, turned yaw and motion,
/// and portal cooldown; they join the world with the chunk entities.
/// their exit portal (found or built) or End destination, position,
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
pub(super) fn resolve_arrivals(&mut self) {
if self.remote.is_some() {
return;
@@ -772,21 +927,28 @@ impl Game<'_> {
}
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
self.portals.dirty = true;
let Some(exit) = self.nether_exit_portal(&plan) else {
continue;
};
let (at, rotation) = self.arrival(&plan, exit);
let motion = rotate_y(horizon_client::entity_nbt::motion(&tag), -rotation);
let motion = horizon_client::entity_nbt::motion(&tag);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
let (at, motion, [yaw, pitch]) = if plan.end {
// Relative.DELTA: the motion is kept.
let at = self.end_portal_exit(&plan);
let into_end = plan.dim == Dim::End;
let turn =
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
(at, motion, turn)
} else {
let Some(exit) = self.nether_exit_portal(&plan) else {
continue;
};
let (at, rotation) = self.arrival(&plan, exit);
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
};
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
c.insert(
"Rotation".into(),
Tag::List(vec![
Tag::Float((yaw + rotation).to_bits()),
Tag::Float(pitch.to_bits()),
]),
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
);
// setPortalCooldown before the teleport, copied by restoreFrom.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
@@ -823,75 +985,117 @@ impl Game<'_> {
let Some(Tag::List(list)) = root.get("arrivals") else {
return;
};
for a in list {
let int = |k: &str| match a.get(k) {
Some(Tag::Int(v)) => Some(*v),
_ => None,
};
let float = |k: &str| match a.get(k) {
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
_ => None,
};
let double = |k: &str| match a.get(k) {
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
_ => None,
};
let dim = match horizon_client::save::text(a.get("dimension")).as_deref() {
Some("minecraft:the_nether") => Dim::Nether,
Some(_) => Dim::Overworld,
None => continue,
};
let (Some(x), Some(y), Some(z), Some(tag)) =
(int("x"), int("y"), int("z"), a.get("entity"))
else {
continue;
};
self.portals.arrivals.push(Arrival {
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
offset: Vec3::new(
double("right").unwrap_or(0.5),
double("up").unwrap_or(0.0),
double("forward").unwrap_or(0.0),
),
width: float("width").unwrap_or(0.25),
height: float("height").unwrap_or(0.25),
},
tag: tag.clone(),
});
}
self.portals
.arrivals
.extend(list.iter().filter_map(arrival_from_tag));
}
/// Write the waiting entities when they changed.
pub(super) fn save_portal_arrivals(&mut self) {
if !self.portals.dirty || self.remote.is_some() {
return;
}
let list = self
.portals
.arrivals
.iter()
.map(|a| {
let p = &a.plan;
Tag::compound([
("dimension", Tag::String(p.dim.id().into())),
("x", Tag::Int(p.approximate.x)),
("y", Tag::Int(p.approximate.y)),
("z", Tag::Int(p.approximate.z)),
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
("right", Tag::Double(p.offset.x.to_bits())),
("up", Tag::Double(p.offset.y.to_bits())),
("forward", Tag::Double(p.offset.z.to_bits())),
("width", Tag::Float(p.width.to_bits())),
("height", Tag::Float(p.height.to_bits())),
("entity", a.tag.clone()),
])
})
.collect();
let list = self.portals.arrivals.iter().map(arrival_tag).collect();
let root = Tag::compound([("arrivals", Tag::List(list))]);
if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() {
self.portals.dirty = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
let spawn = BlockPos::new(-7, 70, 12);
for from in [Dim::Overworld, Dim::Nether] {
let plan = Plan::end_portal(from, spawn, 0.9, 1.4);
assert_eq!(plan.dim, Dim::End);
assert_eq!(plan.approximate, BlockPos::new(100, 50, 0));
assert!(plan.end);
}
let plan = Plan::end_portal(Dim::End, spawn, 0.25, 0.25);
assert_eq!(plan.dim, Dim::Overworld);
assert_eq!(plan.approximate, spawn);
}
#[test]
fn the_end_portal_shape_takes_falling_items_and_standing_mobs() {
let portal = BlockPos::new(0, 100, 3);
let at = |y| Vec3::new(0.5, y, 3.5);
// A dropped item falling through the slab, or in it.
assert!(touches_end_portal(
at(100.6),
at(100.56),
0.25,
0.25,
portal
));
// One tick's fall across the whole slab, in it at neither end.
assert!(touches_end_portal(at(101.0), at(99.9), 0.25, 0.25, portal));
// Resting below the slab (on the floor) or above it.
assert!(!touches_end_portal(
at(100.0),
at(100.0),
0.25,
0.25,
portal
));
assert!(!touches_end_portal(
at(100.8),
at(100.8),
0.25,
0.25,
portal
));
// A cow standing in the portal block.
assert!(touches_end_portal(at(100.0), at(100.0), 0.9, 1.4, portal));
// Beside it.
let beside = Vec3::new(2.0, 100.0, 3.5);
assert!(!touches_end_portal(beside, beside, 0.9, 1.4, portal));
}
#[test]
fn end_portals_turn_entities_like_their_teleport_transition() {
// Into the End: facing west, pitch kept.
assert_eq!(
end_portal_rotation(true, [-30.0, 20.0], [45.0, 10.0]),
[90.0, 20.0]
);
// Out of it: turned by the world spawn's yaw and pitch, pitch clamped.
assert_eq!(
end_portal_rotation(false, [-30.0, 20.0], [45.0, 10.0]),
[15.0, 30.0]
);
assert_eq!(
end_portal_rotation(false, [0.0, 80.0], [0.0, 30.0]),
[0.0, 90.0]
);
}
#[test]
fn waiting_arrivals_keep_their_end_destination_when_saved() {
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
let arrival = Arrival {
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
tag: tag.clone(),
};
let back = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
assert_eq!(back.plan.dim, dim);
assert_eq!(back.plan.approximate, arrival.plan.approximate);
assert!(back.plan.end);
assert_eq!(back.tag, tag);
}
// Arrivals saved before End portals were Nether portal plans.
let mut old = arrival_tag(&Arrival {
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
tag,
});
if let Tag::Compound(c) = &mut old {
c.remove(&"end".into());
}
assert!(!arrival_from_tag(&old).unwrap().plan.end);
}
}
+4
View File
@@ -34,6 +34,10 @@ pub fn open_folder(dir: &Path) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
/// `Util.getPlatform().openUri`: the system browser on the link.
pub fn open_uri(uri: &str) {
open_folder(Path::new(uri));
}
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
pub fn startup_root(
+1 -2
View File
@@ -267,8 +267,7 @@ impl Game<'_> {
// measured from the box's centre (HangingEntity's position).
let (lo, hi) = p.bounds();
let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi));
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
@@ -313,6 +313,7 @@ impl Game<'_> {
return true;
}
self.using = None;
self.send_use_item_complete();
self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player);
if ominous {
self.drink_ominous_bottle(slot, stack);
@@ -609,6 +609,7 @@ impl Game<'_> {
if inside(cursor, bx, by, 20.0, 18.0) {
let open = !self.book_open(book_type);
self.level.recipe_book.open[book_type.index()] = open;
self.send_book_settings(book_type);
if !open {
self.recipe_ui.overlay = None;
}
@@ -694,6 +695,7 @@ impl Game<'_> {
if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) {
let filtering = !self.book_filtering(book_type);
self.level.recipe_book.filtering[book_type.index()] = filtering;
self.send_book_settings(book_type);
self.recipe_ui.page = 0;
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
@@ -714,6 +716,22 @@ impl Game<'_> {
}
false
}
/// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book.
fn send_book_settings(&self, book_type: BookType) {
self.send(
horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings(
horizon_protocol::packets::recipes::RecipeBookChangeSettings {
book_type: book_type.index() as i32,
open: self.book_open(book_type),
filtering: self.book_filtering(book_type),
},
),
);
}
pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> {
let book = self.active_book()?;
Some((self.book_open(book), self.book_filtering(book)))
}
/// How many displays the client's book holds.
pub(super) fn recipe_book_known(&self) -> usize {
self.recipe_ui.client.displays().len()
+177 -68
View File
@@ -117,13 +117,17 @@ pub(super) struct Remote {
joined: bool,
status: String,
pub(super) entity_id: i32,
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
dimension: DimensionType,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
/// Decodes chunk packets as they arrive, off the frame.
decoder: ChunkDecoder,
view_distance: i32,
/// Options.serverRenderDistance: the server's view distance from the
/// login or the last chunk-cache-radius packet.
pub(super) view_distance: i32,
sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
last_input: u8,
@@ -189,6 +193,10 @@ pub(super) struct Remote {
destroying: Option<([i32; 3], u8, f32)>,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
/// Keys whose presses send a packet (swap hands, drop, a mount's
/// inventory): like the click queue, they wait for the tick's
/// `handleKeybinds` phase rather than going out between window events.
pub(super) pending_keys: Vec<KeyCode>,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
death_message: Option<String>,
@@ -212,6 +220,9 @@ pub(super) struct Remote {
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
/// The time a frame spends on chunk packets before it leaves the rest of
@@ -394,6 +405,9 @@ impl Game<'_> {
self.carried = None;
self.using = None;
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
self.potions = Default::default();
self.set_mode_id(0);
self.experience = Experience {
level: 0,
@@ -428,6 +442,7 @@ impl Game<'_> {
joined: false,
status: "Connecting to the server...".into(),
entity_id: 0,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
@@ -473,6 +488,7 @@ impl Game<'_> {
destroying: None,
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
stop_mining: false,
placing: None,
death_message: None,
@@ -486,6 +502,7 @@ impl Game<'_> {
chunk_center: (0, 0),
backlog: VecDeque::new(),
backlog_since: None,
server_links: Vec::new(),
transfer: None,
});
// The level's TickRateManager follows the server's ticking packets.
@@ -862,6 +879,11 @@ impl Game<'_> {
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::DecodeError(e) => {
@@ -1621,7 +1643,11 @@ impl Game<'_> {
}
C::Login(p) => {
self.level.hardcore = p.hardcore;
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0).
self.selected = 0;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.view_distance = p.chunk_radius;
@@ -1639,8 +1665,10 @@ impl Game<'_> {
self.send(S::ClientInformation(info));
}
C::Respawn(p) => {
// A new player, without effects: the server sends them again.
// A new player, without effects (the server sends them
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
self.enter_remote_dimension(&p.spawn, true);
self.screen = Screen::Playing;
if let Some(r) = self.remote.as_mut() {
@@ -2174,6 +2202,11 @@ impl Game<'_> {
self.honey_slide_particles(at);
}
}
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.remote_complete_using();
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
@@ -2336,10 +2369,11 @@ impl Game<'_> {
}
self.apply_remote_abilities();
}
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
// carried index stays, so the next tick answers with SetCarriedItem.
C::SetHeldSlot(p) => {
self.selected = p.slot.clamp(0, 8) as usize;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(self.selected);
if (0..9).contains(&p.slot) {
self.selected = p.slot as usize;
}
}
C::ContainerSetContent(p) => {
@@ -3171,12 +3205,6 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.track_experience_display();
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
// for a server's client (multiplayer and the default single player),
// so the entries advance here or the slot stays shaded until the
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
@@ -3212,6 +3240,14 @@ impl Game<'_> {
// Window callbacks only queue clicks, so an interaction cannot leak
// between CLIENT_TICK_END and a later pong.
self.remote_handle_input();
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
// handleKeybinds: this path replaces `tick_world`'s for a server's
// client (multiplayer and the default single player), so the entries
// advance here or the slot stays shaded until the server says the
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
// a client. Ticking it after the keybinds keeps a held use from
// trying again a tick before the server's entry ends.
self.cooldowns.tick();
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
@@ -3333,6 +3369,11 @@ impl Game<'_> {
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
// at 10, the hurt tilt restarted every frame and shook the camera.
player.health.advance_clock();
}
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
// jump, a spectator, landing) is sent to the server.
@@ -3930,6 +3971,9 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((hit.pos, face, 0.0));
}
// startDestroyBlock plays the first hit and crack itself; the
// server's level events 2019 and 2020 do the rest.
self.breaking_block_effects(hit.pos, hit.face, true);
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
@@ -3942,20 +3986,6 @@ impl Game<'_> {
};
let done = done + progress;
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
if ticks % 4 == 1 {
let c = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&facts.sounds.hit,
Some(c),
(facts.sounds.volume + 1.0) / 8.0,
facts.sounds.pitch * 0.5,
);
}
self.crack_particle(pos, hit.face, state);
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state, seq);
@@ -4354,13 +4384,27 @@ impl Game<'_> {
let Some(info) = self.res.items().get(&held.item).cloned() else {
return false;
};
// MultiPlayerGameMode.useItem runs the use here too (inside
// startPrediction): the `use_cooldown` component starts its entry, and
// the server's ClientboundCooldownPacket confirms it.
self.apply_use_cooldown(&held);
// Food starts being eaten (Consumable.startConsuming), a book and
// quill opens.
self.with_hand(hand, |g| g.start_eating(&info));
// MultiPlayerGameMode.useItem's prediction: an item cooling down
// passes (its packet still goes).
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
if self.cooldowns.is_on_cooldown(&group) {
return false;
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
// no cooldown or use animation starts.
let eating = self.with_hand(hand, |g| g.start_eating(&info));
if info.food.is_some() && !eating {
return false;
}
// It runs the use here too: ItemStack.use starts the `use_cooldown`
// of an item with no use time, and the server's
// ClientboundCooldownPacket confirms it; one held in use starts it at
// finishUsingItem (remote_complete_using).
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
self.apply_use_cooldown(&held);
}
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
return true;
}
@@ -4432,14 +4476,25 @@ impl Game<'_> {
self.remote_use_item(Hand::Off);
}
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
/// use (`held_until_released`), and a
/// consumable's eating animation and its crumbs. The server eats it and
/// sends the stack, the hunger and the sounds.
/// use (`held_until_released`), and a consumable's eating animation and
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
/// stack, its hunger and the sounds.
fn remote_tick_using(&mut self) {
let Some((slot, ticks)) = self.using else {
return;
};
let stack = self.stack_in(slot).cloned();
// The use stops when the hand no longer holds the item it began with
// (ItemStack.isSameItem).
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
if let Some(r) = self.remote.as_mut() {
if ticks == 0 {
r.use_item = item;
} else if r.use_item != item {
self.using = None;
return;
}
}
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
@@ -4464,20 +4519,51 @@ impl Game<'_> {
self.using = None;
return;
}
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item.
let duration = consumable.ticks().max(0) as u32;
let remaining = duration - ticks.min(duration);
if consumable.particles && consumable.should_emit(remaining as i32) {
// Consumable.startConsuming eats one with no use time at once, without
// a use (nor a USE_ITEM_COMPLETE).
if consumable.ticks() <= 0 {
self.using = None;
return;
}
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
// 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 ticks + 1 >= duration {
self.using = None;
if consumable.particles {
self.eat_particles(&stack, 16);
}
} else {
self.using = Some((slot, ticks + 1));
self.using = Some((slot, ticks + 1));
}
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
/// which runs on the client too (Consumable.onConsume calls its
/// ConsumableListeners on both sides): the client's FoodData eats at once,
/// so a held right click finds `Player.canEat` false instead of starting
/// another bite before the server's ClientboundSetHealth arrives.
fn remote_complete_using(&mut self) {
let Some((slot, _)) = self.using.take() else {
return;
};
let Some(stack) = self.stack_in(slot).cloned() else {
return;
};
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return;
};
if info.consumable.as_ref().is_some_and(|c| c.particles) {
self.eat_particles(&stack, 16);
}
if let Some((nutrition, saturation)) = info.food
&& let Some(r) = self.remote.as_mut()
{
// FoodData.eat(nutrition, saturation), as finish_consuming does;
// the next ClientboundSetHealth replaces it.
let (_, food, sat) = &mut r.health;
*food = (*food + nutrition as i32).clamp(0, 20);
*sat = (*sat + saturation).clamp(0.0, *food as f32);
self.apply_remote_health();
}
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
}
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
@@ -4542,6 +4628,31 @@ impl Game<'_> {
}
let (attack, use_item) = (self.left, self.right);
input.tick(&mut RemoteInputWorld(self), attack, use_item);
// handleKeybinds' keybind polls: the queued presses act now, in tick
// order, so no packet goes out between CLIENT_TICK_END and a pong.
let pending = self
.remote
.as_mut()
.map(|r| std::mem::take(&mut r.pending_keys))
.unwrap_or_default();
for key in pending {
match key {
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
KeyCode::KeyQ if !self.spectator() => {
let all = self.keys.contains(&KeyCode::ControlLeft);
self.remote_drop(all);
}
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
}
_ => {}
}
}
if self.clicks.miss_time > 0 {
self.clicks.miss_time -= 1;
}
@@ -4653,22 +4764,8 @@ impl Game<'_> {
if self.spectator() {
return Ok(true);
}
let all = self.keys.contains(&KeyCode::ControlLeft);
// MultiPlayerGameMode.dropItem: predict removal before sending
// the action, then swing only if a nonempty stack was removed.
let dropped = horizon_client::items::take_held_drop(
&mut self.inventory[self.selected],
all,
false,
);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
@@ -4676,12 +4773,9 @@ impl Game<'_> {
// horse or llama, the server opens its inventory
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
KeyCode::KeyE => {
@@ -4728,6 +4822,21 @@ impl Game<'_> {
}));
}
}
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
/// the action; swing only if a nonempty stack was removed.
fn remote_drop(&mut self, all: bool) {
let dropped =
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
}
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
/// sends both slots back; nothing is predicted.
+74 -17
View File
@@ -19,6 +19,9 @@ impl Screen {
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
| Self::Mod => true,
Self::Playing
| Self::GameModeSwitcher
@@ -35,20 +38,35 @@ impl Screen {
}
/// PauseScreen buttons: (x, y, width, label key, action id, active).
/// `links`: the `minecraft:server_links` dialog's button, which the
/// `pause_screen_additions` tag (empty in vanilla) falls back to when the
/// server sent links — a full-width row that pushes the last two rows down.
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
fn pause_buttons(
w: f32,
h: f32,
remote: bool,
links: bool,
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
let (grid_w, grid_h) = (212.0, 166.0);
let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 });
let x0 = ((w - grid_w) * 0.5).floor();
let y0 = ((h - grid_h) * 0.25).floor();
vec![
let down = if links { 24.0 } else { 0.0 };
let mut v = vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
(x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true),
(x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true),
];
if links {
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
(
x0 + 110.0,
y0 + 122.0,
y0 + 122.0 + down,
98.0,
"options.worldOptions.button",
9,
@@ -56,7 +74,7 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
),
(
x0 + 4.0,
y0 + 146.0,
y0 + 146.0 + down,
204.0,
if remote {
"menu.disconnect"
@@ -66,16 +84,17 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
2,
true,
),
]
]);
v
}
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the others stay inactive.
const FRIENDS_ICON: usize = 2;
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
let y = ((h - 166.0) * 0.25).floor() + 98.0;
let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0;
[
(x0, y, "gui/sprites/pause_menu/bug"),
(x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"),
@@ -120,7 +139,9 @@ impl Game<'_> {
u32::MAX,
true,
);
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
let links = !self.server_links().is_empty();
let mut tooltip = None;
for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) {
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(
batch,
@@ -133,9 +154,12 @@ impl Game<'_> {
active,
hover,
);
// PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP.
if id == 11 && hover {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
let mut tooltip = None;
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() {
let active = i == FRIENDS_ICON && friends;
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
// The icon has no label: name it for controller focus and the
@@ -168,7 +192,13 @@ impl Game<'_> {
}
// SpriteIconButton.withTootip: the label as a tooltip.
if let Some(tip) = tooltip {
ui::tooltip(batch, atlas, &[tip], self.cursor);
let lines: Vec<Text> = atlas
.font
.split_lines(&tip, batch.width)
.into_iter()
.map(|(line, _)| line)
.collect();
ui::tooltip(batch, atlas, &lines, self.cursor);
}
}
pub(super) fn pause_click(&mut self) -> Result<()> {
@@ -180,8 +210,9 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let friends = pause_icons(w, h)[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server())
let links = !self.server_links().is_empty();
let friends = pause_icons(w, h, links)[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server(), links)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
@@ -200,6 +231,11 @@ impl Game<'_> {
Some(4) => self.open_advancements(true),
Some(9) => self.open_world_options(),
Some(10) => self.screen = Screen::Friends,
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
Some(11) => {
self.links_ui.scroll = 0.0;
self.screen = Screen::ServerLinks;
}
Some(2) => self.close_world()?,
_ => {}
}
@@ -357,7 +393,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{Screen, pause_buttons};
use super::{Screen, pause_buttons, pause_icons};
#[test]
fn menu_blur_matches_vanilla_screen_backgrounds() {
@@ -390,11 +426,32 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote);
let buttons = pause_buttons(427.0, 240.0, remote, false);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
}
}
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true);
let without = pause_buttons(427.0, 240.0, true, false);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
for b in &with {
if b.4 == 1 || b.4 == 9 || b.4 == 2 {
let same = without.iter().find(|o| o.4 == b.4).unwrap();
assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3);
}
}
}
}
@@ -0,0 +1,487 @@
//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens
//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's
//! fallback when a server sent ClientboundServerLinksPacket): a scrollable
//! list of the server's links, each opened through `ConfirmLinkScreen`
//! (Screen.clickUrlAction), and the `dialog/warning_button`'s
//! DialogScreen.WarningScreen ("Note about custom screens").
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen
use super::{Game, Screen, packs::open_uri};
use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui};
use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind};
/// HeaderAndFooterLayout's header and footer heights; the content wants to
/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`).
const HEADER: f32 = 33.0;
const FOOTER: f32 = 33.0;
const CONTENT_TOP: f32 = HEADER + 30.0;
/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200.
const LINK_WIDTH: f32 = 310.0;
/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons.
const ROW: f32 = 22.0;
/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6).
const SCROLL_RESERVE: f32 = 10.0;
/// DialogScreen's `dialog/warning_button` ImageButton.
const WARNING_BUTTON: f32 = 20.0;
/// The ServerLinks screen's state: ScrollableLayout's scroll and the link
/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`).
#[derive(Default)]
pub(super) struct LinksUi {
pub scroll: f32,
pub confirm: Option<String>,
}
/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI
/// (java.net.URI's scheme then the allowlist; whitespace-free).
pub(super) fn valid_untrusted_uri(uri: &str) -> bool {
let Some(colon) = uri.find(':') else {
return false;
};
let scheme = &uri[..colon];
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return false;
}
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
return false;
}
!uri.chars().any(|c| c.is_control() || c == ' ')
}
/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links
/// that validate, warning about the dropped ones.
pub(super) fn trusted(links: Vec<ServerLink>) -> Vec<ServerLink> {
links
.into_iter()
.filter(|l| {
let ok = valid_untrusted_uri(&l.link);
if !ok {
log::warn!("Horizon: server sent an invalid link: {}", l.link);
}
ok
})
.collect()
}
/// `ServerLinks.Entry.displayName`: `known_server_link.<name>` for a known
/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else.
fn link_label(link: &ServerLink) -> Text {
const KNOWN: [&str; 10] = [
"report_bug",
"community_guidelines",
"support",
"status",
"feedback",
"community",
"website",
"forums",
"news",
"announcements",
];
match &link.kind {
ServerLinkKind::Known(id) => Text::translate(
&format!(
"known_server_link.{}",
KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0])
),
&[],
),
ServerLinkKind::Custom(tag) => {
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag))
}
}
}
/// The scrolled column of link buttons: content height and, for a list that
/// fits, the `HeaderAndFooterLayout` contents frame's top.
fn list_height(n: usize) -> f32 {
n as f32 * ROW - 2.0
}
fn list_top(h: f32, n: usize) -> f32 {
CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER))
}
fn scroll_limit(h: f32, n: usize) -> f32 {
(list_height(n) - (h - HEADER - FOOTER)).max(0.0)
}
/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the
/// footer `Back` button's corner. `top` includes the scroll.
fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> {
let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor();
(0..n).map(|i| (x, top + i as f32 * ROW)).collect()
}
/// The warning button beside the title (DialogScreen's title row: 10 pixels
/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds).
fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) {
let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0;
let y = ((HEADER - WARNING_BUTTON) / 2.0).floor();
if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON {
(0.0f32.max(w - 40.0), 5.0f32.min(h))
} else {
(x, y)
}
}
fn back_button(w: f32, h: f32) -> (f32, f32) {
(
((w - 200.0) / 2.0).floor(),
h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(),
)
}
impl Game<'_> {
/// `connection.serverLinks()`: the links the open server sent (empty for
/// a world without a connection or one that sent none).
pub(super) fn server_links(&self) -> &[ServerLink] {
self.remote
.as_ref()
.map_or(&[], |r| r.server_links.as_slice())
}
/// DialogScreen over the pause menu: `menu.server_links.title`, the
/// scrollable link buttons and `gui.back` in the footer.
pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let links = self.server_links().to_vec();
self.links_ui.scroll = self
.links_ui
.scroll
.clamp(0.0, scroll_limit(h, links.len()));
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
// The title row: the title, then 10 pixels on the warning button.
let title = Text::translate("menu.server_links.title", &[]);
let tw = atlas.font.width_text(&title);
let tx = ((w - tw - 30.0) / 2.0).floor();
batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true);
let (wx, wy) = warning_button(w, h, tw);
let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON);
horizon_client::widgets::record(
"menu.custom_screen_info.tooltip",
[wx, wy, WARNING_BUTTON, WARNING_BUTTON],
true,
);
batch.sprite_scaled(
atlas,
if wh {
"gui/sprites/dialog/warning_button_highlighted"
} else {
"gui/sprites/dialog/warning_button"
},
wx,
wy,
WARNING_BUTTON,
WARNING_BUTTON,
u32::MAX,
);
let first = batch.vertices.len();
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) {
let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0);
ui::button(
batch,
atlas,
x,
y,
LINK_WIDTH,
20.0,
&link_label(link),
true,
hover,
);
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::scrollbar(
batch,
atlas,
(w / 2.0).floor() + 155.0 + 4.0,
HEADER,
h - FOOTER,
list_height(links.len()),
self.links_ui.scroll.trunc(),
);
let (bx, by) = back_button(w, h);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
batch,
atlas,
bx,
by,
200.0,
20.0,
&Text::translate("gui.back", &[]),
true,
hover,
);
if wh {
ui::tooltip(
batch,
atlas,
&[Text::translate("menu.custom_screen_info.tooltip", &[])],
self.cursor,
);
}
}
/// A click on `Screen::ServerLinks`: a link, the warning button or Back.
pub(super) fn server_links_click(&mut self) -> Result<()> {
let (w, h) = self.gui_size();
let links = self.server_links().to_vec();
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
if inside
&& let Some(i) = link_buttons(w, top, links.len())
.iter()
.position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0))
{
self.audio.play_button_click();
self.click_link(&links[i].link);
return Ok(());
}
let title = Text::translate("menu.server_links.title", &[]);
let tw = self.gui.atlas.font.width_text(&title);
let (wx, wy) = warning_button(w, h, tw);
if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) {
self.audio.play_button_click();
self.screen = Screen::DialogWarning;
return Ok(());
}
let (bx, by) = back_button(w, h);
if ui::hovered(self.cursor, bx, by, 200.0, 20.0) {
self.audio.play_button_click();
self.screen = Screen::Paused;
}
Ok(())
}
/// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on
/// Links" asks with ConfirmLinkScreen, else the browser opens directly.
fn click_link(&mut self, url: &str) {
if !self.settings.chat_links {
return;
}
if self.settings.chat_links_prompt {
self.links_ui.confirm = Some(url.to_owned());
self.screen = Screen::ConfirmLink;
} else {
open_uri(url);
}
}
/// `ScrollableLayout`'s mouse wheel over the links.
pub(super) fn server_links_scroll(&mut self, delta: f32) {
let (_, h) = self.gui_size();
self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0)
.clamp(0.0, scroll_limit(h, self.server_links().len()));
}
/// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the
/// following website?", the link, the red warning, and Yes / Copy Link to
/// Clipboard / No.
pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let url = self.links_ui.confirm.clone().unwrap_or_default();
let layout = confirm_link_layout(atlas, (w, h), &url);
for (line, x, y) in &layout.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"];
for ((x, y), key) in layout.buttons.iter().zip(labels) {
let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0);
ui::button(
batch,
atlas,
*x,
*y,
100.0,
20.0,
&Text::translate(key, &[]),
true,
hover,
);
}
}
/// ConfirmLinkScreen's answer: yes opens the browser, the middle button
/// copies the link, no closes; all return to Server Links.
pub(super) fn confirm_link_click(&mut self) {
let (w, h) = self.gui_size();
let url = self.links_ui.confirm.clone().unwrap_or_default();
let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url);
for (i, (x, y)) in layout.buttons.iter().enumerate() {
if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) {
continue;
}
self.audio.play_button_click();
match i {
0 => open_uri(&url),
1 => {
self.clipboard.set(&url);
}
_ => {}
}
self.screen = Screen::ServerLinks;
return;
}
}
/// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect
/// button ("Custom screen rejected" is the shown reason) and Back.
pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let layout = dialog_warning_layout(atlas, (w, h));
for (line, x, y) in &layout.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
let quit = if self.external_server() {
"menu.disconnect"
} else {
"menu.returnToMenu"
};
for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) {
let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0);
ui::button(
batch,
atlas,
x,
y,
150.0,
20.0,
&Text::translate(key, &[]),
true,
hover,
);
}
}
/// WarningScreen's answer: disconnect, or back to Server Links.
pub(super) fn dialog_warning_click(&mut self) -> Result<()> {
let (w, h) = self.gui_size();
let layout = dialog_warning_layout(&self.gui.atlas, (w, h));
for (i, (x, y)) in layout.buttons.iter().enumerate() {
if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) {
continue;
}
self.audio.play_button_click();
if i == 0 {
// DialogConnectionAccess.disconnect: leave with the custom
// screens' reason.
let reason = self.translate(
"menu.custom_screen_info.disconnect",
"Custom screen rejected",
);
self.leave_server(Some(reason))?;
} else {
self.screen = Screen::ServerLinks;
}
return Ok(());
}
Ok(())
}
}
/// A confirm screen's laid-out text (title, message, extra warning lines) and
/// button corners; the whole column centred (ConfirmScreen.init).
struct ConfirmLayout {
lines: Vec<(Text, f32, f32)>,
buttons: Vec<(f32, f32)>,
}
/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at
/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16.
fn confirm_layout(
font: &horizon_client::font::Font,
(w, h): (f32, f32),
title: &Text,
message: &Text,
extra: &[Text],
button_widths: &[f32],
) -> ConfirmLayout {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.take(15)
.map(|(line, _)| line)
.collect();
// LinearLayout vertical spacing 8; the button row's children pad 16 on top.
let extra_h = 17.0 * extra.len() as f32;
let mh = 9.0 * message.len() as f32;
let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0;
let mut y = ((h - total) / 2.0).trunc();
let mut lines = Vec::new();
let mut push = |line: &Text, at: f32| {
let lw = font.width_text(line);
lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at));
};
push(title, y);
y += 17.0;
for line in &message {
push(line, y);
y += 9.0;
}
for line in extra {
y += 8.0;
push(line, y);
y += 9.0;
}
let row_w: f32 = button_widths.iter().sum::<f32>() + 4.0 * (button_widths.len() - 1) as f32;
let by = y + 8.0 + 16.0;
let mut bx = ((w - row_w) / 2.0).trunc();
let buttons = button_widths
.iter()
.map(|bw| {
let p = (bx, by);
bx += bw + 4.0;
p
})
.collect();
ConfirmLayout { lines, buttons }
}
fn confirm_link_layout(
atlas: &horizon_client::hud::HudAtlas,
size: (f32, f32),
url: &str,
) -> ConfirmLayout {
// ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the
// message, `chat.link.warning` in its red (0xFFCCCC).
confirm_layout(
&atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&Text::literal(url.to_owned()),
&[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)],
&[100.0; 3],
)
}
fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout {
confirm_layout(
&atlas.font,
size,
&Text::translate("menu.custom_screen_info.title", &[]),
&Text::translate("menu.custom_screen_info.contents", &[]),
&[],
&[150.0; 2],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untrusted_uris_are_http_or_https_with_a_scheme() {
for ok in [
"https://example.net/x",
"http://example.net",
"HTTPS://EXAMPLE.NET",
] {
assert!(valid_untrusted_uri(ok), "{ok}");
}
for bad in [
"example.net/no-scheme",
"ftp://example.net",
"file:///etc/passwd",
"javascript:alert(1)",
"https://exa mple.net",
"://example.net",
"",
] {
assert!(!valid_untrusted_uri(bad), "{bad}");
}
}
}
@@ -164,11 +164,12 @@ impl Game<'_> {
} else {
[self.yaw, self.pitch]
};
let scale = self.view_scale();
for display in self.text_displays.values_mut() {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
let range = f64::from(d.view_range) * 64.0 * scale;
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
continue;
}
+1 -1
View File
@@ -252,7 +252,7 @@ impl Game<'_> {
}
let range = super::entity_culling::default_range(
glam::DVec3::splat(PrimedTnt::SIZE),
self.settings.entity_distance,
self.view_scale(),
);
for t in &self.tnts {
// EntityRenderer.shouldRender.
+1 -1
View File
@@ -495,7 +495,7 @@ impl Game<'_> {
let range = 10.0
* super::entity_culling::default_range(
glam::DVec3::splat(2.0 * HALF),
self.settings.entity_distance,
self.view_scale(),
);
for t in &self.tridents {
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
+24 -23
View File
@@ -511,10 +511,10 @@ pub struct Renderer {
occlusion: occlusion::Occlusion,
/// Resolution scale and FXAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
hdr: Option<hdr::Hdr>,
/// Whether the window's surface can present scRGB.
hdr_capable: bool,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -1071,7 +1071,7 @@ impl Renderer {
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
let mut config = surface
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
.ok_or("surface has no supported configuration")?;
@@ -1099,7 +1099,7 @@ impl Renderer {
));
}
surface.configure(&device, &config);
(config, caps.present_modes.clone(), hdr::supported(&caps))
(config, caps.present_modes.clone(), hdr::output(&caps))
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
@@ -1113,7 +1113,7 @@ impl Renderer {
view_formats: vec![format],
color_space: wgpu::SurfaceColorSpace::default(),
};
(config, Vec::new(), false)
(config, Vec::new(), None)
};
let offscreen = surface
.is_none()
@@ -1953,22 +1953,23 @@ impl Renderer {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &entity_shader,
entry_point: Some("fs_celestial"),
entry_point: Some("fs_sky_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
// moon shine; nothing else reads the frame's alpha.
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
// the colour premultiplied in fs_sky_celestial. Its alpha is
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
// and moon's discs shine; nothing else reads the frame's alpha.
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
}),
@@ -2456,7 +2457,7 @@ impl Renderer {
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
hdr: None,
hdr_capable,
hdr_output,
pack_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
@@ -2990,24 +2991,24 @@ impl Renderer {
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// scRGB while HDR output is on.
/// HDR10 or scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if self.hdr.is_some() {
config.format = hdr::FORMAT;
config.view_formats = vec![hdr::FORMAT];
config.color_space = hdr::COLOR_SPACE;
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
let on = on && self.hdr_capable && self.surface.is_some();
if on != self.hdr.is_some() {
self.hdr =
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
if output.is_some() != self.hdr.is_some() {
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
}
if let Some(hdr) = &mut self.hdr
+86 -13
View File
@@ -1,17 +1,50 @@
//! HDR output (Horizon setting, off by default): the frame renders exactly as
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white,
//! expanding the world's highlights towards their peak brightness. The world
//! is copied aside before the hand and HUD so only it is expanded.
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
//! white, expanding the world's highlights towards their peak brightness. The
//! world is copied aside before the hand and HUD so only it is expanded.
/// The swapchain format and colour space HDR output presents in.
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear;
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Output {
/// HDR10: 10-bit BT.2020 with the PQ curve, the display's own signal.
Pq,
/// scRGB: linear 16-bit float, BT.709 primaries, 1.0 = 80 nits.
ScRgb,
}
/// Whether `caps` can present scRGB.
pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool {
caps.color_spaces(FORMAT)
.contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR)
impl Output {
pub fn format(self) -> wgpu::TextureFormat {
match self {
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
Self::ScRgb => wgpu::TextureFormat::Rgba16Float,
}
}
pub fn color_space(self) -> wgpu::SurfaceColorSpace {
match self {
Self::Pq => wgpu::SurfaceColorSpace::Bt2100Pq,
Self::ScRgb => wgpu::SurfaceColorSpace::ExtendedSrgbLinear,
}
}
fn space(self) -> wgpu::SurfaceColorSpaces {
match self {
Self::Pq => wgpu::SurfaceColorSpaces::BT2100_PQ,
Self::ScRgb => wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR,
}
}
}
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
.into_iter()
.filter(|(_, name)| only.is_none_or(|o| o == *name))
.map(|(output, _)| output)
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
}
struct Targets {
@@ -25,6 +58,7 @@ pub struct Hdr {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
params: wgpu::Buffer,
pub output: Output,
/// The frame's format: what every other pipeline draws in.
format: wgpu::TextureFormat,
targets: Option<Targets>,
@@ -36,7 +70,7 @@ pub struct Hdr {
}
impl Hdr {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
@@ -90,7 +124,7 @@ impl Hdr {
entry_point: Some("fs_hdr"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: FORMAT,
format: output.format(),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -100,7 +134,7 @@ impl Hdr {
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 16,
size: 32,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -108,6 +142,7 @@ impl Hdr {
layout,
pipeline,
params,
output,
format,
targets: None,
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
@@ -198,6 +233,10 @@ impl Hdr {
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
0.0,
0.0,
0.0,
];
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
let view = target.create_view(&Default::default());
@@ -220,3 +259,37 @@ impl Hdr {
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
wgpu::SurfaceCapabilities {
format_capabilities: offered
.iter()
.map(|&(format, color_spaces)| wgpu::SurfaceFormatCapabilities {
format,
color_spaces,
})
.collect(),
..Default::default()
}
}
#[test]
fn hdr10_first_then_scrgb() {
let both = caps(&[
(F::Bgra8UnormSrgb, S::SRGB),
(F::Rgb10a2Unorm, S::BT2100_PQ | S::BT2100_HLG),
(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR),
]);
assert_eq!(pick(&both, None), Some(Output::Pq));
assert_eq!(pick(&both, Some("scrgb")), Some(Output::ScRgb));
let scrgb = caps(&[(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR)]);
assert_eq!(pick(&scrgb, None), Some(Output::ScRgb));
assert_eq!(pick(&scrgb, Some("pq")), None);
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
}
+11 -7
View File
@@ -216,15 +216,19 @@ pub const INTEGRATED_LOD_DISTANCE: u32 = 128;
pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool {
info.device_type == wgpu::DeviceType::IntegratedGpu && !info.name.starts_with("Apple")
}
/// A LOD distance as far terrain uses it on this adapter.
pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 {
let distance = distance.min(1024);
if lod_capped(info) {
distance.min(INTEGRATED_LOD_DISTANCE)
} else {
distance
}
}
impl Graphics {
/// `lod_distance` as far terrain uses it on this adapter.
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
let distance = self.lod_distance.min(1024);
if lod_capped(info) {
distance.min(INTEGRATED_LOD_DISTANCE)
} else {
distance
}
lod_distance_capped(self.lod_distance, info)
}
}
#[derive(Clone, Debug, PartialEq)]
@@ -255,7 +259,7 @@ pub struct Graphics {
pub resolution_scale: u32,
/// FXAA over the world.
pub fxaa: bool,
/// HDR output (scRGB), where the display path offers it.
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits.
pub hdr_paper_white: u32,
+22 -3
View File
@@ -1,6 +1,6 @@
// Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma
// space, decoded to linear light at the paper white and written to the scRGB
// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the
// space, decoded to linear light at the paper white and written to the swapchain:
// HDR10 (BT.2020, PQ) or scRGB (1.0 = 80 nits). Bright pixels of light sources that still show the
// world as it was before the hand and HUD are expanded towards the peak.
@group(0) @binding(0) var frame: texture_2d<f32>;
@group(0) @binding(1) var world: texture_2d<f32>;
@@ -14,6 +14,8 @@ struct Params {
world: f32,
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
debug: f32,
// 1 for an HDR10 swapchain, 0 for scRGB.
pq: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
@@ -40,6 +42,19 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
const BT709_TO_BT2020 = mat3x3<f32>(
vec3(0.6274, 0.0691, 0.0164),
vec3(0.3293, 0.9195, 0.0880),
vec3(0.0433, 0.0114, 0.8956),
);
// SMPTE ST 2084: nits (up to 10000) to the PQ signal.
fn pq(nits: vec3<f32>) -> vec3<f32> {
let y = pow(clamp(nits / 10000.0, vec3(0.0), vec3(1.0)), vec3(0.1593017578125));
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
}
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
@@ -65,5 +80,9 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
}
}
}
return vec4(light * params.paper, 1.0);
let scrgb = light * params.paper;
if params.pq > 0.5 {
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
}
return vec4(scrgb, 1.0);
}
+34 -1
View File
@@ -321,7 +321,9 @@ fn read_text(t: &Tag) -> Option<String> {
// A component's root `text` may be empty with the visible words in
// `extra` (every legacy display name ViaVersion produces looks like
// this), so flatten the whole component instead of reading one key.
Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()),
// ComponentSerialization.CODEC also takes a list (its first element
// styling the rest), as servers behind ViaVersion send book pages.
Tag::Compound(_) | Tag::List(_) => Some(crate::entity_nbt::name_text(t).plain()),
_ => None,
}
}
@@ -916,6 +918,37 @@ mod tests {
);
}
#[test]
fn written_book_pages_may_be_component_lists() {
// A page as Hypixel sends it through ViaVersion: a list, not a compound.
let page = Tag::List(vec![
Tag::compound([("text", Tag::String("".into()))]),
Tag::compound([("text", Tag::String("Please read ".into()))]),
Tag::compound([("text", Tag::String("the rules".into()))]),
]);
let book = Tag::compound([
(
"title",
Tag::compound([("raw", Tag::String("Rules".into()))]),
),
("author", Tag::String("Server".into())),
(
"pages",
Tag::List(vec![
page,
Tag::compound([("raw", Tag::String("Two".into()))]),
]),
),
]);
let components = Tag::compound([("minecraft:written_book_content", book)]);
let (_, extra) = decode_components(Some(&components));
let pages = extra.and_then(|d| d.written.clone()).map(|w| w.pages);
assert_eq!(
pages,
Some(vec!["Please read the rules".to_owned(), "Two".to_owned()])
);
}
#[test]
fn banner_layers_and_shield_base_round_trip() {
let mut stack = ItemStack::new("minecraft:shield", 1);
+10
View File
@@ -791,6 +791,16 @@ fn login_and_configure(
},
}),
)?;
// ServerConfigurationPacketListenerImpl.startConfiguration: links, only
// when there are any (MinecraftServer.serverLinks is empty elsewhere).
if !shared.config.server_links.is_empty() {
send(
c,
cfg::Clientbound::ServerLinks(cfg::ServerLinks {
links: shared.config.server_links.clone(),
}),
)?;
}
send(
c,
cfg::Clientbound::UpdateEnabledFeatures(cfg::UpdateEnabledFeatures {
+18 -3
View File
@@ -2068,6 +2068,7 @@ impl Hub {
| S::PlayerLoaded(_)
| S::ChunkBatchReceived(_)
| S::ClientInformation(_)
| S::RecipeBookChangeSettings(_)
| S::ClientTickEnd(_)
| S::KeepAlive(_) => false,
S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]),
@@ -2134,6 +2135,15 @@ impl Hub {
g.max_unacknowledged = 10;
}
S::ClientTickEnd(_) => self.guests[i].position_this_tick = false,
// ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket.
S::RecipeBookChangeSettings(p) => {
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
{
let settings = &mut self.guests[i].recipe_book;
settings.open[book.index()] = p.open;
settings.filtering[book.index()] = p.filtering;
}
}
S::ClientInformation(info) => {
let g = &mut self.guests[i];
g.view_distance =
@@ -2269,11 +2279,16 @@ impl Hub {
packets = 1;
}
// shouldCheckPlayerMovement (never the single-player owner).
// The server holds no velocity for a guest (getDeltaMovement),
// so nothing is expected.
if !owner && check && (!g.fall_flying || elytra_check) {
let limit = if g.fall_flying { 300.0 } else { 100.0 };
if moved > limit * f64::from(packets) {
// `movedDist - expectedDist` (getDeltaMovement): the velocity
// the server last simulated for the guest is expected
// movement, not a violation — a landing glide keeps its speed.
let expected = g.motion.as_ref().map_or(0.0, |m| {
let v = m.velocity;
v.x * v.x + v.y * v.y + v.z * v.z
});
if moved - expected > limit * f64::from(packets) {
events.push(HostEvent::Warn(format!(
"{} moved too quickly! {},{},{}",
g.name,
+5
View File
@@ -142,6 +142,10 @@ pub struct NetConfig {
/// server.properties `resource-pack`: offered to every player during
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
/// MinecraftServer.serverLinks (a dedicated server's `bug-report-link`):
/// sent in configuration when non-empty
/// (ServerConfigurationPacketListenerImpl.startConfiguration).
pub server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
impl NetConfig {
/// MinecraftServer.enforceSecureProfile.
@@ -168,6 +172,7 @@ impl NetConfig {
online: None,
log: None,
resource_pack: None,
server_links: Vec::new(),
}
}
}
+5 -2
View File
@@ -324,10 +324,13 @@ impl BlockLandingWorld for MotionWorld<'_, '_, '_> {
type DamageSource = LandingDamage;
type Sound = SceneSound;
fn is_server_level(&self) -> bool {
true
// A remote client's world is a client level: it never tramples,
// the server's block change and teleport do it (FarmlandBlock.fallOn
// and TurtleEggBlock.fallOn check `level instanceof ServerLevel`).
!self.player.client_side
}
fn is_client_side(&mut self) -> Result<bool> {
Ok(false)
Ok(self.player.client_side)
}
fn random_float(&mut self) -> Result<f32> {
Ok(self.scene.random.next_float())
+323 -87
View File
@@ -17,7 +17,7 @@
use crate::fast_hash::FxHashMap;
use crate::{
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, index},
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
};
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
use horizon_world::registry::{BlockRegistry, BlockStateId};
@@ -565,43 +565,130 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
/// Resident chunks the incremental relighter reads and writes.
pub trait LightWorld {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32>;
/// Packed light (sky << 4 | block), None outside loaded chunks.
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8>;
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8);
/// Section `s` of chunk (cx, cz), None unless the chunk is loaded and lit.
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
/// Replace the light of a section `section` returned.
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
}
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
self.get(&(cx, cz))
.filter(|c| c.lit)
.map(|c| &c.sections[s])
}
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light) {
if let Some(chunk) = self.get_mut(&(cx, cz)) {
Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]).light = light;
}
}
}
/// The sections one relight touches, held for the whole flood fill so each
/// cell access is an array index instead of a chunk lookup. Light a section
/// gets written is copied out on the first write and stored back by `finish`.
struct Region<'w> {
world: &'w mut dyn LightWorld,
slots: Vec<Slot>,
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
index: FxHashMap<Pos, u32>,
last: (Pos, u32),
}
struct Slot {
key: Pos,
/// The world's section; its light is the value before this relight.
section: Arc<Section>,
light: Option<Box<[u8; 4096]>>,
}
const NONE: u32 = u32::MAX;
impl<'w> Region<'w> {
fn new(world: &'w mut dyn LightWorld) -> Self {
Self {
world,
slots: Vec::new(),
index: FxHashMap::default(),
last: ((i32::MIN, 0, 0), NONE),
}
}
#[inline]
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
return None;
}
self.get(&(x >> 4, z >> 4))
.filter(|c| c.lit)
.map(|c| c.get(x & 15, y, z & 15))
}
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
return None;
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
if self.last.0 != key {
self.last = (key, self.load(key));
}
self.get(&(x >> 4, z >> 4))
.filter(|c| c.lit)
.map(|c| c.light(x & 15, y, z & 15))
(self.last.1 != NONE).then_some(self.last.1 as usize)
}
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
let Some(chunk) = self.get_mut(&(x >> 4, z >> 4)) else {
fn load(&mut self, key: Pos) -> u32 {
if let Some(&slot) = self.index.get(&key) {
return slot;
}
let slot = match self.world.section(key.0, key.1 as usize, key.2) {
Some(section) => {
self.slots.push(Slot {
key,
section: section.clone(),
light: None,
});
(self.slots.len() - 1) as u32
}
None => NONE,
};
self.index.insert(key, slot);
slot
}
fn state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
let slot = self.slot(x, y, z)?;
Some(self.slots[slot].section.get(index(x, y, z)))
}
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
let slot = self.slot(x, y, z)?;
let slot = &self.slots[slot];
let i = index(x, y, z);
Some(match &slot.light {
Some(light) => light[i],
None => slot.section.light(i),
})
}
/// State and packed light of a cell, with a single section lookup.
fn cell(&mut self, x: i32, y: i32, z: i32) -> Option<(u32, u8)> {
let slot = self.slot(x, y, z)?;
let slot = &self.slots[slot];
let i = index(x, y, z);
let light = match &slot.light {
Some(light) => light[i],
None => slot.section.light(i),
};
Some((slot.section.get(i), light))
}
/// Rewrite a cell's packed light; nothing outside loaded, lit chunks.
fn update_light(&mut self, x: i32, y: i32, z: i32, f: impl FnOnce(u8) -> u8) {
let Some(slot) = self.slot(x, y, z) else {
return;
};
let s = ((y - MIN_Y) >> 4) as usize;
let slot = &mut self.slots[slot];
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
Light::Uniform(v) => Box::new([*v; 4096]),
Light::Full(a) => a.clone(),
});
let i = index(x, y, z);
if chunk.sections[s].light(i) == packed {
return;
}
let section = Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]);
if let Light::Uniform(v) = section.light {
section.light = Light::Full(Box::new([v; 4096]));
}
if let Light::Full(a) = &mut section.light {
a[i] = packed;
light[i] = f(light[i]);
}
/// Store back the light of every section whose values changed.
fn finish(self) {
for slot in self.slots {
let Some(light) = slot.light else {
continue;
};
if (0..4096).all(|i| slot.section.light(i) == light[i]) {
continue;
}
// Drop the clone first so the world's section isn't copied.
drop(slot.section);
let (cx, s, cz) = slot.key;
self.world
.set_section_light(cx, s as usize, cz, Light::Full(light));
}
}
}
@@ -690,25 +777,23 @@ impl LightChanges {
}
}
}
fn level(world: &impl LightWorld, sky: bool, p: Pos) -> Option<u8> {
fn level(world: &mut Region, sky: bool, p: Pos) -> Option<u8> {
world
.light(p.0, p.1, p.2)
.map(|v| if sky { v >> 4 } else { v & 15 })
}
fn set_level(world: &mut impl LightWorld, sky: bool, p: Pos, value: u8) {
let Some(old) = world.light(p.0, p.1, p.2) else {
return;
};
let packed = if sky {
(old & 0x0F) | value << 4
} else {
(old & 0xF0) | value
};
world.set_light(p.0, p.1, p.2, packed);
fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
world.update_light(p.0, p.1, p.2, |old| {
if sky {
(old & 0x0F) | value << 4
} else {
(old & 0xF0) | value
}
});
}
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
/// edge in the column (every block at or above it is a sky source).
pub fn lowest_sky_source(world: &impl LightWorld, tables: &LightTables, x: i32, z: i32) -> i32 {
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
let mut y = MIN_Y + HEIGHT - 1;
let Some(mut top) = world.state(x, y, z) else {
return MIN_Y;
@@ -739,34 +824,52 @@ pub fn relight(
force: bool,
changed: &mut LightChanges,
) {
relight_layer(world, tables, false, positions.to_vec(), force, changed);
let mut region = Region::new(world);
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
}
fn relight_region(
world: &mut Region,
tables: &LightTables,
sky_light: bool,
positions: &[Pos],
force: bool,
changed: &mut LightChanges,
) {
crate::spans::time("lt_block", || {
relight_layer(world, tables, false, positions.to_vec(), force, changed)
});
if sky_light {
let mut seeds = positions.to_vec();
let mut columns: HashMap<(i32, i32), i32> = HashMap::new();
for &(x, _, z) in positions {
if columns.contains_key(&(x, z)) {
continue;
}
let source = lowest_sky_source(world, tables, x, z);
columns.insert((x, z), source);
// Cells whose source status changed: non-15 at or above the new
// lowest source, and old 15s (sources) below it.
for y in source..MIN_Y + HEIGHT {
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
crate::spans::time("lt_columns", || {
for &(x, _, z) in positions {
if columns.contains_key(&(x, z)) {
continue;
}
let source = lowest_sky_source(world, tables, x, z);
columns.insert((x, z), source);
// Cells whose source status changed: non-15 at or above the new
// lowest source, and old 15s (sources) below it.
for y in source..MIN_Y + HEIGHT {
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
seeds.push((x, y, z));
}
}
let mut y = source - 1;
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
seeds.push((x, y, z));
y -= 1;
}
}
let mut y = source - 1;
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
seeds.push((x, y, z));
y -= 1;
}
}
relight_layer(world, tables, true, seeds, force, changed);
});
crate::spans::time("lt_sky", || {
relight_layer(world, tables, true, seeds, force, changed)
});
}
}
fn relight_layer(
world: &mut impl LightWorld,
world: &mut Region,
tables: &LightTables,
sky: bool,
seeds: Vec<Pos>,
@@ -809,42 +912,40 @@ fn relight_layer(
}
}
// Sources inside the cleared area light up again.
let mut sources: HashMap<(i32, i32), i32> = HashMap::new();
for p in cleared {
let seed = if sky {
let lowest = *sources
.entry((p.0, p.2))
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
if p.1 >= lowest { 15 } else { 0 }
} else {
world
.state(p.0, p.1, p.2)
.map_or(0, |s| tables.cells[s as usize].emission)
};
if seed > level(world, sky, p).unwrap_or(15) {
set_level(world, sky, p, seed);
changed.push(p);
increase.push_back(p);
let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default();
crate::spans::time("lt_sources", || {
for p in cleared {
let seed = if sky {
let lowest = *sources
.entry((p.0, p.2))
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
if p.1 >= lowest { 15 } else { 0 }
} else {
world
.state(p.0, p.1, p.2)
.map_or(0, |s| tables.cells[s as usize].emission)
};
if seed > level(world, sky, p).unwrap_or(15) {
set_level(world, sky, p, seed);
changed.push(p);
increase.push_back(p);
}
}
}
});
while let Some(p) = increase.pop_front() {
let Some(l) = level(world, sky, p) else {
let Some((from, packed)) = world.cell(p.0, p.1, p.2) else {
continue;
};
let l = if sky { packed >> 4 } else { packed & 15 };
if l <= 1 {
continue;
}
let Some(from) = world.state(p.0, p.1, p.2) else {
continue;
};
for (d, (ox, oy, oz)) in OFFSETS.iter().enumerate() {
let q = (p.0 + ox, p.1 + oy, p.2 + oz);
let Some(to) = world.state(q.0, q.1, q.2) else {
continue;
};
let Some(ql) = level(world, sky, q) else {
let Some((to, packed)) = world.cell(q.0, q.1, q.2) else {
continue;
};
let ql = if sky { packed >> 4 } else { packed & 15 };
let next = l.saturating_sub(tables.cells[to as usize].dampening.max(1));
if next > ql && !tables.blocked(from as usize, to as usize, d) {
set_level(world, sky, q, next);
@@ -915,10 +1016,10 @@ mod tests {
lit.into_iter().collect()
}
fn sky(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
world.light(x, y, z).unwrap() >> 4
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) >> 4
}
fn block(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
world.light(x, y, z).unwrap() & 15
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) & 15
}
#[test]
@@ -954,6 +1055,108 @@ mod tests {
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
}
#[test]
fn relight_keeps_unchanged_and_unlit_sections() {
let mut w = world(|_| {});
Arc::make_mut(w.get_mut(&(1, 0)).unwrap()).lit = false;
let before = w.clone();
let same = |w: &HashMap<(i32, i32), Arc<Chunk>>, key| {
w[&key]
.sections
.iter()
.zip(&before[&key].sections)
.all(|(a, b)| Arc::ptr_eq(a, b))
};
// Cleared and lit back to the same values: nothing is stored.
relight(
&mut w,
&tables(),
true,
&[(8, 5, 8)],
false,
&mut LightChanges::default(),
);
assert!(w.keys().all(|&key| same(&w, key)));
// Light reaching an unlit chunk isn't written into it.
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(15, 0, 8, TORCH);
relight(
&mut w,
&tables(),
true,
&[(15, 0, 8)],
false,
&mut LightChanges::default(),
);
assert!(same(&w, (1, 0)));
assert_eq!(block(&w, 14, 0, 8), 13);
}
#[test]
fn random_border_relights_match_full_relight() {
// Edits around every border of chunk (0, 0), at the top and bottom of
// the world, in batches; a held clone of the chunk must not change.
let mut w = world(|_| {});
let held = w[&(0, 0)].clone();
let mut rng = 0x9e37_79b9_u32;
let mut next = |n: usize| {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
rng as usize % n
};
let xz = [-2, -1, 0, 1, 7, 14, 15, 16, 17];
let ys = [
MIN_Y,
MIN_Y + 1,
-2,
-1,
0,
1,
15,
16,
MIN_Y + HEIGHT - 2,
MIN_Y + HEIGHT - 1,
];
for _ in 0..60 {
let positions: Vec<Pos> = (0..1 + next(6))
.map(|_| (xz[next(xz.len())], ys[next(ys.len())], xz[next(xz.len())]))
.collect();
for &(x, y, z) in &positions {
let state = [TORCH, SLAB, AIR, STONE][next(4)];
Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state);
}
relight(
&mut w,
&tables(),
true,
&positions,
false,
&mut LightChanges::default(),
);
}
// Every chunk, the outer ring lit as `world` lit it (no neighbours
// past the 3x3).
for (&(cx, cz), chunk) in &w {
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
w.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
.map(|c| &**c)
})
});
for (si, s) in light_chunk(&region, &tables(), true).iter().enumerate() {
for i in 0..4096 {
assert_eq!(
s.get(i),
chunk.sections[si].light(i),
"chunk {cx},{cz} section {si} index {i}"
);
}
}
}
let fresh = world(|_| {});
for (a, b) in held.sections.iter().zip(&fresh[&(0, 0)].sections) {
assert!((0..4096).all(|i| a.light(i) == b.light(i)));
}
}
#[test]
fn incremental_relight_matches_full_relight() {
let mut w = world(|c| {
c.set(8, 0, 8, TORCH);
@@ -1319,4 +1522,37 @@ mod tests {
"fixture heap payload {payload} bytes; old worker clones it plus 24 section Arcs; old application clones current payload and 98304 returned light bytes. New worker clones zero section payload; new application clones current payload only when shared."
);
}
/// Relight cost for torches placed and removed near chunk borders with
/// slabs flickering around them (fluids next to lava, in the game).
/// `cargo test --profile fast -p horizon-client relight_bench -- --ignored --nocapture`
#[test]
#[ignore]
fn relight_bench() {
let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect();
let t = tables();
let mut rng = 0x2545_f491_u32;
let mut next = |n: i32| {
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
(rng % n as u32) as i32
};
let start = std::time::Instant::now();
let mut changed = LightChanges::default();
for round in 0..400 {
let state = [TORCH, SLAB, AIR][round % 3];
let positions: Vec<Pos> = (0..32)
.map(|_| (next(32) - 8, next(12), next(32) - 8))
.collect();
for &(x, y, z) in &positions {
Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state);
}
relight(&mut w, &t, true, &positions, false, &mut changed);
}
println!(
"relight_bench: {:?}, {} sections",
start.elapsed(),
changed.sections().count()
);
}
}
+9 -2
View File
@@ -781,8 +781,9 @@ pub struct Extra {
pub drop_chances: Option<Box<[f32; 8]>>,
/// `Mob.leftHanded`.
pub left_handed: bool,
/// The sound variant: index in the species' `*_sound_variant` registry.
pub sound_variant: u8,
/// The sound variant: index in the species' `*_sound_variant` registry
/// (`finalize::sound_variants`); None is the default, `classic`.
pub sound_variant: Option<u8>,
/// `Zombie.canBreakDoors`.
pub can_break_doors: bool,
/// A goat's `IsScreamingGoat` and the horns it lost (left, right).
@@ -983,6 +984,12 @@ pub enum MobEvent {
target: Who,
velocity: [f64; 3],
},
/// `Entity.move(MoverType.SHULKER, delta)`: shift a target's position
/// (an opening shulker lifts what stands on its shell).
Shift {
target: Who,
delta: [f64; 3],
},
/// `EvokerWololoSpellGoal`: a blue sheep turns red.
Wololo {
mob: u32,
+21 -1
View File
@@ -6,7 +6,7 @@ use horizon_core::geometry::trig;
/// `2.5000003E-7F` widened: the squared distance controls treat as arrived.
const ARRIVED: f64 = 2.500_000_3e-7_f32 as f64;
/// `1.0E-5F` widened.
const EPSILON: f64 = 1.0e-5_f32 as f64;
pub(super) const EPSILON: f64 = 1.0e-5_f32 as f64;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Operation {
@@ -90,6 +90,9 @@ pub enum LookKind {
Fixed,
/// `HappyGhast.HappyGhastLookControl`.
HappyGhast,
/// `Shulker.ShulkerLookControl`: the yaw turns in the attach face's
/// plane and the head is never clamped to the body.
Shulker,
}
#[derive(Clone, Debug, Default)]
pub struct LookControl {
@@ -135,6 +138,9 @@ impl Mob {
if self.species() == super::Species::Rabbit {
return super::rabbit::move_control(self, w);
}
if self.species() == super::Species::Turtle {
return super::turtle::move_control(self);
}
let attr_speed = self.movement_speed();
match self.ai.mov.kind {
MoveKind::Custom => super::ai::custom_move_control(self, w, events),
@@ -345,6 +351,20 @@ impl Mob {
self.body_yaw += 4.0;
}
}
LookKind::Shulker => {
// ShulkerLookControl: resetXRotOnTick, getXRotD 0, and the
// yaw worked out in the attach face's plane (getYRotD).
self.head_pitch = 0.0;
if look.cooldown > 0 {
self.ai.look.cooldown -= 1;
if let Some(y) = super::shulker::look_yaw(self, look.wanted) {
self.head_yaw = rotate_towards(self.head_yaw, y, look.y_max_rot_speed);
}
} else {
self.head_yaw = rotate_towards(self.head_yaw, self.body_yaw, 10.0);
}
// clampHeadRotationToBody does nothing for a shulker.
}
LookKind::Default | LookKind::Fixed => {
if look.kind == LookKind::Default {
self.head_pitch = 0.0;
+12 -1
View File
@@ -1508,7 +1508,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
}
fn sound(&mut self, m: &mut Mob, registry: fn(&SpawnRegistries) -> &VariantRegistry) {
let r = registry(self.data.registries);
m.extra.sound_variant = r.random(self.random) as u8;
m.extra.sound_variant = Some(r.random(self.random) as u8);
}
/// `finalizeSpawn` of the mob's class.
@@ -2014,6 +2014,17 @@ pub fn species_variant_component(s: Species, variant: u8) -> Option<(&'static st
};
Some((component, format!("minecraft:{}", variant_name(s, variant))))
}
/// The `*_sound_variant` registry of a species, in registry order.
pub fn sound_variants(s: Species) -> &'static [&'static str] {
match s {
Species::Pig => &["big", "classic", "mini"],
Species::Cow => &["classic", "moody"],
Species::Chicken => &["classic", "picky"],
Species::Cat => &["classic", "royal"],
Species::Wolf => &["angry", "big", "classic", "cute", "grumpy", "puglin", "sad"],
_ => &[],
}
}
/// The registry name of the game's variant index.
pub fn variant_name(s: Species, index: u8) -> &'static str {
let i = index as usize;
+36 -6
View File
@@ -235,6 +235,26 @@ impl MobWorld<'_> {
};
scene.unobstructed(b).unwrap_or(false)
}
/// `LevelReader.containsAnyLiquid`: the blocks from the box's minimum
/// corner to the block holding its maximum corner.
pub fn contains_any_liquid(&self, b: Aabb) -> bool {
let (lo, hi) = (b.min_position(), b.max_position());
let (x0, y0, z0) = (
lo.x.floor() as i32,
lo.y.floor() as i32,
lo.z.floor() as i32,
);
let (x1, y1, z1) = (
hi.x.floor() as i32,
hi.y.floor() as i32,
hi.z.floor() as i32,
);
(x0..=x1).any(|x| {
(y0..=y1).any(|y| {
(z0..=z1).any(|z| self.facts.get(self.world.block(x, y, z)).fluid != Fluid::None)
})
})
}
}
impl Mob {
/// `Entity.refreshDimensions` after growing from `old_w` by `old_h`:
@@ -813,14 +833,24 @@ impl Mob {
self.velocity.y -= gravity / 4.0;
}
}
// jumpOutOfFluid
// jumpOutOfFluid: Entity.isFree wants the box dry and clear of the
// entities a mob collides with (canBeCollidedWith: living shulkers).
let m = self.velocity;
let ahead = self.bounds().move_by(Vec3::new(
m.x,
m.y + f64::from(0.6_f32) - self.position.y + old_y,
m.z,
));
let wide = ahead.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
if self.horizontal_collision
&& w.no_collision(self.bounds().move_by(Vec3::new(
m.x,
m.y + f64::from(0.6_f32) - self.position.y + old_y,
m.z,
)))
&& w.no_collision(ahead)
&& !w.contains_any_liquid(ahead)
&& !w.others_near(wide).any(|s| {
s.id != self.id
&& s.alive
&& s.species == super::Species::Shulker
&& s.bounds.intersects(wide)
})
{
self.velocity.y = f64::from(0.3_f32);
}
+213 -37
View File
@@ -5,6 +5,7 @@
use super::{
Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who,
anim::{Poses, State},
control::LookKind,
goals::{
Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, NearestTarget, RandomLookAround,
goal_clone, reduced,
@@ -37,7 +38,13 @@ pub fn register(m: &mut Mob) {
g.add(4, ShulkerAttack { attack_time: 0 });
g.add(7, Peek { time: 0 });
g.add(8, RandomLookAround::default());
m.ai.targets.add(1, HurtByTarget::new().alert());
// `ShulkerLookControl`: the head turns in the attach face's plane.
m.ai.look.kind = LookKind::Shulker;
// `HurtByTargetGoal(this, Shulker.class).setAlertOthers()`: damage from
// another shulker draws no retaliation (`toIgnoreDamage`), but a hurt
// shulker alerts every targetless shulker around.
m.ai.targets
.add(1, HurtByTarget::new().alert().ignoring(&[Species::Shulker]));
// ShulkerNearestAttackGoal: the search box is only 4 deep along the
// attach axis.
m.ai.targets.add(
@@ -57,6 +64,10 @@ pub fn register(m: &mut Mob) {
fn face_step(face: u8) -> [i32; 3] {
STEPS[face.min(5) as usize]
}
/// `Direction.getOpposite` for a 3D data value.
fn opposite(face: u8) -> u8 {
face.min(5) ^ 1
}
fn block_pos(m: &Mob) -> [i32; 3] {
[
m.position.x.floor() as i32,
@@ -64,19 +75,83 @@ fn block_pos(m: &Mob) -> [i32; 3] {
m.position.z.floor() as i32,
]
}
/// `Shulker.canStayAt`: an empty spot against a sturdy face.
fn can_stay_at(w: &MobWorld, p: [i32; 3], face: u8) -> bool {
if !w.facts.is_air(w.world.block(p[0], p[1], p[2])) {
/// `Shulker.getProgressDeltaAabb` at scale 1: the shell's box from `pos`
/// (the bottom centre), grown `from`..`to` along `direction`.
fn progress_aabb(direction: u8, from: f64, to: f64, pos: Vec3) -> Aabb {
let s = face_step(direction).map(f64::from);
let (max, min) = (from.max(to), from.min(to));
Aabb::new(Vec3::new(-0.5, 0.0, -0.5), Vec3::new(0.5, 1.0, 0.5))
.expand_towards(Vec3::new(s[0] * max, s[1] * max, s[2] * max))
.contract(Vec3::new(
-s[0] * (1.0 + min),
-s[1] * (1.0 + min),
-s[2] * (1.0 + min),
))
.move_by(pos)
}
/// `Level.noCollision(shulker, aabb)`: no block collision under the empty
/// context, and none of the entities a shulker collides with overlaps
/// (`canBeCollidedWith`: living shulkers and adult happy ghasts; players
/// and other mobs never block it).
fn no_collision(m: &Mob, w: &MobWorld, bounds: Aabb) -> bool {
let mut scene = crate::collisions::SceneCollisions {
world: w.world,
binding: w.collision,
subject: None,
};
if !crate::collisions::no_collision_empty(&mut scene, bounds) {
return false;
}
let grown = bounds.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!w.others_near(grown).any(|s| {
s.id != m.id
&& s.alive
&& (s.species == Species::Shulker || (s.species == Species::HappyGhast && !s.baby))
&& s.bounds.intersects(grown)
})
}
/// `Shulker.canStayAt`: an empty spot against a sturdy face, with room
/// for the fully opened shell (`canStayAt`).
fn can_stay_at(m: &Mob, w: &MobWorld, p: [i32; 3], face: u8) -> bool {
// isPositionBlocked: anything but air blocks, except a moving piston
// being driven through the shulker's own cell.
if !w.facts.is_air(w.world.block(p[0], p[1], p[2]))
&& (p != block_pos(m) || w.block_name(p[0], p[1], p[2]) != "minecraft:moving_piston")
{
return false;
}
// loadedAndEntityCanStandOnFace: the supporting block's face toward
// the shell is sturdy.
let s = face_step(face);
let n = w.world.block(p[0] + s[0], p[1] + s[1], p[2] + s[2]);
w.facts.get(n).full_collision
let support = w.world.block(p[0] + s[0], p[1] + s[1], p[2] + s[2]);
let open = opposite(face);
if w.facts.get(support).sturdy_full & (1 << open) == 0 {
return false;
}
// The fully opened shell's box, shrunk by the skin vanilla uses.
let opened = progress_aabb(
open,
-1.0,
1.0,
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
),
)
.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6));
no_collision(m, w, opened)
}
/// `findAttachableSurface`: the first face (down, up, north, south, west,
/// east) to cling to.
fn attachable(w: &MobWorld, p: [i32; 3]) -> Option<u8> {
(0..6).find(|&f| can_stay_at(w, p, f))
fn attachable(m: &Mob, w: &MobWorld, p: [i32; 3]) -> Option<u8> {
(0..6).find(|&f| can_stay_at(m, w, p, f))
}
/// `BlockUtil.packDifferenceInPosition` within 8 blocks of `pos`.
fn pack_diff(pos: [i32; 3], to: [i32; 3]) -> i32 {
((to[0] - pos[0] + 8) & 0xFF) << 16
| ((to[1] - pos[1] + 8) & 0xFF) << 8
| (to[2] - pos[2] + 8) & 0xFF
}
/// `Shulker.teleportSomewhere`: up to five tries within 8 blocks.
fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
@@ -87,12 +162,31 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
for _ in 0..5 {
let r = |m: &mut Mob| m.random.next_int_bound(17).unwrap_or(8) - 8;
let t = [old[0] + r(m), old[1] + r(m), old[2] + r(m)];
if t[1] <= crate::chunk::MIN_Y || !w.facts.is_air(w.world.block(t[0], t[1], t[2])) {
let unit = Aabb::new(
Vec3::new(f64::from(t[0]), f64::from(t[1]), f64::from(t[2])),
Vec3::new(
f64::from(t[0]) + 1.0,
f64::from(t[1]) + 1.0,
f64::from(t[2]) + 1.0,
),
);
if t[1] <= crate::chunk::MIN_Y
|| !w.facts.is_air(w.world.block(t[0], t[1], t[2]))
|| !no_collision(m, w, unit.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6)))
{
continue;
}
let Some(face) = attachable(w, t) else {
let Some(face) = attachable(m, w, t) else {
continue;
};
// `#minecraft:shulker_does_not_teleport_to` (bedrock) is never clung to.
let s = face_step(face);
if w.has_tag(
"minecraft:shulker_does_not_teleport_to",
w.block_name(t[0] + s[0], t[1] + s[1], t[2] + s[2]),
) {
continue;
}
m.extra.variant3 = face;
events.push(MobEvent::Sound(
"entity.shulker.teleport",
@@ -106,6 +200,14 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) -> bool {
f64::from(t[2]) + 0.5,
);
m.previous = m.position;
// Level.levelEvent(2016, oldPos, packedDiff): the teleport's
// particles at both ends, seen by the players around.
events.push(MobEvent::LevelEvent {
id: 2016,
pos: old,
data: pack_diff(old, t),
});
m.needs_sync = true;
m.extra.counter = 0;
m.ai.target = None;
return true;
@@ -127,31 +229,102 @@ fn set_peek(m: &mut Mob, amount: i32, events: &mut Vec<MobEvent>) {
));
m.extra.counter = amount;
}
/// `Shulker.tick` additions: keep a face (or teleport), ease the peek,
/// stay still and square on its block.
/// `Shulker.updatePeekAmount`/`updatePeekAmountO`: the eased peek.
fn ease_peek(m: &mut Mob) -> (f32, f32) {
let old = m.anim.v[PEEK];
let target = m.extra.counter as f32 * 0.01;
let new = if old > target {
(old - 0.05).clamp(target, 1.0)
} else {
(old + 0.05).clamp(0.0, target)
};
m.anim.v[PEEK] = new;
(old, new)
}
/// `Shulker.tick` additions: keep a face (or teleport), ease the peek
/// (pushing whatever it opens into), stay still and square on its block.
pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.velocity = Vec3::ZERO;
m.body_yaw = 0.0;
m.yaw = 0.0;
m.head_pitch = 0.0;
let p = block_pos(m);
if !can_stay_at(w, p, m.extra.variant3) {
match attachable(w, p) {
if !can_stay_at(m, w, p, m.extra.variant3) {
match attachable(m, w, p) {
Some(f) => m.extra.variant3 = f,
None => {
teleport(m, w, events);
}
}
}
let a = &mut m.anim;
let target = m.extra.counter as f32 * 0.01;
let cur = a.v[PEEK];
if cur > target {
a.v[PEEK] = (cur - 0.05).clamp(target, 1.0);
} else if cur < target {
a.v[PEEK] = (cur + 0.05).clamp(0.0, target);
let (old, new) = ease_peek(m);
if new != old {
on_peek_change(m, w, old, new, events);
}
}
/// `Shulker.getPhysicalPeek`: the lid's lift for a peek amount.
fn physical_peek(amount: f32) -> f64 {
f64::from(0.5 - ((0.5 + amount) * PI).msin() * 0.5)
}
/// `Shulker.onPeekAmountChange`: an opening shell pushes out of its way
/// every entity that can be collided with (except other shulkers).
fn on_peek_change(m: &Mob, w: &MobWorld, old: f32, new: f32, events: &mut Vec<MobEvent>) {
let dir = opposite(m.extra.variant3);
let s = face_step(dir).map(f64::from);
let (old_p, new_p) = (physical_peek(old), physical_peek(new));
let push = new_p - old_p;
if push <= 0.0 {
return;
}
let delta = [push * s[0], push * s[1], push * s[2]];
let area = progress_aabb(dir, old_p, new_p, m.position);
if let Some(p) = &w.player
&& p.alive
&& !p.spectator
&& p.bounds.intersects(area)
{
events.push(MobEvent::Shift {
target: Who::Player,
delta,
});
}
for seen in w.others_near(area) {
if seen.id == m.id
|| !seen.alive
|| seen.species == Species::Shulker
|| !seen.bounds.intersects(area)
{
continue;
}
events.push(MobEvent::Shift {
target: Who::Mob(seen.id),
delta,
});
}
}
/// `ShulkerLookControl.getYRotD`: the look yaw in the open direction's
/// plane (`None`: the target is on the head's own axis).
pub fn look_yaw(m: &Mob, at: [f64; 3]) -> Option<f32> {
let open = opposite(m.extra.variant3);
// `attachFace.getRotation().transform(FORWARD)`: the model's forward
// rotated as the renderer does (`rotations` uses the same quats).
let forward = face_quat(m.extra.variant3) * glam::Vec3::Z;
let s = face_step(open).map(|v| v as f32);
let right = glam::Vec3::from_array(s).cross(forward);
let out = glam::Vec3::new(
(at[0] - m.position.x) as f32,
(at[1] - m.eye_y()) as f32,
(at[2] - m.position.z) as f32,
);
let (delta_right, delta_forward) = (right.dot(out), forward.dot(out));
if delta_right.abs() <= 1.0e-5 && delta_forward.abs() <= 1.0e-5 {
return None;
}
Some(horizon_core::geometry::trig::atan2_degrees(
f64::from(-delta_right),
f64::from(delta_forward),
))
}
/// `Shulker.hurtServer` before the damage: arrows bounce off a closed shell.
pub fn accepts(m: &Mob, source: &Damage) -> bool {
!(m.extra.counter == 0 && source.kind == DamageKind::Arrow)
@@ -319,7 +492,7 @@ impl Goal for Peek {
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.ai.target.is_none()
&& m.random.next_int_bound(reduced(40)).unwrap_or(1) == 0
&& can_stay_at(cx.w, block_pos(m), m.extra.variant3)
&& can_stay_at(m, cx.w, block_pos(m), m.extra.variant3)
}
fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
m.ai.target.is_none() && self.time > 0
@@ -362,24 +535,27 @@ pub fn texture(m: &Mob) -> String {
None => "entity/shulker/shulker".into(),
}
}
/// `attachFace.getOpposite().getRotation()` by attach face: the open
/// direction's rotation (`Direction.getRotation` of `face ^ 1`).
fn face_quat(face: u8) -> glam::Quat {
match face.min(5) {
// Attached below: upright.
0 => glam::Quat::IDENTITY,
1 => glam::Quat::from_rotation_x(PI),
2 => glam::Quat::from_rotation_x(PI / 2.0),
3 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI),
4 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, -PI / 2.0),
_ => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI / 2.0),
}
}
/// `ShulkerRenderer.setupRotations`: turned onto its attach face around the
/// block's centre (after a half turn of the body).
pub fn rotations(m: &Mob) -> glam::Mat4 {
{
let q = match m.extra.variant3 {
// Attached below: upright.
0 => glam::Quat::IDENTITY,
1 => glam::Quat::from_rotation_x(PI),
2 => glam::Quat::from_rotation_x(PI / 2.0),
3 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI),
4 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, -PI / 2.0),
_ => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI / 2.0),
};
glam::Mat4::from_rotation_y(PI)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.5, 0.0))
* glam::Mat4::from_quat(q)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.5, 0.0))
}
let q = face_quat(m.extra.variant3);
glam::Mat4::from_rotation_y(PI)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.5, 0.0))
* glam::Mat4::from_quat(q)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.5, 0.0))
}
/// `ShulkerModel.setupAnim`: the lid rises and twists with the peek, the
/// head turns.
+192 -2
View File
@@ -1,12 +1,17 @@
//! Turtles (`Turtle`): the egg a bred mother carries (`TurtleBreedGoal`), her
//! walk home (`TurtleGoHomeGoal`) and the egg she digs into the sand
//! (`TurtleLayEggGoal`). `extra.flag` is `hasEgg`, `extra.flag2` `isLayingEgg`,
//! `extra.counter` `layEggCounter` and `extra.home` `homePos`.
//! `extra.counter` `layEggCounter` and `extra.home` `homePos`. Its own
//! `TurtleMoveControl` sets the speed it walks and swims at. Not ported yet:
//! `Turtle.travelInWater` (swimming still uses the generic fluid travel),
//! `TurtlePathNavigation` and the rest of the turtle goal set, so movement in
//! water still differs from vanilla.
use super::{
Mob, MobEvent, MobWorld,
control::{EPSILON, Operation, rotlerp},
goals::{Breed, Cx, Goal, JUMP, MOVE, MoveToBlock, goal_clone, reduced},
};
use horizon_core::random::RandomSource;
use horizon_core::{geometry::trig, random::RandomSource};
pub fn register(m: &mut Mob) {
let g = &mut m.ai.goals;
@@ -49,6 +54,45 @@ fn block_of(m: &Mob) -> [i32; 3] {
fn is_sand(w: &MobWorld, p: [i32; 3]) -> bool {
w.has_tag("minecraft:sand", w.block_name(p[0], p[1], p[2]))
}
/// `Turtle.TurtleMoveControl.updateSpeed`: slow in water away from home
/// (slower still as a baby), and halved every tick on land, never under 0.06.
fn update_speed(m: &mut Mob) {
if m.fluids.in_water {
m.velocity.y += 0.005;
if !close_to_home(m, 16.0) {
m.set_speed((m.ai.speed / 2.0).max(0.08));
}
if m.is_baby() {
m.set_speed((m.ai.speed / 3.0).max(0.06));
}
} else if m.on_ground {
m.set_speed((m.ai.speed / 2.0).max(0.06));
}
}
/// `Turtle.TurtleMoveControl.tick`: ease the speed an eighth of the way to
/// the wanted one each tick (after `updateSpeed` cut it), face the wanted
/// point and lift towards it; no jumping, and the operation is never reset.
pub fn move_control(m: &mut Mob) {
update_speed(m);
if m.ai.mov.operation != Operation::MoveTo || m.ai.nav.is_done() {
m.set_speed(0.0);
return;
}
let [wx, wy, wz] = m.ai.mov.wanted;
let (xd, yd, zd) = (wx - m.position.x, wy - m.position.y, wz - m.position.z);
let dist = (xd * xd + yd * yd + zd * zd).sqrt();
if dist < EPSILON {
m.set_speed(0.0);
return;
}
let y_rot = trig::atan2_degrees(zd, xd) - 90.0;
m.yaw = rotlerp(m.yaw, y_rot, 90.0);
m.body_yaw = m.yaw;
let wanted = (m.ai.mov.speed_modifier * m.movement_speed()) as f32;
// Mth.lerp(0.125F, getSpeed(), wanted).
m.set_speed(m.ai.speed + 0.125 * (wanted - m.ai.speed));
m.velocity.y += f64::from(m.ai.speed) * (yd / dist) * 0.1;
}
/// `Turtle.aiStep`: while digging, sand crumbs fly every fifth tick.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.alive() && m.extra.flag2 && m.extra.counter >= 1 && m.extra.counter % 5 == 0 {
@@ -207,3 +251,149 @@ impl Goal for GoHome {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::{
Species,
path::{Path, PathNode, PathType},
};
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// A turtle (movement speed 0.25) strolling (`speedModifier` 1.0) to a
/// point ten blocks ahead along a path it has not finished.
fn strolling_turtle() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:turtle".into();
k.species = Species::Turtle;
k.speed = 0.25;
k.step_height = 1.0;
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
m.on_ground = true;
let node = PathNode {
x: 0,
y: 0,
z: 10,
kind: PathType::Walkable,
};
m.ai.nav.path = Some(Path {
nodes: vec![node],
next: 0,
target: [0, 0, 10],
dist_to_target: 0.0,
reached: true,
});
m.ai.mov.set_wanted(0.0, 0.0, 10.0, 1.0);
m
}
fn speeds(m: &mut Mob, ticks: usize) -> Vec<f32> {
(0..ticks)
.map(|_| {
move_control(m);
assert_eq!(m.ai.zza, m.ai.speed, "setSpeed sets the forward input");
m.ai.speed
})
.collect()
}
#[test]
fn walks_on_land_at_a_third_of_its_movement_speed() {
// TurtleMoveControl.tick on the ground: updateSpeed makes it
// max(speed / 2, 0.06) = 0.06, then Mth.lerp(0.125F, 0.06, 0.25),
// every tick. The default MoveControl walked it at the full 0.25, so
// the walk animation (and TurtleModel's 8 * walkSpeed leg swing) ran
// about nine times too fast (#1776).
let mut m = strolling_turtle();
let land = 0.06_f32 + 0.125 * (0.25 - 0.06_f32);
assert_eq!(speeds(&mut m, 40), vec![land; 40]);
// Facing the wanted point, the body with it; it never jumps.
assert_eq!(m.body_yaw, m.yaw);
assert!(crate::mobs::living::wrap(m.yaw).abs() < 1.0e-3);
assert!(!m.ai.jump);
assert_eq!(m.ai.mov.operation, Operation::MoveTo);
assert_eq!(m.velocity.y, 0.0);
}
#[test]
fn water_speed_is_cut_away_from_home_and_for_babies() {
// updateSpeed in water near home: no cut, the lerp climbs towards
// 0.25, and it lifts the turtle 0.005 a tick. (Only the control's
// speed: Turtle.travelInWater is not ported.)
let mut m = strolling_turtle();
m.on_ground = false;
m.fluids.in_water = true;
let s = speeds(&mut m, 60);
assert_eq!(s[0], 0.125 * 0.25);
assert!(s.windows(2).all(|w| w[1] > w[0]) && s[59] > 0.24 && s[59] < 0.25);
assert!((m.velocity.y - 60.0 * 0.005).abs() < 1.0e-9);
// More than 16 blocks from home: max(speed / 2, 0.08) first.
let mut m = strolling_turtle();
m.on_ground = false;
m.fluids.in_water = true;
m.extra.home = Some([0, 0, 40]);
let far = 0.08_f32 + 0.125 * (0.25 - 0.08_f32);
assert_eq!(speeds(&mut m, 20), vec![far; 20]);
// A baby: max(speed / 3, 0.06).
let mut m = strolling_turtle();
m.on_ground = false;
m.fluids.in_water = true;
m.extra.age = -24000;
let young = 0.06_f32 + 0.125 * (0.25 - 0.06_f32);
assert_eq!(speeds(&mut m, 20), vec![young; 20]);
}
#[test]
fn stops_when_the_path_is_done_or_the_point_reached() {
let mut m = strolling_turtle();
m.ai.nav.stop();
assert_eq!(speeds(&mut m, 1), vec![0.0]);
let mut m = strolling_turtle();
m.ai.mov.set_wanted(0.0, 0.0, 0.0, 1.0);
assert_eq!(speeds(&mut m, 1), vec![0.0]);
// updateSpeed runs before the early return: a stopped turtle in
// water is still lifted 0.005, path done or not moving at all.
let mut m = strolling_turtle();
m.fluids.in_water = true;
m.ai.nav.stop();
assert_eq!(speeds(&mut m, 1), vec![0.0]);
assert_eq!(m.velocity.y, 0.005);
let mut m = strolling_turtle();
m.fluids.in_water = true;
m.ai.mov.operation = Operation::Wait;
assert_eq!(speeds(&mut m, 1), vec![0.0]);
assert_eq!(m.velocity.y, 0.005);
}
#[test]
fn lifts_towards_a_point_above_by_the_slope_of_the_way_there() {
// setDeltaMovement(... + getSpeed() * (dy / dist) * 0.1): two up,
// three across and ten ahead.
let mut m = strolling_turtle();
m.ai.mov.set_wanted(3.0, 2.0, 10.0, 1.0);
let speed = speeds(&mut m, 1)[0];
let dist = (3.0_f64 * 3.0 + 2.0 * 2.0 + 10.0 * 10.0).sqrt();
assert_eq!(m.velocity.y, f64::from(speed) * (2.0 / dist) * 0.1);
assert!(m.velocity.y > 0.0);
}
#[test]
fn turns_at_most_ninety_degrees_a_tick_and_wraps_into_a_turn() {
// rotlerp(yRot, target, 90): from 150 towards about -16.7 it turns
// the full 90 to 60, then reaches the target, kept in [0, 360] as
// about 343.3; the body follows the yaw each tick.
let mut m = strolling_turtle();
m.ai.mov.set_wanted(3.0, 0.0, 10.0, 1.0);
m.yaw = 150.0;
m.body_yaw = 150.0;
let target = trig::atan2_degrees(10.0, 3.0) - 90.0;
assert!((-17.0..-16.0).contains(&target));
move_control(&mut m);
assert_eq!((m.yaw, m.body_yaw), (60.0, 60.0));
move_control(&mut m);
assert_eq!(m.yaw, 60.0 + (target - 60.0) + 360.0);
assert_eq!(m.body_yaw, m.yaw);
}
}
@@ -114,6 +114,7 @@ fn ruby_mod(dir: &Path) -> ContentMod {
/// new blocks behave like their templates with the overrides, and vanilla's
/// states are where they were.
#[test]
#[ignore = "full content root: CI and pr_ready run it"]
fn content_root_loads_with_every_table() {
let Some(base) = prepared() else {
return;
@@ -352,6 +353,7 @@ fn no_content_keeps_the_base_root() {
use horizon_world::registry::BlockStateId;
#[test]
#[ignore = "full content root: CI and pr_ready run it"]
fn shared_behaviour_document_matches_independent_loaders() {
let Some(root) = prepared() else { return };
let assets = Assets::load(&root).expect("assets");
@@ -414,6 +416,7 @@ fn shared_behaviour_document_matches_independent_loaders() {
#[cfg(unix)]
#[test]
#[ignore = "full content root: CI and pr_ready run it"]
fn independent_reloads_read_changed_behaviour_json_and_report_invalid_files() {
let Some(base) = prepared() else { return };
let scratch = std::env::temp_dir().join(format!("horizon-json-reloads-{}", std::process::id()));
@@ -535,6 +538,7 @@ fn independent_reloads_read_changed_behaviour_json_and_report_invalid_files() {
/// A built root is kept for later starts, and built again when a mod's
/// files change.
#[test]
#[ignore = "full content root: CI and pr_ready run it"]
fn content_root_is_reused_until_a_mod_changes() {
let Some(base) = prepared() else {
return;
@@ -571,6 +575,7 @@ fn content_root_is_reused_until_a_mod_changes() {
/// set, pool and templates join the bundles, and its biome tag keeps only
/// what the loader knows.
#[test]
#[ignore = "full content root: CI and pr_ready run it"]
fn content_root_carries_a_structure_mod() {
let Some(base) = prepared() else {
return;
+9
View File
@@ -68,6 +68,9 @@ pub enum Event {
host: String,
port: i32,
},
/// `ClientboundServerLinksPacket`: the links the pause menu's "Server
/// Links..." lists (ClientCommonPacketListenerImpl.handleServerLinks).
ServerLinks(Vec<cfg::ServerLink>),
/// The connection ended. `from_server`: the server gave the reason (or
/// the connection was refused for good), rather than the connection
/// failing (auto-reconnect retries only failures and shutdowns).
@@ -565,6 +568,9 @@ fn receive_play(
play::Clientbound::StoreCookie(p) => {
cookies.lock().unwrap().insert(p.key, p.payload);
}
play::Clientbound::ServerLinks(p) => {
let _ = send.send(Event::ServerLinks(p.links));
}
// ClientCommonPacketListenerImpl.handleTransfer: the connection
// ends and the game connects to the other address.
play::Clientbound::Transfer(p) => {
@@ -644,6 +650,9 @@ fn configure(
cfg::Clientbound::StoreCookie(p) => {
cookies.lock().unwrap().insert(p.key, p.payload);
}
cfg::Clientbound::ServerLinks(p) => {
let _ = send.send(Event::ServerLinks(p.links));
}
cfg::Clientbound::Transfer(p) => {
let _ = send.send(Event::Transfer {
host: p.host,
+5
View File
@@ -56,6 +56,7 @@ pub enum Opt {
ChatVisibility,
ChatColors,
ChatLinks,
ChatLinksPrompt,
ChatOpacity,
TextBackgroundOpacity,
ChatScale,
@@ -243,6 +244,7 @@ impl Opt {
Opt::ChatVisibility => "options.chat.visibility",
Opt::ChatColors => "options.chat.color",
Opt::ChatLinks => "options.chat.links",
Opt::ChatLinksPrompt => "options.chat.links.prompt",
Opt::ChatOpacity => "options.chat.opacity",
Opt::TextBackgroundOpacity => "options.accessibility.text_background_opacity",
Opt::ChatScale => "options.chat.scale",
@@ -581,6 +583,7 @@ impl Opt {
][usize::from(s.chat_visibility.min(2))]),
Opt::ChatColors => on_off(s.chat_colors),
Opt::ChatLinks => on_off(s.chat_links),
Opt::ChatLinksPrompt => on_off(s.chat_links_prompt),
Opt::AutoSuggestions => on_off(s.auto_suggestions),
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
Opt::HighContrast => on_off(s.high_contrast),
@@ -755,6 +758,7 @@ impl Opt {
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
Opt::ChatColors => s.chat_colors = !s.chat_colors,
Opt::ChatLinks => s.chat_links = !s.chat_links,
Opt::ChatLinksPrompt => s.chat_links_prompt = !s.chat_links_prompt,
Opt::AutoSuggestions => s.auto_suggestions = !s.auto_suggestions,
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
Opt::HighContrast => s.high_contrast = !s.high_contrast,
@@ -1507,6 +1511,7 @@ impl OptionsUi {
Opt::ChatVisibility,
Opt::ChatColors,
Opt::ChatLinks,
Opt::ChatLinksPrompt,
Opt::ChatOpacity,
Opt::TextBackgroundOpacity,
Opt::ChatScale,
+3
View File
@@ -3820,6 +3820,9 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
fn is_decorated(&self, proto: &crate::streaming::Proto) -> bool {
self.inner.is_decorated(proto)
}
fn light_view(&self, proto: &crate::streaming::Proto) -> Option<Chunk> {
self.inner.light_view(proto)
}
fn save_protos(&self, protos: &[&crate::streaming::Proto]) {
self.save
.tables
+4
View File
@@ -145,6 +145,7 @@ pub struct Settings {
pub chat_visibility: u8,
pub chat_colors: bool,
pub chat_links: bool,
pub chat_links_prompt: bool,
pub chat_opacity: f32,
pub text_background_opacity: f32,
pub chat_scale: f32,
@@ -230,6 +231,7 @@ impl Default for Settings {
chat_visibility: 0,
chat_colors: true,
chat_links: true,
chat_links_prompt: true,
chat_opacity: 1.0,
text_background_opacity: 0.5,
chat_scale: 1.0,
@@ -423,6 +425,7 @@ impl Settings {
chat_visibility: f.int("chatVisibility") as u8,
chat_colors: f.bool("chatColors"),
chat_links: f.bool("chatLinks"),
chat_links_prompt: f.bool("chatLinksPrompt"),
chat_opacity: f.double("chatOpacity") as f32,
text_background_opacity: f.double("textBackgroundOpacity") as f32,
chat_scale: f.double("chatScale") as f32,
@@ -506,6 +509,7 @@ impl Settings {
put_i(&mut f, "chatVisibility", i32::from(self.chat_visibility));
put_b(&mut f, "chatColors", self.chat_colors);
put_b(&mut f, "chatLinks", self.chat_links);
put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt);
put_f(&mut f, "chatOpacity", self.chat_opacity);
put_f(
&mut f,
+3 -2
View File
@@ -52,8 +52,9 @@ pub fn thread_cpu() -> Option<Duration> {
Some(Duration::new(t.tv_sec as u64, t.tv_nsec as u32))
}
#[cfg(not(unix))]
None
horizon_threads::thread_cpu()
}
/// The frame's spans, summed by name, slowest first; clears them.
pub fn take() -> Vec<Span> {
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
@@ -135,7 +136,7 @@ mod tests {
/// The thread's CPU clock moves with work done on it, not with time
/// spent asleep.
#[cfg(unix)]
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
#[test]
fn thread_cpu_counts_work_not_sleep() {
let start = super::thread_cpu().unwrap();
+38 -2
View File
@@ -70,6 +70,11 @@ pub trait ChunkGenerator: Send + Sync + 'static {
false
}
/// What to save of a proto chunk dropped before it became final.
/// A decorated proto chunk's blocks as a column, to light a finished
/// neighbour against (vanilla lights with neighbours at FEATURES).
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
None
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::save::SavedProto> {
None
}
@@ -212,6 +217,7 @@ struct Shared {
}
pub struct Streaming {
shared: Arc<Shared>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
results: mpsc::Receiver<Done>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
@@ -539,8 +545,13 @@ impl Streaming {
.expect("spawn worker")
})
.collect();
let light_view = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
Self {
shared,
light_view,
results,
threads,
order: 0,
@@ -779,6 +790,26 @@ impl Streaming {
fn distance(&self, x: i32, z: i32) -> i64 {
self.ranges().priority(x, z, self.loading)
}
/// The 3x3 a light job reads: the finished columns, and for a neighbour
/// that will not finish (past the area), its decorated proto chunk, as
/// vanilla lights its last FULL ring against neighbours at FEATURES.
fn light_region(&self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
for dx in 0..3 {
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
region[dz][dx] = Some(match world.chunks.get(&n) {
Some(chunk) => chunk.clone(),
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
let proto = self.protos.get(&n)?.as_ref()?;
Arc::new((self.light_view)(proto)?)
}
None => return None,
});
}
}
Some(Box::new(region))
}
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
@@ -900,6 +931,11 @@ impl Streaming {
} else {
apply_relighting(chunk, sections, &mut changed);
}
// Saved unlit (at the edge of what was loaded), or its light
// changed: saved again.
if (first && !chunk.light_loaded) || !changed.is_empty() {
world.modified.insert((x, z));
}
chunk.lit = true;
if first {
for dz in -1..=1 {
@@ -1136,7 +1172,7 @@ impl Streaming {
world.dirty_light.remove(&(x, z));
continue;
}
let Some(region) = Self::region(world, x, z) else {
let Some(region) = self.light_region(world, x, z) else {
continue;
};
world.dirty_light.remove(&(x, z));
@@ -1468,7 +1504,7 @@ impl Streaming {
counts[1] += 1;
} else {
counts[2] += 1;
if Self::region(world, key.0, key.1).is_some() {
if self.light_region(world, key.0, key.1).is_some() {
world.dirty_light.insert(key);
}
}
+6
View File
@@ -505,6 +505,12 @@ impl ChunkGenerator for VanillaTerrain {
fn is_decorated(&self, proto: &Proto) -> bool {
matches!(proto.downcast_ref::<VanillaProto>(), Some(VanillaProto::Ready(c)) if c.is_decorated())
}
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
match proto.downcast_ref::<VanillaProto>() {
Some(VanillaProto::Ready(chunk)) => Some(self.column(chunk)),
_ => None,
}
}
fn saved_proto(&self, proto: &Proto) -> Option<SavedProto> {
let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::<VanillaProto>() else {
return None;
+69
View File
@@ -279,6 +279,75 @@ fn run<T: Send + 'static>(
handle.join().unwrap().unwrap()
}
#[test]
fn recipe_settings_packets_update_only_the_players_book_and_survive_save() {
let Some(resources) = resources() else {
return;
};
let mut s = server(&resources);
let port = s.hub.lan.port;
let _bot = run(&mut s, move || connect(port, "RecipeSettings", true));
let deadline = Instant::now() + Duration::from_secs(5);
while s.hub.guests.is_empty() {
s.tick(&[]);
assert!(Instant::now() < deadline, "guest did not join");
thread::yield_now();
}
for book in 0..4 {
for flags in 0..4 {
let open = flags & 1 != 0;
let filtering = flags & 2 != 0;
let before = s.hub.guests[0].recipe_book.clone();
// Vanilla's wire payload: RecipeBookType VarInt, open, filtering.
let packet = S::decode(
horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS,
&[book, u8::from(open), u8::from(filtering)],
)
.unwrap();
s.hub.guests[0].inbox.push_back(packet);
s.tick(&[]);
let settings = &s.hub.guests[0].recipe_book;
for i in 0..4 {
assert_eq!(
settings.open[i],
if i == usize::from(book) {
open
} else {
before.open[i]
}
);
assert_eq!(
settings.filtering[i],
if i == usize::from(book) {
filtering
} else {
before.filtering[i]
}
);
}
let mut saved = horizon_client::save::Level::default();
horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved);
let mut reopened = horizon_client::save::Level::default();
reopened.read_player(&saved.player_tag());
assert_eq!(reopened.recipe_book.open, settings.open);
assert_eq!(reopened.recipe_book.filtering, settings.filtering);
}
}
for book_type in [-1, 4] {
let mut wire = horizon_protocol::buf::Writer::new();
wire.varint(book_type);
wire.bool(true);
wire.bool(true);
assert!(
S::decode(
horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS,
&wire.data,
)
.is_err()
);
}
}
#[test]
fn sprint_commands_survive_keyboard_input_changes() {
let Some(resources) = resources() else {
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "horizon-mod-lua"
version = "0.1.0"
edition.workspace = true
license.workspace = true
description = "Write Horizon mods in Lua: a Lua interpreter (piccolo) running inside the mod's WebAssembly sandbox"
# One feature per capability, as the mod's horizon-mod.toml lists them: the
# game refuses a mod whose component imports an interface it didn't declare,
# so only the `horizon.<interface>` tables a feature names are compiled in.
[features]
game-read = []
chat = []
hud = []
storage = []
[dependencies]
horizon-mod-sdk = { path = "../horizon-mod-sdk" }
# Pure Rust, so it builds for wasm32-unknown-unknown (C Lua needs setjmp). MIT.
piccolo = "0.3.3"
# piccolo's hash tables (ahash) and math.random (rand) ask getrandom for
# seeds; a mod has no entropy source, so they get a fixed sequence (below).
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom02 = { package = "getrandom", version = "0.2", features = ["custom"] }
getrandom03 = { package = "getrandom", version = "0.3" }
[lints]
workspace = true
+574
View File
@@ -0,0 +1,574 @@
//! Horizon mods in Lua (`docs/mods.md`, "Lua"): a Lua 5.4 interpreter
//! ([piccolo](https://github.com/kyren/piccolo), pure Rust) compiled into the
//! mod's WebAssembly component with the mod's script, so a Lua mod runs in
//! the same sandbox, with the same capabilities and time budgets, as a Rust
//! one. The mod's crate is one line:
//!
//! ```ignore
//! horizon_mod_lua::lua_mod!("main.lua");
//! ```
//!
//! The script registers its commands with `horizon.command(name)` and
//! defines the hooks it wants as globals: `on_tick(tick)`,
//! `on_frame(seconds)`, `on_command(name, args)` (a string returned is the
//! feedback, `error(text)` the red error) and `on_chat(text)`. The
//! interfaces are tables of `horizon`: `log` always, and `game`, `chat`,
//! `hud`, `storage` with this crate's feature of the same capability
//! (`game-read` for `game`), as the mod's `horizon-mod.toml` lists them.
#[cfg(feature = "chat")]
use horizon_mod_sdk::chat;
#[cfg(feature = "game-read")]
use horizon_mod_sdk::game;
#[cfg(feature = "hud")]
use horizon_mod_sdk::hud;
#[cfg(feature = "storage")]
use horizon_mod_sdk::storage;
use horizon_mod_sdk::{Registration, log};
use piccolo::{
Callback, CallbackReturn, Closure, Context, Error, Executor, Lua, StashedExecutor, StaticError,
Table, Value, Variadic,
};
use std::cell::RefCell;
mod stdlib;
/// Seeds for piccolo's hash tables and `math.random`: a mod's sandbox has
/// no entropy source, so they come from a fixed sequence (SplitMix64).
/// `getrandom` 0.3 takes it with `--cfg getrandom_backend="custom"`, which
/// `cargo horizon-mod build` passes.
#[cfg(target_arch = "wasm32")]
mod entropy {
use std::cell::Cell;
std::thread_local! {
static STATE: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
}
fn fill(dest: &mut [u8]) {
for chunk in dest.chunks_mut(8) {
let z = STATE.with(|s| {
let z = s.get().wrapping_add(0x9E37_79B9_7F4A_7C15);
s.set(z);
z
});
let mut z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
chunk.copy_from_slice(&z.to_le_bytes()[..chunk.len()]);
}
}
fn fill02(dest: &mut [u8]) -> Result<(), getrandom02::Error> {
fill(dest);
Ok(())
}
getrandom02::register_custom_getrandom!(fill02);
/// getrandom 0.3's custom backend.
///
/// # Safety
/// `dest` points to `len` writable bytes (getrandom's contract).
#[unsafe(no_mangle)]
unsafe extern "Rust" fn __getrandom_v03_custom(
dest: *mut u8,
len: usize,
) -> Result<(), getrandom03::Error> {
// SAFETY: getrandom passes a buffer of `len` bytes it owns.
fill(unsafe { std::slice::from_raw_parts_mut(dest, len) });
Ok(())
}
}
#[doc(hidden)]
pub use horizon_mod_sdk as __sdk;
std::thread_local! {
/// What `horizon.command` registered while the script's chunk ran.
static COMMANDS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
/// A hook's argument.
pub enum Arg {
Integer(i64),
Number(f64),
Text(String),
}
/// A running Lua mod: the interpreter with the script loaded.
pub struct LuaMod {
lua: Lua,
/// One executor, restarted for each call (a new one per call costs a
/// thread's allocation).
executor: StashedExecutor,
}
/// A Lua error as the player reads it: the message alone for `error(text)`.
fn message(e: StaticError) -> String {
match e {
StaticError::Lua(e) => e.to_string(),
StaticError::Runtime(e) => e.to_string(),
}
}
impl LuaMod {
/// Runs the script's chunk; the hooks it defined and the commands it
/// registered make the [`Registration`].
pub fn start(name: &str, source: &str) -> Result<(Self, Registration), String> {
let mut lua = Lua::core();
lua.try_enter(|ctx| {
stdlib::install(ctx)?;
install(ctx)
})
.map_err(message)?;
COMMANDS.with(|c| c.borrow_mut().clear());
let chunk = lua
.try_enter(|ctx| {
let closure = Closure::load(ctx, Some(name), source.as_bytes())?;
Ok(ctx.stash(Executor::start(ctx, closure.into(), ())))
})
.map_err(message)?;
lua.execute::<()>(&chunk).map_err(message)?;
let mut registration = Registration::new();
lua.enter(|ctx| {
let defined = |hook: &'static str| matches!(ctx.get_global(hook), Value::Function(_));
registration.tick = defined("on_tick");
registration.frame = defined("on_frame");
registration.chat = defined("on_chat");
});
registration.commands = COMMANDS.with(|c| std::mem::take(&mut *c.borrow_mut()));
let executor = lua.enter(|ctx| ctx.stash(Executor::new(ctx)));
Ok((LuaMod { lua, executor }, registration))
}
/// Calls the global function `hook` with `args`: what it returned, as
/// text (`None` for nothing or nil), or the error it raised. A hook the
/// script didn't define returns `None`.
pub fn call(&mut self, hook: &'static str, args: Vec<Arg>) -> Result<Option<String>, String> {
let executor = &self.executor;
let found = self
.lua
.try_enter(|ctx| {
let Value::Function(f) = ctx.get_global(hook) else {
return Ok(false);
};
let args: Vec<Value> = args
.into_iter()
.map(|a| match a {
Arg::Integer(i) => Value::Integer(i),
Arg::Number(n) => Value::Number(n),
Arg::Text(s) => ctx.intern(s.as_bytes()).into(),
})
.collect();
ctx.fetch(executor).restart(ctx, f, Variadic(args));
Ok(true)
})
.map_err(message)?;
if !found {
return Ok(None);
}
self.lua.finish(executor);
self.lua
.try_enter(|ctx| {
let value = ctx.fetch(executor).take_result::<Value>(ctx)??;
Ok(match value {
Value::Nil => None,
Value::String(s) => Some(s.to_str_lossy().into_owned()),
other => Some(other.to_string()),
})
})
.map_err(message)
}
/// A hook that returns nothing: an error goes to the mod's log.
pub fn run(&mut self, hook: &'static str, args: Vec<Arg>) {
if let Err(e) = self.call(hook, args) {
log::write(log::Level::Error, &format!("{hook}: {e}"));
}
}
}
/// `fn(args) -> results` as a Lua function.
fn function<'gc>(
ctx: Context<'gc>,
f: impl Fn(Context<'gc>, Vec<Value<'gc>>) -> Result<Vec<Value<'gc>>, Error<'gc>> + 'static,
) -> Callback<'gc> {
Callback::from_fn(&ctx, move |ctx, _, mut stack| {
let args: Vec<Value> = stack.drain(..).collect();
let out = f(ctx, args)?;
stack.extend(out);
Ok(CallbackReturn::Return)
})
}
fn text<'gc>(ctx: Context<'gc>, s: &str) -> Value<'gc> {
ctx.intern(s.as_bytes()).into()
}
/// The argument at `i` as text (numbers convert, as Lua's `tostring`).
fn arg_text(args: &[Value], i: usize, what: &str) -> Result<String, String> {
match args.get(i).copied().unwrap_or(Value::Nil) {
Value::String(s) => Ok(s.to_str_lossy().into_owned()),
Value::Integer(n) => Ok(n.to_string()),
Value::Number(n) => Ok(n.to_string()),
other => Err(format!(
"{what} must be a string, not {}",
other.type_name()
)),
}
}
fn lua_error<'gc>(ctx: Context<'gc>, message: String) -> Error<'gc> {
text(ctx, &message).into()
}
#[cfg(feature = "game-read")]
fn table<'gc>(
ctx: Context<'gc>,
fields: &[(&'static str, Value<'gc>)],
) -> Result<Table<'gc>, Error<'gc>> {
let t = Table::new(&ctx);
for &(k, v) in fields {
t.set(ctx, k, v)?;
}
Ok(t)
}
/// The `horizon` table and `print` (to the mod's log).
fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
let horizon = Table::new(&ctx);
horizon.set(
ctx,
"command",
function(ctx, |ctx, args| {
let name = arg_text(&args, 0, "the command's name").map_err(|e| lua_error(ctx, e))?;
COMMANDS.with(|c| c.borrow_mut().push(name));
Ok(vec![])
}),
)?;
let log_table = Table::new(&ctx);
for (name, level) in [
("debug", log::Level::Debug),
("info", log::Level::Info),
("warn", log::Level::Warn),
("error", log::Level::Error),
] {
log_table.set(
ctx,
name,
function(ctx, move |ctx, args| {
let message = arg_text(&args, 0, "the message").map_err(|e| lua_error(ctx, e))?;
log::write(level, &message);
Ok(vec![])
}),
)?;
}
horizon.set(ctx, "log", log_table)?;
ctx.set_global(
"print",
function(ctx, |_, args| {
let line: Vec<String> = args
.iter()
.map(|v| match v {
Value::String(s) => s.to_str_lossy().into_owned(),
other => other.to_string(),
})
.collect();
log::write(log::Level::Info, &line.join("\t"));
Ok(vec![])
}),
)?;
#[cfg(feature = "game-read")]
{
let game_table = Table::new(&ctx);
game_table.set(
ctx,
"player",
function(ctx, |ctx, _| {
let Some(p) = game::get_player() else {
return Ok(vec![Value::Nil]);
};
let t = table(
ctx,
&[
("x", Value::Number(p.x)),
("y", Value::Number(p.y)),
("z", Value::Number(p.z)),
("yaw", Value::Number(p.yaw.into())),
("pitch", Value::Number(p.pitch.into())),
("health", Value::Number(p.health.into())),
("food", Value::Integer(p.food.into())),
("dimension", text(ctx, &p.dimension)),
],
)?;
Ok(vec![t.into()])
}),
)?;
game_table.set(
ctx,
"clock",
function(ctx, |ctx, _| {
let Some(c) = game::get_clock() else {
return Ok(vec![Value::Nil]);
};
let t = table(
ctx,
&[
("game_time", Value::Integer(c.game_time as i64)),
("day_time", Value::Integer(c.day_time as i64)),
],
)?;
Ok(vec![t.into()])
}),
)?;
horizon.set(ctx, "game", game_table)?;
}
#[cfg(feature = "chat")]
{
let chat_table = Table::new(&ctx);
chat_table.set(
ctx,
"show",
function(ctx, |ctx, args| {
let line = arg_text(&args, 0, "the line").map_err(|e| lua_error(ctx, e))?;
chat::show(&line);
Ok(vec![])
}),
)?;
horizon.set(ctx, "chat", chat_table)?;
}
#[cfg(feature = "hud")]
{
let hud_table = Table::new(&ctx);
hud_table.set(
ctx,
"set_panel",
function(ctx, |ctx, args| {
let corner = match arg_text(&args, 0, "the corner").map_err(|e| lua_error(ctx, e))?.as_str() {
"top-left" => hud::Corner::TopLeft,
"top-right" => hud::Corner::TopRight,
"bottom-left" => hud::Corner::BottomLeft,
"bottom-right" => hud::Corner::BottomRight,
other => {
return Err(lua_error(
ctx,
format!("no corner {other:?}: top-left, top-right, bottom-left or bottom-right"),
));
}
};
let Some(Value::Table(rows)) = args.get(1).copied() else {
return Err(lua_error(ctx, "the lines must be a table".into()));
};
let mut lines = Vec::new();
for i in 1..=rows.length() {
let line = match rows.get(ctx, i) {
Value::Table(row) => hud::Line {
text: arg_text(&[row.get(ctx, "text")], 0, "a line's text")
.map_err(|e| lua_error(ctx, e))?,
color: row.get(ctx, "color").to_integer().unwrap_or(0xFFFFFF) as u32,
},
v => hud::Line {
text: arg_text(&[v], 0, "a line").map_err(|e| lua_error(ctx, e))?,
color: 0xFFFFFF,
},
};
lines.push(line);
}
hud::set_panel(corner, &lines);
Ok(vec![])
}),
)?;
hud_table.set(
ctx,
"clear",
function(ctx, |_, _| {
hud::clear();
Ok(vec![])
}),
)?;
horizon.set(ctx, "hud", hud_table)?;
}
#[cfg(feature = "storage")]
{
let storage_table = Table::new(&ctx);
storage_table.set(
ctx,
"get",
function(ctx, |ctx, args| {
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
Ok(vec![match storage::get(&key) {
Some(bytes) => ctx.intern(&bytes).into(),
None => Value::Nil,
}])
}),
)?;
storage_table.set(
ctx,
"set",
function(ctx, |ctx, args| {
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
let value = match args.get(1).copied().unwrap_or(Value::Nil) {
Value::String(s) => s.as_bytes().to_vec(),
v => arg_text(&[v], 0, "the value")
.map_err(|e| lua_error(ctx, e))?
.into_bytes(),
};
// Lua's way: true, or nil and why.
Ok(match storage::set(&key, &value) {
Ok(()) => vec![Value::Boolean(true)],
Err(e) => vec![Value::Nil, text(ctx, &e)],
})
}),
)?;
storage_table.set(
ctx,
"delete",
function(ctx, |ctx, args| {
let key = arg_text(&args, 0, "the key").map_err(|e| lua_error(ctx, e))?;
storage::delete(&key);
Ok(vec![])
}),
)?;
horizon.set(ctx, "storage", storage_table)?;
}
ctx.set_global("horizon", horizon)?;
Ok(())
}
/// A Lua mod: the crate's `src/lib.rs` is `horizon_mod_lua::lua_mod!("main.lua");`,
/// the script's path from the crate's folder.
#[macro_export]
macro_rules! lua_mod {
($script:literal) => {
const _: () = {
// The SDK's export macro names the SDK's crate.
use $crate::__sdk as horizon_mod_sdk;
struct LuaMod($crate::LuaMod);
impl horizon_mod_sdk::Mod for LuaMod {
fn init() -> Result<(Self, horizon_mod_sdk::Registration), String> {
let source = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $script));
let (m, registration) = $crate::LuaMod::start($script, source)?;
Ok((LuaMod(m), registration))
}
fn tick(&mut self, tick: u64) {
self.0
.run("on_tick", vec![$crate::Arg::Integer(tick as i64)]);
}
fn frame(&mut self, seconds: f32) {
self.0
.run("on_frame", vec![$crate::Arg::Number(seconds.into())]);
}
fn command(&mut self, name: &str, args: &str) -> Result<Option<String>, String> {
self.0.call(
"on_command",
vec![
$crate::Arg::Text(name.into()),
$crate::Arg::Text(args.into()),
],
)
}
fn chat(&mut self, text: &str) {
self.0.run("on_chat", vec![$crate::Arg::Text(text.into())]);
}
}
horizon_mod_sdk::export_mod!(LuaMod);
};
};
}
#[cfg(test)]
mod tests {
use super::*;
/// Loads `source` and calls its global `f`.
fn run(source: &str) -> Result<Option<String>, String> {
let (mut m, _) = LuaMod::start("test.lua", source)?;
m.call("on_command", vec![])
}
#[test]
fn the_script_makes_the_registration() {
let (_, r) = LuaMod::start(
"test.lua",
r#"horizon.command("where") horizon.command("coords")
function on_tick(t) end
function on_command(name, args) end"#,
)
.unwrap();
assert_eq!(r.commands, ["where", "coords"]);
assert!(r.tick && !r.frame && !r.chat);
}
#[test]
fn a_command_answers_or_errs() {
assert_eq!(
run("function on_command() return 'done' end"),
Ok(Some("done".into()))
);
assert_eq!(run("function on_command() end"), Ok(None));
assert_eq!(
run("function on_command() error('Not in a world') end"),
Err("Not in a world".into())
);
assert!(run("function on_command() return nil + 1 end").is_err());
assert!(LuaMod::start("test.lua", "this is not lua").is_err());
}
#[test]
fn string_format_as_c_prints() {
let f = |args: &str| {
run(&format!(
"function on_command() return string.format({args}) end"
))
};
assert_eq!(
f("'XYZ %.1f / %.1f', 12.5, -3.25"),
Ok(Some("XYZ 12.5 / -3.2".into()))
);
assert_eq!(
f("'Day %d, %02d:%02d', 2, 6, 5"),
Ok(Some("Day 2, 06:05".into()))
);
assert_eq!(
f("'[%5s|%-4d|%x|%X|%+d]', 'ab', 7, 255, 255, 3"),
Ok(Some("[ ab|7 |ff|FF|+3]".into()))
);
assert_eq!(
f("'%g %g %e', 0.0001, 100000, 1234.5"),
Ok(Some("0.0001 100000 1.234500e+03".into()))
);
assert_eq!(f("'%.0f%%', 99.6"), Ok(Some("100%".into())));
assert_eq!(f("'%s %s', 1.0, nil"), Ok(Some("1.0 nil".into())));
assert!(
f("'%d', 1.5")
.unwrap_err()
.contains("no integer representation")
);
}
#[test]
fn table_insert_remove_concat() {
let script = r#"function on_command()
local t = {}
table.insert(t, "b")
table.insert(t, "c")
table.insert(t, 1, "a")
local last = table.remove(t)
return table.concat(t, ",") .. "|" .. last .. "|" .. string.rep("ab", 3, "-")
end"#;
assert_eq!(run(script), Ok(Some("a,b|c|ab-ab-ab".into())));
}
#[test]
fn rep_and_concat_keep_bytes() {
let script = r#"function on_command()
local r = string.rep("\255", 2, "\0")
local c = table.concat({"\254", 7, "\128"}, "\255")
return tostring(r == "\255\0\255" and #r == 3) .. " " .. tostring(c == "\254\255" .. "7\255\128" and #c == 5)
end"#;
assert_eq!(run(script), Ok(Some("true true".into())));
}
}
+337
View File
@@ -0,0 +1,337 @@
//! What piccolo's standard library leaves out that mods reach for:
//! `string.format`, `string.rep`, `table.insert`, `table.remove` and
//! `table.concat`, as Lua 5.4 defines them (the subset of `format` below).
use crate::{function, lua_error, text};
use piccolo::{Context, Error, Table, Value};
/// Adds them to the `string` and `table` tables.
pub fn install(ctx: Context<'_>) -> Result<(), Error<'_>> {
let string = match ctx.get_global("string") {
Value::Table(t) => t,
_ => {
let t = Table::new(&ctx);
ctx.set_global("string", t)?;
t
}
};
string.set(
ctx,
"format",
function(ctx, |ctx, args| {
let out = format(&args)
.map_err(|e| lua_error(ctx, format!("bad argument to 'format' ({e})")))?;
Ok(vec![text(ctx, &out)])
}),
)?;
string.set(
ctx,
"rep",
function(ctx, |ctx, args| {
let s = bytes_arg(&args, 0).ok_or_else(|| {
lua_error(ctx, "bad argument #1 to 'rep' (string expected)".into())
})?;
let n = args.get(1).and_then(|v| v.to_integer()).unwrap_or(0).max(0) as usize;
let sep = bytes_arg(&args, 2).unwrap_or_default();
if (s.len() + sep.len()).saturating_mul(n) > 1 << 20 {
return Err(lua_error(ctx, "resulting string too large".into()));
}
Ok(vec![bytes(ctx, &vec![s; n].join(&sep[..]))])
}),
)?;
let table = match ctx.get_global("table") {
Value::Table(t) => t,
_ => {
let t = Table::new(&ctx);
ctx.set_global("table", t)?;
t
}
};
table.set(
ctx,
"insert",
function(ctx, |ctx, args| {
let Some(Value::Table(t)) = args.first().copied() else {
return Err(lua_error(
ctx,
"bad argument #1 to 'insert' (table expected)".into(),
));
};
let n = t.length();
match args.len() {
2 => {
t.set(ctx, n + 1, args[1])?;
}
3 => {
let at = args[1]
.to_integer()
.filter(|&i| (1..=n + 1).contains(&i))
.ok_or_else(|| {
lua_error(
ctx,
"bad argument #2 to 'insert' (position out of bounds)".into(),
)
})?;
for i in (at..=n).rev() {
t.set(ctx, i + 1, t.get(ctx, i))?;
}
t.set(ctx, at, args[2])?;
}
_ => {
return Err(lua_error(
ctx,
"wrong number of arguments to 'insert'".into(),
));
}
}
Ok(vec![])
}),
)?;
table.set(
ctx,
"remove",
function(ctx, |ctx, args| {
let Some(Value::Table(t)) = args.first().copied() else {
return Err(lua_error(
ctx,
"bad argument #1 to 'remove' (table expected)".into(),
));
};
let n = t.length();
let at = args.get(1).and_then(|v| v.to_integer()).unwrap_or(n);
if n == 0 && args.len() < 2 {
return Ok(vec![Value::Nil]);
}
if !(1..=n + 1).contains(&at) {
return Err(lua_error(
ctx,
"bad argument #2 to 'remove' (position out of bounds)".into(),
));
}
let removed = t.get(ctx, at);
for i in at..n {
t.set(ctx, i, t.get(ctx, i + 1))?;
}
if at <= n {
t.set(ctx, n, Value::Nil)?;
}
Ok(vec![removed])
}),
)?;
table.set(
ctx,
"concat",
function(ctx, |ctx, args| {
let Some(Value::Table(t)) = args.first().copied() else {
return Err(lua_error(
ctx,
"bad argument #1 to 'concat' (table expected)".into(),
));
};
let sep = bytes_arg(&args, 1).unwrap_or_default();
let first = args.get(2).and_then(|v| v.to_integer()).unwrap_or(1);
let last = args
.get(3)
.and_then(|v| v.to_integer())
.unwrap_or_else(|| t.length());
let mut parts = Vec::new();
for i in first..=last {
let v = t.get(ctx, i);
parts.push(bytes_arg(&[v], 0).ok_or_else(|| {
lua_error(
ctx,
format!("invalid value (at index {i}) in table for 'concat'"),
)
})?);
}
Ok(vec![bytes(ctx, &parts.join(&sep[..]))])
}),
)?;
Ok(())
}
/// A string or number argument as bytes: Lua strings are byte strings, so `rep` and `concat`
/// keep bytes that are not UTF-8 (binary values from `horizon.storage`, for one) as they are.
fn bytes_arg(args: &[Value], i: usize) -> Option<Vec<u8>> {
match args.get(i).copied()? {
Value::String(s) => Some(s.as_bytes().to_vec()),
_ => string_arg(args, i).map(String::into_bytes),
}
}
fn bytes<'gc>(ctx: Context<'gc>, b: &[u8]) -> Value<'gc> {
Value::String(piccolo::String::from_slice(&ctx, b))
}
/// A string or number argument as text, as Lua coerces them.
fn string_arg(args: &[Value], i: usize) -> Option<String> {
match args.get(i).copied()? {
Value::String(s) => Some(s.to_str_lossy().into_owned()),
Value::Integer(n) => Some(n.to_string()),
Value::Number(n) => Some(number_text(n)),
_ => None,
}
}
/// `tostring` of a float: `1.0`, `0.5`, `1e+100`.
fn number_text(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{n:.1}")
} else {
format!("{n}")
}
}
/// `string.format` for `%d %i %u %c %x %X %o %f %F %e %E %g %G %s %q %%` with
/// flags `-0+ #`, a width and a precision.
fn format(args: &[Value]) -> Result<String, String> {
let template = string_arg(args, 0).ok_or("#1: string expected")?;
let mut out = String::new();
let mut next = 1;
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
if chars.peek() == Some(&'%') {
chars.next();
out.push('%');
continue;
}
let mut flags = String::new();
while let Some(&f) = chars.peek().filter(|f| "-0+ #".contains(**f)) {
flags.push(f);
chars.next();
}
let mut width = String::new();
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
width.push(d);
chars.next();
}
let mut precision = None;
if chars.peek() == Some(&'.') {
chars.next();
let mut p = String::new();
while let Some(&d) = chars.peek().filter(|d| d.is_ascii_digit()) {
p.push(d);
chars.next();
}
precision = Some(p.parse::<usize>().unwrap_or(0));
}
let conversion = chars.next().ok_or("invalid conversion '%' to 'format'")?;
let arg = args.get(next).copied().unwrap_or(Value::Nil);
let what = format!("#{}", next + 1);
next += 1;
let integer = || -> Result<i64, String> {
match arg {
Value::Integer(i) => Ok(i),
Value::Number(n) if n.fract() == 0.0 => Ok(n as i64),
Value::Number(_) => Err(format!("{what}: number has no integer representation")),
_ => Err(format!("{what}: number expected, got {}", arg.type_name())),
}
};
let float = || -> Result<f64, String> {
arg.to_number()
.ok_or_else(|| format!("{what}: number expected, got {}", arg.type_name()))
};
let plus = flags.contains('+');
let body = match conversion {
'd' | 'i' | 'u' => {
let n = integer()?;
if plus && n >= 0 {
format!("+{n}")
} else {
n.to_string()
}
}
'c' => char::from_u32(integer()? as u32).unwrap_or('?').to_string(),
'x' => format!("{:x}", integer()?),
'X' => format!("{:X}", integer()?),
'o' => format!("{:o}", integer()?),
'f' | 'F' => {
let n = float()?;
let s = format!("{n:.*}", precision.unwrap_or(6));
if plus && n >= 0.0 { format!("+{s}") } else { s }
}
'e' | 'E' => {
let s = c_exponent(float()?, precision.unwrap_or(6));
if conversion == 'E' {
s.to_uppercase()
} else {
s
}
}
'g' | 'G' => {
let n = float()?;
let p = precision.unwrap_or(6).max(1);
let exp = if n == 0.0 {
0
} else {
n.abs().log10().floor() as i32
};
let s = if exp < -4 || exp >= p as i32 {
c_exponent(n, p - 1)
} else {
let s = format!("{n:.*}", (p as i32 - 1 - exp).max(0) as usize);
if s.contains('.') && !flags.contains('#') {
s.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
s
}
};
if conversion == 'G' {
s.to_uppercase()
} else {
s
}
}
's' => {
let s = match arg {
Value::String(s) => s.to_str_lossy().into_owned(),
Value::Number(n) => number_text(n),
Value::Nil => "nil".into(),
other => other.to_string(),
};
match precision {
Some(p) => s.chars().take(p).collect(),
None => s,
}
}
'q' => format!(
"{:?}",
string_arg(&[arg], 0).ok_or(format!("{what}: string expected"))?
),
other => return Err(format!("invalid conversion '%{other}' to 'format'")),
};
let width: usize = width.parse().unwrap_or(0);
let pad = width.saturating_sub(body.chars().count());
if flags.contains('-') {
out.push_str(&body);
out.extend(std::iter::repeat_n(' ', pad));
} else if flags.contains('0') && !matches!(conversion, 's' | 'q' | 'c') {
let (sign, digits) = match body.strip_prefix(['-', '+']) {
Some(rest) => (&body[..1], rest),
None => ("", body.as_str()),
};
out.push_str(sign);
out.extend(std::iter::repeat_n('0', pad));
out.push_str(digits);
} else {
out.extend(std::iter::repeat_n(' ', pad));
out.push_str(&body);
}
}
Ok(out)
}
/// C's `%e`: `1.500000e+02`.
fn c_exponent(n: f64, precision: usize) -> String {
let s = format!("{n:.*e}", precision);
let (mantissa, exp) = s.split_once('e').unwrap_or((&s, "0"));
let exp: i32 = exp.parse().unwrap_or(0);
format!(
"{mantissa}e{}{:02}",
if exp < 0 { '-' } else { '+' },
exp.abs()
)
}
@@ -0,0 +1,16 @@
[package]
name = "coords-hud-lua"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
description = "Example Horizon mod in Lua: the coords-hud example, line for line"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-lua = { path = "../../../horizon-mod-lua", features = ["game-read", "hud", "chat"] }
[lints]
workspace = true
@@ -0,0 +1,8 @@
id = "coords_hud_lua"
name = "Coordinates HUD (Lua)"
version = "0.1.0"
description = "The Coordinates HUD example written in Lua: your coordinates, facing and the time of day in a corner of the screen. /where puts them in chat, /coords hides or shows the panel."
authors = ["Horizon"]
api = "0.1"
side = "client"
capabilities = ["game-read", "hud", "chat"]
@@ -0,0 +1,67 @@
-- Example Horizon mod in Lua (docs/mods.md, "Lua"): coordinates, facing and
-- the time of day in the top-left corner, /where to put them in chat and
-- /coords to hide or show the panel. The same mod as examples/coords-hud
-- in Rust, and the same test checks both.
local shown = true
-- The panel last sent, so an unchanged one costs no call.
local last = ""
horizon.command("where")
horizon.command("coords")
-- Direction.fromYRot: yaw 0 faces south, 90 west.
local FACING = { "South (+Z)", "West (-X)", "North (-Z)", "East (+X)" }
local function facing(yaw)
return FACING[(math.floor(yaw / 90 + 0.5) & 3) + 1]
end
-- "Day 3, 14:05": day time 0 is 6:00 on day 1.
local function time_of_day(clock)
local day = clock.day_time // 24000 + 1
local ticks = clock.day_time % 24000
local hours = (ticks // 1000 + 6) % 24
local minutes = ticks % 1000 * 60 // 1000
return string.format("Day %d, %02d:%02d", day, hours, minutes)
end
local function lines(p, clock)
local out = {
string.format("XYZ %.1f / %.1f / %.1f", p.x, p.y, p.z),
"Facing " .. facing(p.yaw),
}
if clock then
table.insert(out, time_of_day(clock))
end
return out
end
function on_tick(tick)
local p = shown and horizon.game.player()
if not p then
if last ~= "" then
horizon.hud.clear()
last = ""
end
return
end
local now = lines(p, horizon.game.clock())
local key = table.concat(now, "\n")
if key ~= last then
horizon.hud.set_panel("top-left", now)
last = key
end
end
function on_command(name, args)
if name == "coords" then
shown = not shown
return "Coordinates panel " .. (shown and "shown" or "hidden")
end
local p = horizon.game.player()
if not p then
error("Not in a world")
end
horizon.chat.show(string.format("%.0f, %.0f, %.0f in %s",
math.floor(p.x), math.floor(p.y), math.floor(p.z), p.dimension))
end
@@ -0,0 +1,2 @@
//! The coords-hud example in Lua: the mod is `main.lua`.
horizon_mod_lua::lua_mod!("main.lua");
+17
View File
@@ -412,6 +412,23 @@ interface render {
/// own frame budget; hidden, they are dropped. One map shows at a time:
/// the first loaded mod's.
set-minimap: func(minimap: option<minimap>);
record far-terrain {
/// How far the distant terrain reaches, in chunks: 64 to 1024. On
/// integrated graphics other than Apple's the game caps it lower.
distance: u32,
/// Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms
/// less GPU time per frame on integrated graphics.
reduced: bool,
}
/// Draws the world beyond the render distance as distant terrain, out to
/// `distance` chunks, until the next call (none ends it). The game builds
/// it on its own workers, from the chunks already loaded or saved and,
/// where nobody has been, from the world's generator; the simulation
/// distance doesn't change. Only worlds whose generator the game has
/// (single player and a LAN world you host) show it. The player's own LOD
/// Distance setting, when on, wins. One far terrain shows at a time: the
/// first loaded mod's.
set-far-terrain: func(far-terrain: option<far-terrain>);
}
/// What the game calls. Only the hooks `init` asks for are ever called.
+3 -2
View File
@@ -7,13 +7,13 @@
//! version's level so the launcher can tell before it installs.
/// The newest level this Horizon serves.
pub const LEVEL: u32 = 6;
pub const LEVEL: u32 = 7;
/// Every function the game offers mods, as `interface.function`, with its
/// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new
/// function takes the next level and raises [`LEVEL`] (the `api_reference`
/// test fails until it is listed here).
pub const FUNCTIONS: [(&str, u32); 45] = [
pub const FUNCTIONS: [(&str, u32); 46] = [
("log.write", 0),
("game.get-player", 0),
("game.get-clock", 0),
@@ -59,6 +59,7 @@ pub const FUNCTIONS: [(&str, u32); 45] = [
("browser.set-recipes", 6),
("recipes.place", 6),
("recipes.give", 6),
("render.set-far-terrain", 7),
];
/// What the game's program carries so the launcher can read its level
+22 -3
View File
@@ -78,9 +78,9 @@ use render_bindings::horizon::mods::input;
pub use render_bindings::horizon::mods::input::{Event as InputEvent, HeldItem, ItemUse};
/// What capability `render` asks the game to draw (WIT `interface render`).
pub use render_bindings::horizon::mods::render::{
Corner as RenderCorner, FillShape, FrameRate, ItemShape, Minimap as ModMinimap,
Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape, TextShape,
Zoom as ModZoom,
Corner as RenderCorner, FarTerrain as ModFarTerrain, FillShape, FrameRate, ItemShape,
Minimap as ModMinimap, Point as RenderPoint, QuadShape, Rect as RenderRect, Shape, SpriteShape,
TextShape, Zoom as ModZoom,
};
use render_bindings::{RenderHost, horizon::mods::render};
/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items.
@@ -414,6 +414,8 @@ struct State {
zoom: Option<ModZoom>,
/// Capability `render`'s minimap, when the mod shows one.
minimap: Option<ModMinimap>,
/// Capability `render`'s far terrain, when the mod draws one.
far_terrain: Option<ModFarTerrain>,
input: InputState,
browser: BrowserState,
/// Capability `storage`'s folder; made on first `set`.
@@ -616,6 +618,7 @@ impl Mods {
corner: None,
zoom: None,
minimap: None,
far_terrain: None,
input: InputState::default(),
browser: BrowserState::default(),
data_dir,
@@ -1170,6 +1173,12 @@ impl Mods {
.iter()
.find_map(|e| e.run.as_ref()?.store.data().minimap)
}
/// The far terrain the first running mod draws (capability `render`).
pub fn far_terrain(&self) -> Option<ModFarTerrain> {
self.entries
.iter()
.find_map(|e| e.run.as_ref()?.store.data().far_terrain)
}
fn inputs(&self) -> impl Iterator<Item = &InputState> {
self.entries
.iter()
@@ -1723,6 +1732,8 @@ const TURN_MIN: f32 = 0.05;
/// `set-minimap`'s side in GUI pixels and its blocks per map pixel.
const MINIMAP_SIZE: (f32, f32) = (16.0, 256.0);
const MINIMAP_SCALE: (f32, f32) = (0.5, 4.0);
/// `set-far-terrain`'s distance in chunks, as the LOD Distance setting's.
const FAR_TERRAIN_DISTANCE: (u32, u32) = (64, 1024);
/// A mod's corner panel: shapes in a `width` × `height` box the game
/// places in `corner`, stacked with the other overlays.
@@ -1812,6 +1823,14 @@ impl render::Host for State {
..m
});
}
fn set_far_terrain(&mut self, far_terrain: Option<ModFarTerrain>) {
self.far_terrain = far_terrain.map(|f| ModFarTerrain {
distance: f
.distance
.clamp(FAR_TERRAIN_DISTANCE.0, FAR_TERRAIN_DISTANCE.1),
..f
});
}
fn frame_stats(&mut self) -> FrameRate {
let (fps, min, low) = self.snapshot.frame;
FrameRate { fps, min, low }
+79 -8
View File
@@ -1,6 +1,6 @@
//! Loads real mods built with the SDK: the coords-hud and predator-missile
//! examples and a guest
//! that misbehaves on command. Needs `rustup target add wasm32-unknown-unknown`.
//! Loads real mods built with the SDK: the coords-hud example (in Rust and
//! in Lua), the predator-missile example and a guest that misbehaves on
//! command. Needs `rustup target add wasm32-unknown-unknown`.
use horizon_mods::{
BlockPos, Clock, Corner, GameMode, HeldItem, Hit, Mods, Output, Package, Player, Runs,
ServerPlayer, Shape, Snapshot, Status,
@@ -39,10 +39,17 @@ fn mods_dir() -> &'static Path {
let status = Command::new(env!("CARGO"))
.current_dir(&root)
.args(["build", "--release", "--target", "wasm32-unknown-unknown"])
// As `cargo horizon-mod build` does, for the Lua runtime's seeds.
.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
"--cfg getrandom_backend=\"custom\"",
)
.args([
"-p",
"coords-hud",
"-p",
"coords-hud-lua",
"-p",
"predator-missile",
"-p",
"welcome",
@@ -70,6 +77,7 @@ fn mods_dir() -> &'static Path {
};
let mods = [
("coords_hud", example("coords-hud")),
("coords_hud_lua", example("coords-hud-lua")),
("predator_missile", example("predator-missile")),
("welcome", example("welcome")),
("beacons", example("beacons")),
@@ -140,9 +148,19 @@ fn predator_missile_runs_commands() {
assert!(blast.iter().all(|l| l.contains("summon tnt")));
}
/// The same example in Rust and in Lua: the same checks pass for both.
#[test]
fn coords_hud_draws_and_answers() {
let mut mods = load("coords_hud.hmod");
for (hmod, name) in [
("coords_hud.hmod", "Coordinates HUD"),
("coords_hud_lua.hmod", "Coordinates HUD (Lua)"),
] {
coords_hud_checks(hmod, name);
}
}
fn coords_hud_checks(hmod: &str, name: &str) {
let mut mods = load(hmod);
assert_eq!(mods.entries()[0].status, Status::Running);
assert_eq!(mods.commands().collect::<Vec<_>>(), ["where", "coords"]);
mods.set_snapshot(player());
@@ -161,7 +179,7 @@ fn coords_hud_draws_and_answers() {
assert!(mods.command("where"));
let chat = Output::Chat {
name: "Coordinates HUD".into(),
name: name.into(),
text: "12, 64, -4 in minecraft:overworld".into(),
};
assert_eq!(mods.drain(), [chat]);
@@ -174,6 +192,44 @@ fn coords_hud_draws_and_answers() {
};
assert_eq!(mods.drain(), [hidden]);
assert!(!mods.command("gamemode creative"));
// On the title screen /where is an error, `error()` in Lua.
mods.set_snapshot(Snapshot::default());
assert!(mods.command("where"));
let error = Output::Feedback {
text: "Not in a world".into(),
error: true,
};
assert_eq!(mods.drain(), [error], "{hmod}");
}
/// What a tick of the HUD example costs, in Rust and in Lua: the player
/// moves every tick, so each one formats and sends a new panel.
#[test]
fn coords_hud_tick_cost_in_rust_and_lua() {
for hmod in ["coords_hud.hmod", "coords_hud_lua.hmod"] {
let mut mods = load(hmod);
let mut snapshot = player();
// Warm up: the first calls compile and fill caches.
for t in 0..100 {
snapshot.player.as_mut().unwrap().x += 0.1;
mods.set_snapshot(snapshot.clone());
mods.tick(t);
}
let ticks = 2000;
let started = Instant::now();
for t in 0..ticks {
snapshot.player.as_mut().unwrap().x += 0.1;
mods.set_snapshot(snapshot.clone());
mods.tick(t);
}
let per_tick = started.elapsed() / ticks as u32;
assert_eq!(mods.entries()[0].status, Status::Running, "{hmod}");
println!("{hmod}: {per_tick:?} per tick (a new panel each tick)");
assert!(
per_tick.as_micros() < 1000,
"{hmod} took {per_tick:?} per tick"
);
}
}
#[test]
@@ -205,7 +261,7 @@ fn undeclared_capabilities_are_refused() {
/// from the functions they import.
#[test]
fn a_mod_for_a_newer_horizon_is_refused() {
use horizon_mods::api_level::{imports, needed};
use horizon_mods::api_level::{LEVEL, imports, needed};
use wit_parser::{ManglingAndAbi, Resolve};
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let wit = std::fs::read_to_string(root.join("crates/horizon-mod-sdk/wit/horizon.wit"))
@@ -255,8 +311,8 @@ experimental = ["ui", "render", "input", "recipes"]
needed(imports(&wasm).unwrap().iter().map(String::as_str)).0
};
assert_eq!(level("coords_hud.hmod"), 0);
// The UI guest fills a browser panel (`browser`, level 6).
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 6);
// The UI guest draws far terrain (`render.set-far-terrain`).
assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL);
}
#[test]
@@ -725,6 +781,21 @@ fn render_minimap_is_clamped_and_hidden() {
assert_eq!(map(&mut mods, "off"), None);
}
#[test]
fn render_far_terrain_is_clamped_and_ended() {
let mut mods = load("horizon_mods_test_ui_guest.hmod");
let far = |mods: &mut Mods, args: &str| {
assert!(mods.command(&format!("far_terrain {args}")));
mods.far_terrain().map(|f| (f.distance, f.reduced))
};
assert!(mods.far_terrain().is_none());
assert_eq!(far(&mut mods, "256 1"), Some((256, true)));
// The distance is clamped to LOD Distance's 64 to 1024 chunks.
assert_eq!(far(&mut mods, "8 0"), Some((64, false)));
assert_eq!(far(&mut mods, "99999 0"), Some((1024, false)));
assert_eq!(far(&mut mods, "off"), None);
}
/// `game.raycast`: the view's hit the game worked out, up to the distance
/// asked for; the game works it out only while a mod imports the call.
#[test]
@@ -112,6 +112,7 @@ impl Mod for Guest {
"input_wheel",
"zoom",
"minimap",
"far_terrain",
"raycast",
"quiet",
"quiet_many",
@@ -242,6 +243,15 @@ impl Mod for Guest {
});
render::set_minimap(map);
}
// `far_terrain off`, or `far_terrain DISTANCE REDUCED`.
"far_terrain" => {
let words: Vec<&str> = args.split(' ').collect();
let far = (args != "off").then(|| render::FarTerrain {
distance: words.first().and_then(|w| w.parse().ok()).unwrap_or(0),
reduced: words.get(1) == Some(&"1"),
});
render::set_far_terrain(far);
}
"quiet" => command::run_quiet(&args.split(';').map(str::to_owned).collect::<Vec<_>>()),
"quiet_many" => {
let n: usize = args.parse().unwrap_or(0);
@@ -45,6 +45,54 @@ packet!(
packet!(KnownPack { namespace: String => Str, id: String => Str, version: String => Str });
packet!(SelectKnownPacks { packs: Vec<KnownPack> => List<KnownPack> });
packet!(CodeOfConduct { text: String => Str });
/// ServerLinks.UntrustedEntry's `type` (Either<KnownLinkType, Component>): a
/// known link's id, or a custom label component.
#[derive(Clone, Debug, PartialEq)]
pub enum ServerLinkKind {
/// ServerLinks.KnownLinkType by id (ByIdMap: unknown ids read as 0).
Known(i32),
/// A custom label (TRUSTED_CONTEXT_FREE_STREAM_CODEC: a component in NBT).
Custom(Tag),
}
/// ServerLinks.UntrustedEntry: the label and the unchecked link string (the
/// client keeps only `http`/`https` URIs, Util.parseAndValidateUntrustedUri).
#[derive(Clone, Debug, PartialEq)]
pub struct ServerLink {
pub kind: ServerLinkKind,
pub link: String,
}
impl Codec<ServerLink> for ServerLink {
fn read(r: &mut Reader) -> Result<Self> {
let kind = if Bool::read(r)? {
ServerLinkKind::Known(VarInt::read(r)?)
} else {
ServerLinkKind::Custom(Nbt::read(r)?)
};
Ok(Self {
kind,
link: Str::read(r)?,
})
}
fn write(v: &Self, w: &mut Writer) {
match &v.kind {
ServerLinkKind::Known(id) => {
Bool::write(&true, w);
VarInt::write(id, w);
}
ServerLinkKind::Custom(tag) => {
Bool::write(&false, w);
Nbt::write(tag, w);
}
}
Str::write(&v.link, w);
}
}
packet!(
/// ClientboundServerLinksPacket (a common packet: configuration and play).
/// Links the pause menu's "Server Links..." screen lists.
ServerLinks { links: Vec<ServerLink> => List<ServerLink> }
);
packet!(
/// ClientboundShowDialogPacket.CONTEXT_FREE_STREAM_CODEC: the dialog
/// itself (`Dialog.DIRECT_CODEC` as NBT).
@@ -89,6 +137,7 @@ packet_enum!(Clientbound, crate::ids::configuration::clientbound {
UpdateTags(UpdateTags) = UPDATE_TAGS,
SelectKnownPacks(SelectKnownPacks) = SELECT_KNOWN_PACKS,
ClearDialog(Empty) = CLEAR_DIALOG,
ServerLinks(ServerLinks) = SERVER_LINKS,
ShowDialog(ShowDialog) = SHOW_DIALOG,
CodeOfConduct(CodeOfConduct) = CODE_OF_CONDUCT,
});
@@ -236,6 +285,18 @@ mod tests {
Clientbound::CodeOfConduct(CodeOfConduct {
text: "Be nice".into(),
}),
Clientbound::ServerLinks(ServerLinks {
links: vec![
ServerLink {
kind: ServerLinkKind::Known(0),
link: "https://bugs.example.net".into(),
},
ServerLink {
kind: ServerLinkKind::Custom(crate::text::literal("Custom link")),
link: "https://example.net".into(),
},
],
}),
Clientbound::ShowDialog(ShowDialog {
dialog: Tag::compound([("type", Tag::String("minecraft:notice".into()))]),
}),
+5 -1
View File
@@ -15,7 +15,7 @@ use crate::{
configuration::{
ClientInformation, CookieRequest, CookieResponse, CustomClickAction, CustomPayload,
Disconnect, KeepAlive, Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse,
StoreCookie, Transfer, UpdateTags,
ServerLinks, StoreCookie, Transfer, UpdateTags,
},
login::Property,
},
@@ -1903,6 +1903,9 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
StopSound(StopSound) = STOP_SOUND,
StartConfiguration(Empty) = START_CONFIGURATION,
StoreCookie(StoreCookie) = STORE_COOKIE,
// A common packet: the same `configuration::ServerLinks` the
// configuration phase sends.
ServerLinks(ServerLinks) = SERVER_LINKS,
SwingAnimation(SwingAnimation) = SWING_ANIMATION,
SystemChat(SystemChat) = SYSTEM_CHAT,
TabList(TabList) = TAB_LIST,
@@ -2290,6 +2293,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
ChatCommandSigned(ChatCommandSigned) = CHAT_COMMAND_SIGNED,
ChatSessionUpdate(ChatSessionUpdate) = CHAT_SESSION_UPDATE,
PlaceRecipe(super::recipes::PlaceRecipe) = PLACE_RECIPE,
RecipeBookChangeSettings(super::recipes::RecipeBookChangeSettings) = RECIPE_BOOK_CHANGE_SETTINGS,
ChunkBatchReceived(ChunkBatchReceived) = CHUNK_BATCH_RECEIVED,
ClientCommand(ClientCommand) = CLIENT_COMMAND,
ClientInformation(ClientInformation) = CLIENT_INFORMATION,
@@ -500,6 +500,33 @@ impl Codec<RecipeBookSettings> for RecipeBookSettings {
}
}
/// ServerboundRecipeBookChangeSettingsPacket: the active book's open and
/// filtering flags, with its `RecipeBookType` ordinal as a VarInt.
#[derive(Clone, Debug, PartialEq)]
pub struct RecipeBookChangeSettings {
pub book_type: i32,
pub open: bool,
pub filtering: bool,
}
impl Codec<RecipeBookChangeSettings> for RecipeBookChangeSettings {
fn read(r: &mut Reader) -> Result<Self> {
let book_type = r.varint()?;
if !(0..4).contains(&book_type) {
return Err(crate::Error::new("invalid recipe book type"));
}
Ok(Self {
book_type,
open: r.bool()?,
filtering: r.bool()?,
})
}
fn write(v: &Self, w: &mut Writer) {
w.varint(v.book_type);
w.bool(v.open);
w.bool(v.filtering);
}
}
/// ClientboundPlaceGhostRecipePacket: the recipe to show faded in a menu
/// whose items were missing.
#[derive(Clone, Debug, PartialEq)]
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Scheduling hints for the game's threads: the OS calls the crates
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_WindowsProgramming"] }
[lints]
workspace = true
+37
View File
@@ -41,6 +41,43 @@ fn favour() -> bool {
false
}
/// The CPU time the calling thread has used, where only an OS call gives it
/// precisely. Windows counts it exactly only in cycles of the time-stamp
/// counter (`QueryThreadCycleTime`; `GetThreadTimes` moves in scheduler ticks
/// of ~15.6 ms), so the counter's rate is measured once against the wall
/// clock and cycles convert to time. `None` elsewhere: unix callers read
/// `CLOCK_THREAD_CPUTIME_ID` themselves.
#[cfg(all(windows, target_arch = "x86_64"))]
pub fn thread_cpu() -> Option<std::time::Duration> {
use std::{
sync::OnceLock,
time::{Duration, Instant},
};
use windows_sys::Win32::System::{
Threading::GetCurrentThread, WindowsProgramming::QueryThreadCycleTime,
};
// Time-stamp counter cycles per nanosecond.
static RATE: OnceLock<f64> = OnceLock::new();
let rate = *RATE.get_or_init(|| {
// SAFETY: RDTSC has no preconditions on x86_64.
let tsc = || unsafe { core::arch::x86_64::_rdtsc() };
let (wall, start) = (Instant::now(), tsc());
std::thread::sleep(Duration::from_millis(20));
(tsc() - start) as f64 / wall.elapsed().as_nanos() as f64
});
let mut cycles = 0;
// SAFETY: GetCurrentThread's pseudo handle needs no closing; a valid out pointer.
if unsafe { QueryThreadCycleTime(GetCurrentThread(), &mut cycles) } == 0 {
return None;
}
Some(Duration::from_nanos((cycles as f64 / rate) as u64))
}
#[cfg(not(all(windows, target_arch = "x86_64")))]
pub fn thread_cpu() -> Option<std::time::Duration> {
None
}
/// Set once as the game starts, before its helper threads do: they then go
/// behind the server thread and the render thread (`behind_server`).
static HELPERS_BEHIND: AtomicBool = AtomicBool::new(false);
@@ -616,6 +616,9 @@ impl MineshaftPiece {
// A chest minecart with the mineshaft loot table.
let seed = c.random.long();
let mut nbt = super::legacy::spawn_nbt("minecraft:chest_minecart", pos, false, false);
// As the proto chunk saves the new cart: not ticked, not burning.
nbt.insert("HasTicked".into(), Tag::Byte(1));
nbt.insert("Fire".into(), Tag::Short(0));
let p = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
+8
View File
@@ -435,6 +435,13 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `rotate` | `bool` | The map turns with the player, who faces up, and an N on its rim marks north. Otherwise north is up and the player's arrow turns. |
| `round` | `bool` | Round, the corners left clear; otherwise square. |
### record `far-terrain`
| Field | Type | |
| --- | --- | --- |
| `distance` | `u32` | How far the distant terrain reaches, in chunks: 64 to 1024. On integrated graphics other than Apple's the game caps it lower. |
| `reduced` | `bool` | Coarser far tiles, as the Far Detail setting's Reduced: 2 to 4 ms less GPU time per frame on integrated graphics. |
| Function | Signature | What it does |
| --- | --- | --- |
| `set-hud` | `func(shapes: list<shape>)` | Replaces the mod's HUD shapes: up to 1024, drawn every frame in order over the HUD (after the hotbar, under chat and screens), hidden with the HUD (F1). An empty list removes them. |
@@ -446,6 +453,7 @@ Any four-cornered shape (a wheel's segment, an arrow): the corners in order roun
| `frame-stats` | `func() -> frame-rate` | The game's frame counter, recomputed twice a second. |
| `set-zoom` | `func(zoom: zoom)` | Zooms the camera until the next call (`factor` 1 ends it), only while playing: a screen opening or the spyglass's own view (which wins) leave the camera vanilla's. With several mods, the strongest zoom counts. |
| `set-minimap` | `func(minimap: option<minimap>)` | Shows a map of the world around the player in `corner`, stacked with the game's overlays and the mods' panels, until the next call (none hides it). It looks like a map item: vanilla's map colours with their height shading and water depth, and the player's arrow. Underground, under a roof and in the Nether it maps the floor around the player's height. The game keeps the map's tiles and refreshes them within its own frame budget; hidden, they are dropped. One map shows at a time: the first loaded mod's. |
| `set-far-terrain` | `func(far-terrain: option<far-terrain>)` | Draws the world beyond the render distance as distant terrain, out to `distance` chunks, until the next call (none ends it). The game builds it on its own workers, from the chunks already loaded or saved and, where nobody has been, from the world's generator; the simulation distance doesn't change. Only worlds whose generator the game has (single player and a LAN world you host) show it. The player's own LOD Distance setting, when on, wins. One far terrain shows at a time: the first loaded mod's. |
## `events`
+2
View File
@@ -121,5 +121,7 @@ What real mods need next, in the order modpacks and servers need it:
use-stop, key-down, key-up).
- Experimental `render` (`experimental = ["render"]`): HUD shapes (fill, quad, sprite, item, text), `gui-size`, camera
`shake`, drawn by the game's own renderer.
- SDK: `horizon-mod-lua`, mods written in Lua 5.4 (the interpreter built into the mod, same sandbox and API);
`cargo horizon-mod new --template lua`; example `coords-hud-lua`.
- API 0.2 (content): data-driven blocks and items, tags, `horizon:add_to`, terrain adaptation.
- API 0.1.0: `log`, `game`, `chat`, `hud`, `storage`; `init`, `tick`, `frame`, `command`, `chat`.
+7 -2
View File
@@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says
A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every
function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`):
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`. A mod needs the level of
level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 server mods' `world`, level 5 `recipes` (`items`, `all`, `known`), level 6 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`, level 7 `render.set-far-terrain`. A mod needs the level of
the newest function it imports (a component imports only the functions it calls). The level is read from the file,
never declared:
@@ -109,7 +109,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
| Mod API: `input` (claimed items' use, the mod's keys, the mouse wheel) | experimental | manifest `experimental = ["input"]` |
| Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` |
| Server mods: world `horizon-server-mod`, `players`, `server-events`, `world`, typed `server-command`s | experimental | manifest `api = "0.3"` |
| Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | |
| Script mods: Lua (`horizon-mod-lua`, an interpreter built into the mod; `cargo horizon-mod new --template lua`) | experimental | `horizon-mod-lua` 0.1.0 |
| `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 |
| Shader API | not started | |
| `horizon-ctl` commands and MCP tools | preview | none yet (unversioned) |
@@ -134,3 +134,8 @@ Deprecations and breaking changes are in bold. The release notes of the game rel
for a map image costs too much through hook calls, so the game keeps the tiles (vanilla's map colours) and the mod
picks the corner, side, scale and shape. One map shows at a time, the first loaded mod's. It is a new function and a
new type, so mods built before it keep loading.
- **`render.set-far-terrain`** turns on the game's own distant terrain, not one a mod builds: sampling the generator
and meshing thousands of far tiles runs on the game's workers within its frame and upload budgets, which no hook
call could match. The mod picks the distance (64 to 1024 chunks, capped lower on integrated graphics as LOD Distance
is) and Far Detail's Reduced. The player's own LOD Distance, when on, wins; one far terrain shows at a time, the
first loaded mod's.
+2 -2
View File
@@ -29,7 +29,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
**Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are.
**HDR** (Display, off by default) presents to an scRGB swapchain (`Rgba16Float`, extended linear sRGB) on displays and compositors that offer it. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
**HDR** (Display, off by default) presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
Which settings each screen shows, their descriptions and costs are in `crates/horizon-client/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them.
@@ -47,7 +47,7 @@ Coverage and detail improve progressively; a large selected radius does not mean
Ordinary Overworld atmospheric fog extends with the selected LOD distance. Fog Off disables distance haze at large ranges too; fluid, darkness and boss fog retain their limits.
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. The official Far View mod (`mods/official/far-view`, [mods.md](mods.md)) turns the same distant terrain on from the mod browser, with its own distance and detail; the setting, when on, wins. Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
Not implemented: anisotropic filtering (wgpu needs linear magnification for it, which blurs close blocks), MSAA (FXAA instead) and exclusive fullscreen (fullscreen is borderless).
+79 -5
View File
@@ -116,7 +116,7 @@ MCP: `account_sign_out`, needs `confirm: true`.
## `horizon-launcher account select`
Play as this account.
Play as this account, or as one of your offline names.
horizon-launcher account select <WHO>
@@ -126,19 +126,46 @@ MCP: `account_select`.
## `horizon-launcher account offline`
Play offline, without an account.
Play offline, without an account: as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game made up.
horizon-launcher account offline
horizon-launcher account offline [NAME]
- `NAME`
MCP: `account_offline`.
## `horizon-launcher account forget-offline`
Forget one of your offline names.
horizon-launcher account forget-offline <NAME>
- `NAME`
MCP: `account_forget_offline`.
## `horizon-launcher account skin`
Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape PNG, the model. Kept by the launcher and given to the game when that name plays.
horizon-launcher account skin <NAME> [FILE]
- `NAME`
- `FILE`: The skin's PNG
- `--model`: classic or slim arms
- `--cape`: The cape's PNG
- `--no-skin`: Back to the default skin
- `--no-cape`: Take the cape off
MCP: `account_skin`.
## `horizon-launcher settings`
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it).
Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink, background PATH (a picture behind Home; "default" shows Horizon's landscape).
horizon-launcher settings [KEY] [VALUE]
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy)
- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy, while-playing, language, accent, background)
- `VALUE`: The new value (with KEY)
MCP: `settings`.
@@ -153,6 +180,26 @@ Show or set the extra arguments the game starts with (for example --server-threa
MCP: `game_args`.
## `horizon-launcher share-log`
Upload an instance's latest.log to mclo.gs (a paste service for Minecraft logs) and print its link, for a bug report: only when the player asks. The home folder, the user name and the account names are taken out first; the link expires after 90 days unread.
horizon-launcher share-log
- `--instance`: The instance (default: the selected one)
MCP: `share_log`, only when the player asks.
## `horizon-launcher unshare-log`
Delete a log shared with share-log: the "<id>:<token>" it printed.
horizon-launcher unshare-log <SHARED>
- `SHARED`
MCP: `unshare_log`, needs `confirm: true`.
## `horizon-launcher logs`
Print the end of the game's log (logs/latest.log in the game folder), or its newest crash report.
@@ -344,6 +391,33 @@ Export an instance (or a port) to share: its settings, packs, Horizon mods and t
MCP: `instance_export`.
## `horizon-launcher instance launch`
Show or set what runs around an instance's game: a shell command before it (the game starts only if it succeeds), a wrapper it runs through (`nice -n 5`), and environment variables ("NAME=value NAME2=value"). An empty value clears one. On Linux, also GameMode, MangoHud and the dedicated graphics card, when the computer has them.
horizon-launcher instance launch <INSTANCE>
- `INSTANCE`
- `--pre-launch`
- `--wrapper`
- `--env`
- `--gamemode`: Linux: run it through GameMode (gamemoderun), on or off
- `--mangohud`: Linux: MangoHud's overlay, on or off
- `--dedicated-gpu`: Linux: the dedicated graphics card (DRI_PRIME, or NVIDIA's offload), on or off
MCP: `instance_launch`, needs `confirm: true`.
## `horizon-launcher instance shortcut`
Put a shortcut on the desktop that plays an instance (`play --instance`): a .desktop file (also in the applications menu) on Linux, an app on macOS, a .lnk on Windows.
horizon-launcher instance shortcut [INSTANCE]
- `INSTANCE`: The instance (default: the selected one)
- `--remove`: Move the instance's shortcuts to the trash instead
MCP: `instance_shortcut`.
## `horizon-launcher instance import`
Import what a file is, as dropping it on the window does: a shared instance (a .mrpack without mod loaders, such as `instance export` writes, or a game folder zip) becomes a new instance; a Java modpack goes to the Modpacks import; a resource or shader pack into the instance (default: the selected one).
+65 -3
View File
@@ -15,7 +15,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
| SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 |
| Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands |
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; and Recipes, a recipe viewer |
| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; Far View (`far-view`), distant terrain; and Recipes, a recipe viewer |
| Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model |
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) |
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
@@ -89,7 +89,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
| `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
| `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` |
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
| `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's); `set-far-terrain(far-terrain)`: the world past the render distance as distant terrain, out to 64 to 1024 chunks, built by the game's own workers (the player's LOD Distance, when on, wins; the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` |
| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key.<mod>.<name>:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` |
| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds; `place(book-id, all)`: the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); `give(stack)`: in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` |
@@ -132,7 +132,8 @@ There is no file, socket, process or raw-input interface, on purpose.
| **wasmtime + component model** | yes: memory, fuel, no imports but ours | yes | WIT → Rust on both sides | 85 ns per empty hook call; 50 ms to compile a 64 KB mod on load | **chosen** |
| wasmtime, core modules with a hand ABI | yes | yes | no: pointers and lengths by hand | a few ns less per call | not worth the fragile ABI |
| wasmi (interpreter) | yes | yes | no components | no compile time, execution ~10-50x slower than cranelift code | kept in mind for a platform without JIT |
| Lua (mlua) or another script | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
| Lua (mlua) or another script, in the game | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
| Lua *inside* the WebAssembly sandbox ([`horizon-mod-lua`](#lua-mods)) | yes: the same as any mod | yes | the same API, as Lua tables | the interpreter in the mod (~240 KB); a HUD tick ~60 µs | **offered** as a way in for Lua writers |
| native `cdylib` plugins | none | no: per OS and arch, unstable Rust ABI | yes | none | unsafe for players |
| an embedded JVM | none per mod | yes | Java | 100+ MB, seconds of startup | see the research |
@@ -246,6 +247,66 @@ and each of its commands: it must start and never trap or overrun. It checks a
`.hpack` and a resource pack `.zip` too, and a signature with `--key <public
key> --signature <file>.minisig`.
### Lua mods
A mod can be written in Lua 5.4 instead of Rust:
`cargo horizon-mod new my-mod --template lua` makes one. The interpreter,
[piccolo](https://github.com/kyren/piccolo) (pure Rust, MIT), is built into
the mod's WebAssembly component with the script by
[`horizon-mod-lua`](../crates/horizon-mod-lua/src/lib.rs). So a Lua mod is an
`.hmod` like any other: the same sandbox, capabilities, time budgets and
checks. Only the toolchain is shared with Rust mods (`cargo horizon-mod build`
compiles the interpreter once, in about 30 s).
```lua
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
```
- **Hooks are global functions the script defines:** `on_tick(tick)`,
`on_frame(seconds)`, `on_command(name, args)` and `on_chat(text)`. The game
calls only those that exist. A command returns its feedback as a string, or
raises `error(text)` for the red error.
- **Commands** are registered with `horizon.command(name)` while the script
loads.
- **The API is a table per interface:**
- `horizon.game.player()` (a table with `x y z yaw pitch health food
dimension`, or nil) and `horizon.game.clock()` (`game_time day_time`).
- `horizon.chat.show(text)`.
- `horizon.hud.set_panel(corner, lines)`, where the corner is
`"top-left"`, ... and the lines are strings or `{text =, color =}`; and
`horizon.hud.clear()`.
- `horizon.storage.get(key)`, `set(key, value)` (`true`, or `nil` and why)
and `delete(key)`.
- `horizon.log.info(text)`, ...; `print` writes to the mod's log.
- **Capabilities are the crate's features too.** The Cargo.toml's
`horizon-mod-lua` features list the same capabilities as
`horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`): only those
tables are built in, and the game refuses a mod whose component uses one
it didn't declare. A capability in the manifest but not in the features
leaves its table out: the script fails with `attempt to index a nil value`.
- **The standard library** is piccolo's: base, `math`, `string` (`len sub
lower upper reverse`), `table` (`pack unpack`) and `coroutine`. The mod
runtime adds `string.format` (`%d %s %f %x %e %g %q`, with flags, width and
precision), `string.rep`, `table.insert`, `table.remove` and `table.concat`.
There is no `io` or `os`, and patterns (`string.find`, `gsub`) aren't there
yet.
- **`math.random`** is seeded from a fixed sequence: the sandbox has no
entropy.
The example [`coords-hud-lua`](../crates/horizon-mod-sdk/examples/coords-hud-lua/main.lua)
is [`coords-hud`](../crates/horizon-mod-sdk/examples/coords-hud/src/lib.rs)
line for line, and the sandbox test runs the same checks on both. With the
player moving every tick (a new panel each one), a tick costs 54–68 µs in Lua
against 22–63 µs in Rust on a busy Mac mini M4 Pro
(`coords_hud_tick_cost_in_rust_and_lua`), under the 0.1 ms the API asks of a
HUD script.
### Testing a mod
`cargo horizon-mod test` builds the mod, then runs each `tests/<name>.toml` in
@@ -611,6 +672,7 @@ so the same rustc and `Cargo.lock` give the same file.
| Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` |
| Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` |
| Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) |
| Far View | `far_view` | Graphics > LOD Distance and Far Detail | `distance` (chunks): `64`, `128`, `256` (default), `512`, `1024`; `detail`: `full` (default), `reduced`. The game builds and draws the distant terrain (`render.set-far-terrain`); the player's LOD Distance, when on, wins |
| Recipes | `recipes` | nothing (new) | `give` (a click on the item panel gives the item in creative mode: one, a stack with a right click): `false` (default), `true`. The keys are the game's: `key.recipes.recipe` (default `key.keyboard.r`) and `key.recipes.uses` (default `key.keyboard.u`) in `horizon-mods.txt`. Every item sits in the item panel (`browser`) with a search box (words in the name or id, `@namespace`, `#tag`); R or a click shows an item's recipes, U or a right click its uses, a tab per station, and + fills the grid with a recipe the player knows (`recipes.place`). On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book |
## Versions and compatibility
+2
View File
@@ -44,6 +44,8 @@ Harnesses can also run on the Mac mini, the desktop and playhorizon (`tools/pari
Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip` runs the game on a virtual screen) and each host's `exclude`. The Mac excludes nothing: vanilla's `Math.sin`, `pow`, `log` and the like don't round the same on an Apple silicon Mac as on Linux (HotSpot uses fdlibm there, Intel's stubs on x86-64), and Rust's float functions round like neither (they call the platform libm), so code that mirrors a vanilla `Math` call uses `horizon_core::jmath`, which computes what HotSpot computes on the host architecture. The ThinkPad no longer runs harnesses (2026-10-26): playhorizon is the Linux host that is always on.
The file's `solo` list names harnesses that drive game windows (`remote_fireworks`, `server_player_sculk`, `world_reopen`, ...). On a machine where `parity.py` has 4 CPUs or fewer, such as playhorizon's CI runner, each of them runs alone, after the others: there frames are drawn on the CPU, and beside other harnesses a screenshot or a control request took more ticks than the check allows. `PARITY_SOLO=1` or `0` forces this on or off.
### Any command on playhorizon: `tools/remote.sh`
For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh <worktree> <command...>` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/<name>` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 0-3 (`--cpus`): the CI runner has 4-7, so these runs never slow a pull request's parity job. A benchmark asks for its CPUs with `--cpus 0-3 --exclusive` (and says so in its pull request): it starts only when no other run uses them, and later runs wait for it to end. The command gets scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Whatever the command leaves running (a game, a daemon) is stopped when it ends, and stopping `remote.sh` (or losing the connection) stops the command on the host. Logs stay in `~/remote-work/<name>.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones.
+1
View File
@@ -2763,6 +2763,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2 0.10.9",
"sys-locale",
"tar",
"toml 1.1.8+spec-1.1.0",
"trash",
+2
View File
@@ -22,6 +22,8 @@ tar = { version = "0.4", default-features = false }
toml = "1.1"
zip = { version = "2", default-features = false, features = ["deflate"] }
dirs = "5"
# The system's preferred languages, for the window's language (i18n.rs).
sys-locale = "0.3"
self-replace = "1"
clap = { version = "4", features = ["derive"] }
horizon-theme = { path = "../crates/horizon-theme" }
+60 -2
View File
@@ -100,6 +100,62 @@ The opening, then Play ([clip](docs/opening.webm)):
pack into the selected instance. A data pack is pointed at the world rows, and the Packs section
and the world rows keep taking their own drops. A note in the corner says what happened.
`horizon-launcher instance import <file>`.
- **Launch options**: Home's Instance row has **Launch…**: per instance, a shell command run in its
folder before the game (the game starts only if it succeeds), a wrapper the game runs through
(`nice -n 5`), and environment variables (`NAME=value OTHER="two words"`), kept in
`instances.json` (`horizon_launcher::launch`). The game stays one process the launcher follows:
on Unix `/bin/sh` runs the command and then `exec`s the wrapper and the game, whose words pass
through `"$@"` untouched; on Windows `cmd /C` runs `<command> && <game>`. Both the window's Play
and `play` use them. `horizon-launcher instance launch <instance> [--pre-launch …]
[--wrapper …] [--env …]` (an MCP agent needs `confirm: true`: it sets commands Play runs).
On Linux the panel also has switches for what the computer has: **GameMode** (`gamemoderun`
on the PATH), **MangoHud** (`mangohud`), and, with two graphics cards under
`/sys/class/drm`, the **dedicated graphics card** (`DRI_PRIME=1`, or NVIDIA's
`__NV_PRIME_RENDER_OFFLOAD`, `__VK_LAYER_NV_optimus` and `__GLX_VENDOR_LIBRARY_NAME` when its
driver is loaded). A switch whose tool is gone does nothing. `instance launch --gamemode
on|off --mangohud on|off --dedicated-gpu on|off`; `HORIZON_LAUNCHER_LINUX_TOOLS` makes any
system show them, for tests.
- **Desktop shortcut**: Home's Instance row has **Shortcut**: a shortcut on the desktop that plays
the selected instance without opening the launcher (`horizon-launcher play --instance <id>`).
A `.desktop` file on Linux (also put in the applications menu, and marked trusted with `gio`
where it exists), a small app with Horizon's icon on macOS, a `.lnk` on Windows
(`horizon_launcher::shortcut`). **Remove shortcut** moves it to the trash; it is found by the
instance id inside it, so a rename doesn't lose it. `horizon-launcher instance shortcut
[instance] [--remove]`; `HORIZON_LAUNCHER_DESKTOP` points it at another folder.
- **Accounts and quick switch**: the top bar's account chip opens a menu of everyone who can play:
each signed-in Microsoft account, the offline names the player keeps, and the name the game
made up (`horizon-player.txt`). Picking one plays as it from the next Play. Settings > Account
adds offline names (3 to 16 letters, digits or `_`; the game gets `--name`) and forgets them;
the Console's Share log takes them out of the log too. `horizon-launcher account offline
[NAME]`, `account select <name>` (an account or an offline name), `account forget-offline
<name>`.
- **Accent and Home background**: on top of Dark, Light or System, Settings > Launcher picks an
accent (Horizon's violet, blue, teal, green, amber, red or pink) and a picture of the player's
own behind Home (PNG, JPEG or WebP, cropped to fill; **Landscape** brings Horizon's back). An
accent turns the violet tokens to its hue, then darkens or lightens them just enough to keep
white on the Play button at 4.5:1 and the accent at 3:1 on every background (a test holds every
accent in both looks to it). The picture is copied into the data folder.
`horizon-launcher settings accent NAME`, `settings background PATH|default`.
- **Language**: English, French and Russian (the languages Horizon's own game menus have).
The window follows the system's language unless the player picks one in Settings > Launcher
(each named in itself); a string a table lacks shows in English. The tables are
`launcher/lang/<code>.json`, English text to its translation, Horizon's own; a test fails when
the code asks `tr("…")` for a string a table lacks, or a table keeps one the code dropped.
`horizon-launcher settings language system|en|fr|ru`.
- **Skin and cape**: each offline name's **Skin…** in Settings > Account shows the front of its
skin and cape, takes a skin PNG (64×64, or the older 64×32) and a cape PNG (64×32), and picks
classic or slim arms. The launcher keeps copies in its `skins/` folder and gives the game
`HORIZON_OFFLINE_SKIN`, `HORIZON_OFFLINE_SKIN_MODEL` and `HORIZON_OFFLINE_CAPE` when that name
plays; the game draws the default skins until it reads them. A Microsoft account's skin and
cape: coming soon, with sign-in. `horizon-launcher account skin <name> [FILE] [--model
classic|slim] [--cape FILE] [--no-skin] [--no-cape]`.
- **Console**: Home's Console shows the game's output of this run as it comes: a search (every
word, any case), Follow to keep the newest line in view, Copy, and Open logs folder. Warnings
and errors stand out. **Share log…** uploads the log to [mclo.gs](https://mclo.gs), a paste
service for Minecraft logs, only after the player confirms; the home folder, the user name and
the account names are taken out first (`horizon_launcher::game_log`), and **Delete it** removes
the paste. `horizon-launcher share-log [--instance]` and `unshare-log <id:token>`;
`HORIZON_LAUNCHER_PASTE_URL` points it at a test server.
- **Worlds**: Home lists the selected instance's worlds (picture, mode, version, last played,
size). **Back up** zips one into the instance's `backups/` as the game's Make Backup does
(`<date>_<time>_<folder>.zip`); **Copy** puts "<name> (1)" beside it; **Delete** moves it to the
@@ -140,8 +196,10 @@ page `servers.json` (the list), `servers/<name>/` (the servers created there) an
- **Settings**: install new Horizon versions automatically (on unless you turn it off; the
selected version only moves to the new one if you were on the newest), the opening animation,
the game's extra command-line arguments, an HTTP proxy for downloads, and the launcher's own
version and update state.
what the window does while you play (stays by default; Minimise, or Hide on macOS, puts it away
when the game starts and brings it back to the front when the game closes; `settings
while-playing stay|minimize|hide`), the game's extra command-line arguments, an HTTP proxy for
downloads, and the launcher's own version and update state.
- **Updates**: there is one channel. Every release is published as the release, GitHub's latest,
once its smoke test passed (docs/release-flow.md), and the launcher installs it. The game
pre-releases of the old Alpha channel (v0.2.1 on) stay on the Versions page to install
+157
View File
@@ -0,0 +1,157 @@
{
"Home": "Accueil",
"Mods": "Mods",
"Servers": "Serveurs",
"Versions": "Versions",
"Patch notes": "Notes de version",
"Settings": "Paramètres",
"Accounts, updates, and the launcher itself.": "Comptes, mises à jour et le launcher lui-même.",
"Account": "Compte",
"Updates": "Mises à jour",
"Launcher": "Launcher",
"Network": "Réseau",
"Game": "Jeu",
"AI": "IA",
"About": "À propos",
"Install new versions automatically": "Installer les nouvelles versions automatiquement",
"Your selected version only changes if you were playing the newest one.": "Votre version choisie ne change que si vous jouiez à la plus récente.",
"Appearance": "Apparence",
"Dark": "Sombre",
"Light": "Clair",
"System": "Système",
"Dark by default. System follows your computer's light or dark.": "Sombre par défaut. Système suit le mode clair ou sombre de votre ordinateur.",
"Language": "Langue",
"System follows your computer's language: {language} here. A string not translated yet shows in English.": "Système suit la langue de votre ordinateur : ici {language}. Un texte pas encore traduit s'affiche en anglais.",
"While playing": "Pendant le jeu",
"Stay": "Rester",
"Minimise": "Réduire",
"Hide": "Masquer",
"The launcher stays open while you play.": "Le launcher reste ouvert pendant que vous jouez.",
"The launcher goes down when the game starts, and comes back when it closes.": "Le launcher se réduit quand le jeu démarre et revient quand il se ferme.",
"The launcher hides when the game starts, and comes back when it closes.": "Le launcher se masque quand le jeu démarre et revient quand il se ferme.",
"Show the opening": "Afficher l'ouverture",
"The wordmark, while the release list loads.": "Le logo, pendant que la liste des versions se charge.",
"{version} available": "{version} disponible",
"Up to date": "À jour",
"up to date": "à jour",
"Checking…": "Vérification…",
"downloading {version}, {percent}%": "téléchargement de {version}, {percent} %",
"Updated to {version}. Restart the launcher to use it.": "Mis à jour vers {version}. Redémarrez le launcher pour l'utiliser.",
"the update failed: {error}": "la mise à jour a échoué : {error}",
"Install": "Installer",
"Restart": "Redémarrer",
"Check now": "Vérifier",
"Open": "Ouvrir",
"Data folder": "Dossier des données",
"Logs": "Journaux",
"crash reports beside it": "rapports de plantage à côté",
"Applications menu": "Menu des applications",
"Horizon and its icon are in your desktop's menu.": "Horizon et son icône sont dans le menu de votre bureau.",
"Not in your desktop's menu.": "Pas dans le menu de votre bureau.",
"Microsoft account (coming soon)": "Compte Microsoft (bientôt)",
"Microsoft account": "Compte Microsoft",
"Another account": "Un autre compte",
"Microsoft sign-in is coming soon. Until then the game plays offline: single player, LAN and servers in offline mode work.": "La connexion Microsoft arrive bientôt. D'ici là, le jeu se joue hors ligne : solo, LAN et serveurs en mode hors ligne fonctionnent.",
"Coming soon": "Bientôt",
"Microsoft account · plays": "Compte Microsoft · joue",
"Microsoft account · signed in": "Compte Microsoft · connecté",
"Asking Microsoft…": "Demande à Microsoft…",
"Add another account.": "Ajoutez un autre compte.",
"Copy code": "Copier le code",
"Open link": "Ouvrir le lien",
"Play as": "Jouer en tant que",
"Play offline": "Jouer hors ligne",
"Sign out": "Se déconnecter",
"Offline": "Hors ligne",
"Microsoft": "Microsoft",
"Offline, the game's name": "Hors ligne, le nom du jeu",
"Manage accounts": "Gérer les comptes",
"Offline name": "Nom hors ligne",
"Offline name · plays": "Nom hors ligne · joue",
"A name, 3 to 16 letters": "Un nom, de 3 à 16 lettres",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Jouez hors ligne sous un autre nom, et changez depuis la barre du haut. Les serveurs en ligne n'acceptent pas les noms hors ligne.",
"Add": "Ajouter",
"Skin…": "Skin…",
"Forget": "Oublier",
"{name}'s skin": "Skin de {name}",
"{name}'s skin is kept.": "Le skin de {name} est enregistré.",
"{name}'s cape is kept.": "La cape de {name} est enregistrée.",
"Choose a skin…": "Choisir un skin…",
"Default skin": "Skin par défaut",
"Classic arms": "Bras classiques",
"Slim arms": "Bras fins",
"Choose a cape…": "Choisir une cape…",
"No cape": "Pas de cape",
"Default": "Par défaut",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "Un PNG de skin 64×64 (ou 64×32) et un PNG de cape 64×32, gardés par le launcher et donnés au jeu quand ce nom joue. Le jeu dessine encore les skins par défaut ; il utilisera ceux-ci dès qu'il le pourra. Skin et cape d'un compte Microsoft : bientôt.",
"Accent colour": "Couleur d'accent",
"Horizon's violet by default. Every colour keeps text as easy to read.": "Le violet d'Horizon par défaut. Chaque couleur garde le texte aussi lisible.",
"Choose": "Choisir",
"Choose a picture…": "Choisir une image…",
"Landscape": "Paysage",
"Your picture, behind Home, cropped to fill it.": "Votre image, derrière l'accueil, recadrée pour le remplir.",
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Le paysage d'Horizon. Choisissez votre propre image PNG, JPEG ou WebP.",
"Home background": "Fond de l'accueil",
"Play": "Jouer",
"Running": "En cours",
"Stopped": "Arrêté",
"Start the game, or run a server for others to join.": "Lancez le jeu, ou ouvrez un serveur que d'autres peuvent rejoindre.",
"No version installed yet.": "Aucune version installée pour l'instant.",
"Choose a version": "Choisir une version",
"Host a server": "Héberger un serveur",
"Preparing…": "Préparation…",
"Prepare": "Préparer",
"Prepare again": "Préparer à nouveau",
"Not found. Choose the folder where your launcher keeps Minecraft.": "Introuvable. Choisissez le dossier où votre launcher garde Minecraft.",
"Ready to play": "Prêt à jouer",
"Resources": "Ressources",
"Minecraft folder": "Dossier Minecraft",
"Choose…": "Choisir…",
"Playing offline": "Jeu hors ligne",
"Online servers need a Microsoft account.": "Les serveurs en ligne demandent un compte Microsoft.",
"Accounts…": "Comptes…",
"The first preparation takes a few minutes.": "La première préparation prend quelques minutes.",
"Horizon crashed": "Horizon a planté",
"When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Pour le signaler sur Discord, joignez le rapport de plantage (ou collez-le avec Copier le rapport) et dites ce que vous faisiez.",
"The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Le jeu s'est arrêté sans rapport de plantage. Pour le signaler sur Discord, joignez latest.log du dossier des journaux et dites ce que vous faisiez.",
"Open report": "Ouvrir le rapport",
"Copied": "Copié",
"Copy report": "Copier le rapport",
"Open logs folder": "Ouvrir le dossier des journaux",
"Report on Discord": "Signaler sur Discord",
"Dismiss": "Fermer",
"INSTANCES": "INSTANCES",
"SERVERS": "SERVEURS",
"HIDE": "MASQUER",
"Website": "Site web",
"Ready": "Prêt",
"Needs preparing": "À préparer",
"{version} is not installed": "{version} n'est pas installée",
"No version installed": "Aucune version installée",
"Modpack": "Modpack",
"From Horizon's registry": "Du registre d'Horizon",
"Instance": "Instance",
"Edit…": "Modifier…",
"Copy…": "Copier…",
"Export…": "Exporter…",
"Close export": "Fermer l'export",
"Launch…": "Lancement…",
"Close launch": "Fermer le lancement",
"Open folder": "Ouvrir le dossier",
"Join a friend": "Rejoindre un ami",
"Paste an invite code": "Collez un code d'invitation",
"Paste the code a friend shows when they invite you.": "Collez le code qu'un ami affiche quand il vous invite.",
"Join": "Rejoindre",
"Shortcut": "Raccourci",
"Remove shortcut": "Retirer le raccourci",
"today": "aujourd'hui",
"yesterday": "hier",
"{days} days ago": "il y a {days} jours",
"{time} in total · last played {when}": "{time} au total · joué pour la dernière fois {when}",
"Not played yet": "Pas encore joué",
"under a minute": "moins d'une minute",
"PARTNER": "PARTENAIRE",
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Pas préparé. Horizon a besoin des fichiers de votre propre Minecraft {version}.",
"Prepared for Minecraft {version}": "Préparé pour Minecraft {version}",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Préparé pour Minecraft {version}, sans ses sons : le jeu sera muet. Préparez à nouveau pour les copier depuis votre installation de Minecraft."
}
+157
View File
@@ -0,0 +1,157 @@
{
"Home": "Главная",
"Mods": "Моды",
"Servers": "Серверы",
"Versions": "Версии",
"Patch notes": "Список изменений",
"Settings": "Настройки",
"Accounts, updates, and the launcher itself.": "Аккаунты, обновления и сам лаунчер.",
"Account": "Аккаунт",
"Updates": "Обновления",
"Launcher": "Лаунчер",
"Network": "Сеть",
"Game": "Игра",
"AI": "ИИ",
"About": "О программе",
"Install new versions automatically": "Устанавливать новые версии автоматически",
"Your selected version only changes if you were playing the newest one.": "Выбранная версия меняется, только если вы играли в самую новую.",
"Appearance": "Оформление",
"Dark": "Тёмное",
"Light": "Светлое",
"System": "Системное",
"Dark by default. System follows your computer's light or dark.": "По умолчанию тёмное. «Системное» следует светлой или тёмной теме компьютера.",
"Language": "Язык",
"System follows your computer's language: {language} here. A string not translated yet shows in English.": "«Системный» следует языку компьютера: здесь {language}. Ещё не переведённый текст показывается по-английски.",
"While playing": "Во время игры",
"Stay": "Оставаться",
"Minimise": "Свернуть",
"Hide": "Скрыть",
"The launcher stays open while you play.": "Лаунчер остаётся открытым, пока вы играете.",
"The launcher goes down when the game starts, and comes back when it closes.": "Лаунчер сворачивается при запуске игры и возвращается, когда она закрывается.",
"The launcher hides when the game starts, and comes back when it closes.": "Лаунчер скрывается при запуске игры и возвращается, когда она закрывается.",
"Show the opening": "Показывать заставку",
"The wordmark, while the release list loads.": "Логотип, пока загружается список версий.",
"{version} available": "Доступна {version}",
"Up to date": "Обновлено",
"up to date": "обновлено",
"Checking…": "Проверка…",
"downloading {version}, {percent}%": "загрузка {version}, {percent}%",
"Updated to {version}. Restart the launcher to use it.": "Обновлено до {version}. Перезапустите лаунчер, чтобы применить.",
"the update failed: {error}": "обновление не удалось: {error}",
"Install": "Установить",
"Restart": "Перезапустить",
"Check now": "Проверить",
"Open": "Открыть",
"Data folder": "Папка данных",
"Logs": "Журналы",
"crash reports beside it": "отчёты о сбоях рядом",
"Applications menu": "Меню приложений",
"Horizon and its icon are in your desktop's menu.": "Horizon и его значок есть в меню рабочего стола.",
"Not in your desktop's menu.": "Нет в меню рабочего стола.",
"Microsoft account (coming soon)": "Аккаунт Microsoft (скоро)",
"Microsoft account": "Аккаунт Microsoft",
"Another account": "Другой аккаунт",
"Microsoft sign-in is coming soon. Until then the game plays offline: single player, LAN and servers in offline mode work.": "Вход через Microsoft скоро появится. А пока игра работает офлайн: одиночная игра, LAN и серверы в офлайн-режиме.",
"Coming soon": "Скоро",
"Microsoft account · plays": "Аккаунт Microsoft · играет",
"Microsoft account · signed in": "Аккаунт Microsoft · вход выполнен",
"Asking Microsoft…": "Запрос к Microsoft…",
"Add another account.": "Добавьте ещё один аккаунт.",
"Copy code": "Скопировать код",
"Open link": "Открыть ссылку",
"Play as": "Играть за",
"Play offline": "Играть офлайн",
"Sign out": "Выйти",
"Offline": "Офлайн",
"Microsoft": "Microsoft",
"Offline, the game's name": "Офлайн, имя от игры",
"Manage accounts": "Управление аккаунтами",
"Offline name": "Офлайн-имя",
"Offline name · plays": "Офлайн-имя · играет",
"A name, 3 to 16 letters": "Имя, от 3 до 16 букв",
"Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Играйте офлайн под другим именем и переключайтесь в верхней панели. Онлайн-серверы не принимают офлайн-имена.",
"Add": "Добавить",
"Skin…": "Скин…",
"Forget": "Забыть",
"{name}'s skin": "Скин {name}",
"{name}'s skin is kept.": "Скин {name} сохранён.",
"{name}'s cape is kept.": "Плащ {name} сохранён.",
"Choose a skin…": "Выбрать скин…",
"Default skin": "Скин по умолчанию",
"Classic arms": "Обычные руки",
"Slim arms": "Тонкие руки",
"Choose a cape…": "Выбрать плащ…",
"No cape": "Без плаща",
"Default": "По умолчанию",
"A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "PNG скина 64×64 (или 64×32) и PNG плаща 64×32 хранятся в лаунчере и передаются игре, когда играет это имя. Пока игра рисует стандартные скины; она начнёт использовать эти, как только сможет. Скин и плащ аккаунта Microsoft: скоро.",
"Accent colour": "Цвет акцента",
"Horizon's violet by default. Every colour keeps text as easy to read.": "По умолчанию фиолетовый Horizon. С любым цветом текст читается так же легко.",
"Choose": "Выбрать",
"Choose a picture…": "Выбрать картинку…",
"Landscape": "Пейзаж",
"Your picture, behind Home, cropped to fill it.": "Ваша картинка за главной страницей, обрезанная по размеру.",
"Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Пейзаж Horizon. Выберите свою картинку PNG, JPEG или WebP.",
"Home background": "Фон главной",
"Play": "Играть",
"Running": "Запущено",
"Stopped": "Остановлен",
"Start the game, or run a server for others to join.": "Запустите игру или сервер, к которому смогут подключиться другие.",
"No version installed yet.": "Пока не установлено ни одной версии.",
"Choose a version": "Выбрать версию",
"Host a server": "Открыть сервер",
"Preparing…": "Подготовка…",
"Prepare": "Подготовить",
"Prepare again": "Подготовить заново",
"Not found. Choose the folder where your launcher keeps Minecraft.": "Не найдено. Выберите папку, где ваш лаунчер хранит Minecraft.",
"Ready to play": "Готово к игре",
"Resources": "Ресурсы",
"Minecraft folder": "Папка Minecraft",
"Choose…": "Выбрать…",
"Playing offline": "Игра офлайн",
"Online servers need a Microsoft account.": "Для онлайн-серверов нужен аккаунт Microsoft.",
"Accounts…": "Аккаунты…",
"The first preparation takes a few minutes.": "Первая подготовка занимает несколько минут.",
"Horizon crashed": "Horizon вылетел",
"When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Сообщая об этом в Discord, приложите файл отчёта о сбое (или вставьте его через «Скопировать отчёт») и расскажите, что вы делали.",
"The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Игра закрылась без отчёта о сбое. Сообщая об этом в Discord, приложите latest.log из папки журналов и расскажите, что вы делали.",
"Open report": "Открыть отчёт",
"Copied": "Скопировано",
"Copy report": "Скопировать отчёт",
"Open logs folder": "Открыть папку журналов",
"Report on Discord": "Сообщить в Discord",
"Dismiss": "Закрыть",
"INSTANCES": "СБОРКИ",
"SERVERS": "СЕРВЕРЫ",
"HIDE": "СКРЫТЬ",
"Website": "Сайт",
"Ready": "Готово",
"Needs preparing": "Нужна подготовка",
"{version} is not installed": "{version} не установлена",
"No version installed": "Версия не установлена",
"Modpack": "Сборка модов",
"From Horizon's registry": "Из каталога Horizon",
"Instance": "Сборка",
"Edit…": "Изменить…",
"Copy…": "Копировать…",
"Export…": "Экспорт…",
"Close export": "Закрыть экспорт",
"Launch…": "Запуск…",
"Close launch": "Закрыть запуск",
"Open folder": "Открыть папку",
"Join a friend": "Присоединиться к другу",
"Paste an invite code": "Вставьте код приглашения",
"Paste the code a friend shows when they invite you.": "Вставьте код, который друг показывает, когда приглашает вас.",
"Join": "Войти",
"Shortcut": "Ярлык",
"Remove shortcut": "Убрать ярлык",
"today": "сегодня",
"yesterday": "вчера",
"{days} days ago": "{days} дн. назад",
"{time} in total · last played {when}": "всего {time} · последняя игра: {when}",
"Not played yet": "Ещё не запускалась",
"under a minute": "меньше минуты",
"PARTNER": "ПАРТНЁР",
"Not prepared. Horizon needs the files of your own Minecraft {version}.": "Не подготовлено. Horizon нужны файлы вашего собственного Minecraft {version}.",
"Prepared for Minecraft {version}": "Подготовлено для Minecraft {version}",
"Prepared for Minecraft {version}, without its sounds: the game will be silent. Prepare again to copy them from your Minecraft install.": "Подготовлено для Minecraft {version}, но без звуков: игра будет беззвучной. Подготовьте заново, чтобы скопировать их из вашей установки Minecraft."
}
+115
View File
@@ -638,6 +638,33 @@ pub struct Accounts {
pub accounts: Vec<Account>,
/// The UUID of the account the game starts with; none plays offline.
pub selected: Option<String>,
/// Offline names the player keeps, to switch between (`--name`): no account, no skin.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub offline: Vec<String>,
/// The offline name that plays when no account is selected; none: the name the game made up
/// (`horizon-player.txt`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offline_selected: Option<String>,
/// Each offline name's skin and cape (`skins`), by the name in lowercase.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub looks: std::collections::BTreeMap<String, crate::skins::Look>,
}
/// Who plays, as the quick switch lists them.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Player {
/// A Microsoft account, by UUID.
Microsoft(String),
/// An offline name.
Offline(String),
/// Offline as the name the game made up.
GameName,
}
/// Whether `name` can be an offline name: 3 to 16 letters, digits or `_`, as account names are.
pub fn valid_offline_name(name: &str) -> bool {
(3..=16).contains(&name.chars().count())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
impl Accounts {
@@ -687,6 +714,94 @@ impl Accounts {
}
Ok(())
}
/// Who plays now.
pub fn player(&self) -> Player {
match (self.selected(), &self.offline_selected) {
(Some(a), _) => Player::Microsoft(a.uuid.clone()),
(None, Some(name)) => Player::Offline(name.clone()),
(None, None) => Player::GameName,
}
}
/// Plays as `who`: an account, an offline name (kept), or the game's own name.
pub fn play_as(&mut self, who: Player) -> Result<(), String> {
match who {
Player::Microsoft(uuid) => {
if !self.accounts.iter().any(|a| a.uuid == uuid) {
return Err("That account isn't signed in.".into());
}
self.selected = Some(uuid);
}
Player::Offline(name) => {
let name = self.add_offline(&name)?;
self.selected = None;
self.offline_selected = Some(name);
}
Player::GameName => {
self.selected = None;
self.offline_selected = None;
}
}
Ok(())
}
/// Keeps an offline name (once, in any case); returns it as kept.
pub fn add_offline(&mut self, name: &str) -> Result<String, String> {
let name = name.trim();
if !valid_offline_name(name) {
return Err(format!(
"{name}: an offline name is 3 to 16 letters, digits or _"
));
}
if let Some(kept) = self.offline.iter().find(|n| n.eq_ignore_ascii_case(name)) {
return Ok(kept.clone());
}
self.offline.push(name.to_string());
Ok(name.to_string())
}
/// Forgets an offline name; if it was playing, the game's own name plays.
pub fn remove_offline(&mut self, name: &str) -> Result<(), String> {
let before = self.offline.len();
self.offline.retain(|n| !n.eq_ignore_ascii_case(name));
self.looks.remove(&name.to_ascii_lowercase());
if self.offline.len() == before {
return Err(format!("No offline name {name}."));
}
if self
.offline_selected
.as_ref()
.is_some_and(|n| n.eq_ignore_ascii_case(name))
{
self.offline_selected = None;
}
Ok(())
}
/// An offline name's look (the default: no skin of its own).
pub fn look(&self, name: &str) -> crate::skins::Look {
self.looks
.get(&name.to_ascii_lowercase())
.cloned()
.unwrap_or_default()
}
/// The game's variables for the offline name that plays: its skin and cape.
pub fn look_env(&self) -> Vec<(&'static str, String)> {
match self.name_arg() {
Some(name) => crate::skins::env(&crate::skins::dir(), name, &self.look(name)),
None => Vec::new(),
}
}
/// The game's `--name` for this run: the offline name, when one plays.
pub fn name_arg(&self) -> Option<&str> {
match self.selected() {
Some(_) => None,
None => self.offline_selected.as_deref(),
}
}
/// Sign out: the secrets leave the keychain, the account the list.
pub fn remove(&mut self, store: &dyn SecretStore, uuid: &str) -> Result<(), String> {
store.delete(uuid)?;
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more