Compare commits

...
Author SHA1 Message Date
Ludovic Marie b34c4102fb Union union/pt2-e2: #3522 d8e912738 2026-10-29 19:46:39 +01:00
Ludovic Marie 72358bd8e6 Union union/pt2-e2: #3507 011515f71 2026-10-29 19:46:38 +01:00
Ludovic Marie a1cd36c2e1 Union union/pt2-e2: #3405 95c234cc1 2026-10-29 19:46:37 +01:00
Ludovic Marie 60468ef090 Union union/pt2-e2: #3401 1a01f5ef5 2026-10-29 19:46:36 +01:00
Ludovic Marie b8ab9f88a3 Union union/pt2-e2: #3374 a717d6d58 2026-10-29 19:46:35 +01:00
Ludovic Marie b497de4773 Union union/pt2-e2: #3373 1ffe68635 2026-10-29 19:46:33 +01:00
Ludovic Marie ccb9bd06f6 Union union/pt2-e2: #3372 217216e26 2026-10-29 19:46:32 +01:00
Ludovic Marie be45c9bdcb Union union/pt2-e2: #3370 825f8f884 2026-10-29 19:46:32 +01:00
Ludovic Marie 1ead734255 Union union/pt2-e2: #3369 0771f59bc 2026-10-29 19:46:31 +01:00
Ludovic Marie b92890c9de Union union/pt2-e2: #3366 52cc53a4f 2026-10-29 19:46:30 +01:00
Ludovic Marie 6cc879f0d8 Union union/pt2-e2: #3360 bd59c0bab 2026-10-29 19:46:29 +01:00
Ludovic Marie a9932bdba1 Union union/pt2-e2: #3163 481251832 2026-10-29 19:46:29 +01:00
Ludovic Marie c22beae2ad Union union/pt2-e2: #2872 b36a63fb3 2026-10-29 19:46:28 +01:00
Ludovic Marie fb14d12753 Union union/pt2-e2: #2847 c455bb6ec 2026-10-29 19:46:27 +01:00
Ludovic Marie 463e6f0a16 Union union/pt2-e2: #2821 a11e54e7b 2026-10-29 19:46:26 +01:00
Ludovic Marie a42c6f9f97 Union union/pt2-e2: #2689 c566e69c6 2026-10-29 19:46:25 +01:00
Ludovic Marie 584c3260e3 Union union/pt2-e2: #2577 ae72e1f6c 2026-10-29 19:46:25 +01:00
Ludovic Marie 7335d3d3d1 Union union/pt2-e2: #2570 c7aca8489 2026-10-29 19:46:24 +01:00
Ludovic MarieandClaude Opus 5.5 d8e912738b Merge main into the branch: the HDR graphics page shares the video title key
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:46:02 +01:00
Ludovic Marie cbea22fbb6 Merge pull request #3831 from horizon-rust/perf/streaming-results-no-wait
🤖 Agent: merging. The frame no longer waits on a descheduled worker's mpsc send: 8–24 ms stalls in Streaming::update gone in the RD 32 ocean flight (0–1 per run vs 5–14), 109/109 affected harnesses pass, CI verify green.
2026-10-29 19:43:45 +01:00
Ludovic MarieandClaude Opus 5.5 c7aca8489f Merge main into the branch: keep the world icon beside the preset check
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:42:46 +01:00
Ludovic Marie 319876a186 Merge pull request #2795 from s1f102500012/launcher-simplified-chinese
🤖 Agent: correction from pr-triage-1: my first push of the main merge missed one line (`Language::ALL` still said six), so it didn't build; 46ac49ad0 adds it and merges main once more. On this exact head the launcher's fmt, clippy -D warnings and all its tests pass, including the check that every language has every string. Sorry for the noise.
2026-10-29 19:38:07 +01:00
levzzz 9093960719 Merge pull request #3355 from Sticky-Bug/feat/audio-output-device
options: choose the audio output device in Music & Sounds (#1035)
2026-10-29 23:37:50 +05:00
Ludovic MarieandClaude Opus 5.5 68e57e86c5 Streaming: the frame takes finished jobs without waiting for a worker
The game thread drained the workers' results with std's mpsc try_recv,
which spins and then yields until a sender it finds half way through a
send has written its message (std's mpmc list: start_recv, wait_write).
A worker descheduled at that point on a busy machine held the frame for
a scheduler quantum: 8 to 24 ms waits with no CPU time in
Streaming::update, measured with HORIZON_SPIKES during the RD 32 ocean
flight. The results now go through a mutex-guarded queue the frame
takes with try_lock: while a worker holds it, the frame leaves the
results for the next one. The blocking waits (save, wait, drop) keep
their timeouts on a condvar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:28:52 +01:00
Ludovic MarieandClaude Opus 5.5 46ac49ad05 Launcher: Language::ALL holds seven languages, formatted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:02:12 +01:00
Ludovic Marie d23ab5ae50 Merge remote-tracking branch 'origin/main' into pt1-2795 2026-10-29 19:02:03 +01:00
Ludovic MarieandClaude Opus 5.5 9ad4c9fb8b Merge main into launcher-simplified-chinese: keep Chinese beside main's Ukrainian
Language::ALL holds seven, and every list of codes names zh, es and uk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:01:03 +01:00
levzzz c7065027b1 Merge pull request #3461 from senjinthedragon/hdr-wayland
HDR: Auto Paper White and Peak Brightness, from what the compositor reports
2026-10-29 22:52:21 +05:00
rdbt b97e39e074 Merge pull request #3800 from horizon-rust/chore/deprecate-in-frame
Deprecate --in-frame (unsupported, will be removed soon)
2026-10-29 17:49:28 +00:00
levzzz 1deaac7263 Merge pull request #3820 from horizon-rust/fix/prepare-output-version-2
horizon-prepare: OUTPUT_VERSION 2 (the command registries now export selected_entities)
2026-10-29 22:48:11 +05:00
rdbt ef66d5b892 Merge branch 'main' into chore/deprecate-in-frame 2026-10-29 17:41:01 +00:00
Ludovic MarieandClaude Opus 5.5 b732e582b9 Merge main into feat/audio-output-device: keep soundDevice beside main's new option keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:38:36 +01:00
levzzzandClaude Opus 5.5 f145c903b6 Merge main into hdr-wayland (with #3176 and #3203 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:38:32 +05:00
levzzz d009ebd140 Merge pull request #3203 from senjinthedragon/hdr-screen
HDR: its settings get a screen of their own
2026-10-29 22:38:18 +05:00
Ludovic Marie 0c49146fc8 Merge remote-tracking branch 'origin/main' into fix/prepare-output-version-2 2026-10-29 18:37:49 +01:00
Ludovic Marie cbbdf773e2 Merge pull request #3817 from GIGABAIT93/fix/launcher-language-count
Launcher: Language::ALL holds six languages (main doesn't build)
2026-10-29 18:34:25 +01:00
Ludovic Marie e1207bb017 Merge pull request #3453 from d3grando/fix-3357
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:34:17 +01:00
Ludovic Marie 94a737918a Merge pull request #3443 from d3grando/fix-shulker-box-menu
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:34:10 +01:00
Ludovic MarieandClaude Opus 5.5 7ed5855e4a horizon-prepare: OUTPUT_VERSION 2 (command registries export selected_entities)
#3410 made the command parse oracle, which also exports
command-registries.json during preparation, write "selected_entities". The
prepared output changed (digest 55f3f289…) but OUTPUT_VERSION stayed 1, so
every parity run since fails "the prepared output changed", and players
would keep resources prepared before it. #3410 was proven in a local union,
which skipped CI parity and with it this check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:32:53 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) 011515f715 Merge main into the branch; the poison test module joins the forgiveness tests
Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-29 12:30:56 -05:00
levzzz e3cbe2688f Merge pull request #2911 from masa-masa-0314/fix/world-select-icons
Select World: show each world's preview icon
2026-10-29 22:30:37 +05:00
levzzz 7dc72d6c33 Merge pull request #3582 from Username59138/fix/command-block-screen
Important fix: command blocks keep their command, suggest commands and dim the screen as vanilla
2026-10-29 22:30:23 +05:00
Ludovic Marie f4e1a98127 Merge pull request #3446 from d3grando/fix-3407
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:27:36 +01:00
levzzzandClaude Opus 5.5 e5087bdb5a Merge main into hdr-screen (with #3176 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:27:34 +05:00
levzzz 0e7918fb88 Merge pull request #3176 from senjinthedragon/hdr-ui
HDR: UI Brightness, and the HUD and menus never lift
2026-10-29 22:27:20 +05:00
levzzz e5076cd6ab Merge pull request #3230 from Mik00000/fix/spawner-mob-display
Display mob inside spawners and trial spawners (fixes #3107)
2026-10-29 22:23:08 +05:00
levzzz 3d4cade73b Merge pull request #3544 from degepie/fix/issue-2841-frog-jump
Frogs long jump: FrogAi's long jump activity (fixes #2841)
2026-10-29 22:22:45 +05:00
levzzzandClaude Opus 5.5 1332c90868 Merge main into the branch; the command box types AltGr characters as main's other text boxes do
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:22:27 +05:00
levzzz 1b38710e92 Merge pull request #2970 from kacpherek/fix/multiplayer-safety-notice
Show the multiplayer safety notice on the first Multiplayer click
2026-10-29 22:20:02 +05:00
levzzz aaa301b2e5 Merge pull request #2924 from elythepreciouscat/feat/vanilla-controls-26-3
Options: add Java 26.3 controls
2026-10-29 22:19:46 +05:00
levzzzandClaude Opus 5.5 3052227936 HDR Auto: watch the display only while HDR output is on
The Wayland watch was made whenever the surface offered HDR, so with
HDR off (the default) the renderer still did a blocking dispatch on
winit's connection at startup. It's now made when set_hdr turns HDR
output on and dropped when it turns off; the next display_changed
applies what it first reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:19:42 +05:00
levzzz 95a366fe89 Merge pull request #3049 from kacpherek/feat/render-distance-128
Raise the render distance cap from 64 to 128 chunks
2026-10-29 22:19:30 +05:00
levzzz fc0e275414 Merge pull request #3229 from Mik00000/fix/bow-arrow-types
Support firing spectral and tipped arrows from bows and crossbows
2026-10-29 22:19:13 +05:00
levzzz 5dc0515248 Merge pull request #3148 from echo-ilovech3ss/fix/middle-click-pick-block-selection
Select the picked hotbar slot in singleplayer and LAN
2026-10-29 22:18:57 +05:00
levzzzandClaude Opus 5.5 20a3c3e399 Merge main, #3176 and #3203 into hdr-wayland: Auto beside Black Level and UI Brightness
Paper White and Peak keep their Auto step (0) on Graphics: HDR, where
#3203 moved them; Black Level and UI Brightness are saved, parsed and
passed to set_hdr beside the Auto nits. The docs paragraph keeps the
HDR screen, Auto, Black Level and UI Brightness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:18:50 +05:00
levzzz 0a6020695b Merge pull request #3131 from Mik00000/fix/invisible-frames-custom-heads
Fix invisible item frames (#2925)
2026-10-29 22:18:42 +05:00
levzzzandClaude Opus 5.5 dfde0ac091 Merge main and #3176 into hdr-screen: all five HDR settings on HDR's screen
Main gained Black Level (#3145) and #3176 adds UI Brightness; both
move from Display to Graphics: HDR with the rest, in main's order
(Black Level, Peak, Paper White, UI Brightness), and wait for the HDR
switch like the other sliders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:16:24 +05:00
GIGABAIT93andClaude Opus 5.5 dc274d681c Launcher: Language::ALL holds six languages
Merging main into #3625 put Spanish and Ukrainian both in Language::ALL
but left its length at 5, so the launcher no longer builds on main
(E0308: expected an array with a size of 5, found one with a size of 6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:14:26 +02:00
levzzzandClaude Opus 5.5 246a071dbc Merge main into hdr-ui: UI Brightness beside main's Black Level
Main gained HDR Black Level (#3145). Both settings are kept: the
params slot after `pq` is the black level and the next one is the UI
brightness; set_hdr takes both; options save hdrBlackLevel and
hdrUiBrightness; the UI Brightness row follows Paper White.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:14:22 +05:00
levzzz 1e965ec5a8 Merge pull request #3749 from reservedbytes/fix/jmath-atan2
fix(core): atan2 matches StrictMath
2026-10-29 22:12:08 +05:00
levzzzandClaude Opus 5.5 a7ff35cf7b Merge horizon-rust/main into fix/world-select-icons
Resolves two conflicts:
- gfx.rs: main made padded_row/copy_frame free functions (copy_frame takes
  the device and size) and dropped write_screenshot's COPY_SRC check for
  the OpenGL readback target; take_readback now calls the free copy_frame
  and write_screenshot keeps the ScreenshotRequest signature.
- remote.rs leave_server: keeps main's hud_disconnected (which resets the
  boss overlay), overlay_time and item_activation reset, plus this branch's
  world_icon = None.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:10:21 +05:00
levzzz 3e31a55950 Merge pull request #3644 from iLostName/invisible-player-third-person
invisibility: the player's own body is hidden in third person and the inventory, its armour still drawn
2026-10-29 22:10:15 +05:00
levzzz 4e910746ce Merge pull request #3625 from GIGABAIT93/feat/ukrainian
Launcher: Ukrainian
2026-10-29 22:10:04 +05:00
levzzz 06254caea3 Merge pull request #3751 from leifiyoo/fix/creative-drop-items
Creative display drops reach the server on a server connection (#3523)
2026-10-29 22:07:59 +05:00
levzzz 7b78be8d4e Merge pull request #3738 from GIGABAIT93/fix/server-pack-overlay-cache
Server resource packs: keep the overlay when the pack is only touched
2026-10-29 22:07:53 +05:00
levzzz 73602536bb Merge pull request #3717 from 1038april/feat/own-name-tag
Extras: show your own name tag in third person
2026-10-29 22:07:47 +05:00
levzzz d601fc2e54 Merge pull request #3701 from Domanffe/fix/launcher-nightly-label
Launcher: a Nightly row in Versions reads as its commit
2026-10-29 22:05:21 +05:00
levzzz 9ca1b032ce Merge pull request #3673 from d3grando/fix-3001
Type AltGr characters into chat and search boxes on Windows
2026-10-29 22:05:16 +05:00
levzzz 5b8898d018 Merge pull request #3660 from xFedeT/fix/mod-settings-at-title
Mods: Options > Mods > Settings opens the mod's page at the title screen
2026-10-29 22:05:10 +05:00
levzzz 28a4e5443f Merge pull request #3652 from d3grando/fix-3211
The mouse wheel selects a bundle's item in the creative inventory
2026-10-29 22:05:04 +05:00
levzzz dc05ba0cc7 Merge pull request #3641 from iLostName/pack-mcmeta-folder-entry
resource packs: a zip that stores pack.mcmeta as pack.mcmeta/ loads, as ZipFile.getEntry finds it
2026-10-29 22:04:58 +05:00
Ludovic MarieandClaude Opus 5.5 2c8b7ee172 Merge main into the branch: keep both lang test blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:04:37 +01:00
levzzz 74cf4f8c53 Merge pull request #3646 from d3grando/fix-3525
Shader packs: a program the shader compiler panics on no longer crashes the game
2026-10-29 22:04:15 +05:00
levzzz 335d11e35e Merge pull request #3640 from d3grando/fix-3552
Graphics settings: the scrollbar can be held and dragged
2026-10-29 22:02:16 +05:00
levzzz cc7bcbf4e5 Merge pull request #3731 from Joansitoh/refactor/launcher-fields-share-line-edit
Launcher: every text field uses the shared LineEdit
2026-10-29 22:01:18 +05:00
levzzz 182b9a1473 Merge pull request #3775 from martipops/split/05-viewports
Render: the splitscreen layout model (where each player's view goes)
2026-10-29 22:01:03 +05:00
levzzzandClaude Opus 5.5 cb3f4387db Merge main into the invisibility branch: render_layers beside main's cape, and no cape while invisible
Main added CapeLayer (mobs::render::cape) at the spot this branch adds render_layers;
both stay. CapeLayer draws only when !state.isInvisible, so the player's own cape is
left out under Invisibility too, in the world and the inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:00:59 +05:00
levzzz db0d65ee3c Merge pull request #3771 from elythepreciouscat/fix/linux-opengl-taa
Linux: OpenGL falls back when TAA pipeline cannot compile
2026-10-29 22:00:57 +05:00
levzzz b328f4d660 Merge pull request #3765 from iLostName/far-terrain-render-distance-drop
far terrain: chunks past a shrunk render distance stay drawn until their tiles are ready
2026-10-29 22:00:51 +05:00
levzzz 37f8b6a525 Merge pull request #3145 from senjinthedragon/hdr-black
HDR: a black level setting, for displays whose black isn't 0
2026-10-29 22:00:46 +05:00
levzzz 3a9d71b2db Merge pull request #3726 from martipops/split/01-compositor
Render: present offscreen views through a window compositor
2026-10-29 22:00:40 +05:00
levzzz 0a1a6908c0 Merge pull request #3705 from arismoko/upstream-mod-id-order
Allocate mod item IDs in deterministic registry order
2026-10-29 22:00:34 +05:00
Ludovic MarieandClaude Opus 5.5 95c234cc1e Merge main into the branch
Keep main's Component::Instrument beside this branch's StewEffects (enum,
test and item_wire_probe).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:00:27 +01:00
levzzz 201ae4d255 Merge pull request #3774 from martipops/split/04-pointer-ownership
Input: one player owns the keyboard, mouse and pointer (splitscreen groundwork)
2026-10-29 21:59:25 +05:00
levzzz 918ff50c7a Merge pull request #3752 from xFedeT/fix/player-scale-attribute
Player model: /attribute minecraft:scale scales the model, not only the camera
2026-10-29 21:59:19 +05:00
levzzz e2ce8e42a6 Merge pull request #3739 from UMCEKO/mods/item-components
Content mods: items patch their template's data components
2026-10-29 21:59:13 +05:00
levzzzandClaude Opus 5.5 5815bf4f11 jmath::atan2: use the fdlibm port geometry::trig already has
horizon_core::geometry::trig::fdlibm_atan2 is the same e_atan2.c port, and it
passes every vector of atan2_matches_strictmath, so jmath calls it instead of
keeping a second copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:58:48 +05:00
levzzzandClaude Opus 5.5 9c21a95409 Merge main into feat/ukrainian: Ukrainian beside main's Spanish
Both languages stay in Language; the translation test walks Language::ALL; the CLI's
help, its error and the docs now list es too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:57:20 +05:00
levzzz 91ae68828a Merge pull request #3699 from jaszkus/fix/creative-trash-tooltip
Creative inventory: the Destroy Item tooltip follows the language
2026-10-29 21:55:58 +05:00
levzzz a6bcea674c Merge pull request #3667 from xFedeT/fix/mod-structure-suggestions
Mods: structures, pools and templates from content mods show in command suggestions
2026-10-29 21:55:52 +05:00
levzzz 938ae3ca47 Merge pull request #3650 from d3grando/fix-3171
Draw lightning bolts: sample inside the white sprite
2026-10-29 21:55:47 +05:00
levzzz 5b87045fee Merge pull request #3632 from SU4G3/fix/horizon-ctl-drop-remote
horizon-ctl drop: let the server throw the stack on a client of a server
2026-10-29 21:55:41 +05:00
levzzz 25776d1a4c Merge pull request #3602 from GIGABAIT93/fix/pack-cache-test-mtime
Pack cache tests: wait for the filesystem clock's next tick after each build
2026-10-29 21:54:52 +05:00
levzzz 247b67a77d Merge pull request #3587 from GIGABAIT93/fix/windows-pr-ready
Python tools and tests: the same results on Windows
2026-10-29 21:54:45 +05:00
levzzz 47e8375fd1 Merge pull request #3547 from NikitaRTN/test/nonblocking-disconnect
Net: guard fast server disconnect against a stalled reader
2026-10-29 21:54:39 +05:00
levzzz e4a131f5c7 Merge pull request #3470 from d3grando/bench-windows
perf_bench: run on Windows (and pass vanilla the manifest's -XX flags)
2026-10-29 21:54:33 +05:00
Ludovic Marie 4f792eb7de Merge pull request #3803 from horizon-rust/docs/agent-attribution-template
🤖 Agent: docs only (AGENTS.md + PR template); verify, launcher and parity-scope green; queued.
2026-10-29 17:53:05 +01:00
levzzz e431a41ab1 Merge pull request #3698 from iLostName/smooth-chunk-arrival
chunk loading: a worker frees the meshes the frame has uploaded
2026-10-29 21:52:02 +05:00
levzzz dfabf62567 Merge pull request #3691 from 1038april/fix/map-hands
Draw the first-person arms under a held map
2026-10-29 21:51:54 +05:00
levzzz 7dd05db9be Merge pull request #3683 from repositorykreml1n/main
Add files via upload
2026-10-29 21:51:46 +05:00
levzzz d0f6223a90 Merge pull request #3654 from d3grando/fix-3510
Screenshots on OpenGL draw the frame where it can be read back
2026-10-29 21:51:40 +05:00
levzzz c457259ae2 Merge pull request #3649 from d3grando/fix-3597
startup: report why resource loading failed instead of "loader stopped"
2026-10-29 21:51:32 +05:00
levzzz 088107b407 Merge pull request #3626 from GIGABAIT93/feat/ukrainian-game
Game: Horizon's own strings in Ukrainian
2026-10-29 21:51:26 +05:00
levzzzandClaude Opus 5.5 ec86a343c5 Merge main into fix-3552
graphics_ui.rs: main passes the settings to layout, content_height and scroll;
the held scrollbar's code and tests take them too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:51:11 +05:00
levzzzandClaude Opus 5.5 21fdd33cdf Merge main into fix-3525
crash.rs: keep main's report_watchdog beside this branch's payload_text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:49:57 +05:00
levzzz ee6cf6acb8 Merge pull request #3609 from degepie/fix/issue-3040-cursor-shapes
Title buttons and creative tabs show the pointing hand under the mouse (#3040, #3042)
2026-10-29 21:47:46 +05:00
levzzz b0831e54c8 Merge pull request #3606 from degepie/fix/issue-3028-splash-sounds
Splash sounds and particles when the player falls into water (#3028)
2026-10-29 21:47:40 +05:00
levzzz f1341139ff Merge pull request #3556 from d3grando/fix-3173
Jukebox: a server's client plays the song of level event 1010
2026-10-29 21:47:34 +05:00
Ludovic MarieandClaude Opus 5.5 4f9cc17e91 AGENTS.md: contributors' agents name their own person
The attribution rule gave Ludovic's exact line first, so contributors' agents
copied it: #3800 (rdbtCVS) opened as "Ludovic's AI agent", #3499 as "AI
agent (Codex), not by himself". The rule is now a template filled with the
login the PR is opened from; Ludovic's line stays as a labelled example. The
PR template carries the same line as a comment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:47:28 +01:00
levzzz 8913deaa76 Merge pull request #3384 from SolomonNumb1/fix/smithing-ingredients
сlean up station recipes
2026-10-29 21:46:25 +05:00
levzzz 66d731cbf0 Merge pull request #3608 from degepie/fix/issue-3008-phantom-hitbox
Draw the phantom's model inside its box (#3008)
2026-10-29 21:44:48 +05:00
levzzz acb209dbf7 Merge pull request #3337 from degepie/fix/perf-bench-javac-java-home
perf_bench: compile the driver mod with JAVA_HOME's javac
2026-10-29 21:44:03 +05:00
rdbtCVSandClaude Sonnet 5.5 74f0265db1 Deprecate --in-frame, --no-server-thread and HORIZON_SERVER_THREAD=0
The in-frame single-player path is unsupported and will be removed soon.
The game prints a deprecation warning when it is requested, the help text
and docs say never to use it, and the e2e drivers warn on their flags.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:43:43 +00:00
levzzzandClaude Opus 5.5 fe47804f28 Merge main into hdr-wayland: display_changed beside main's TAA set_world_post
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:43:21 +05:00
levzzz e8214a02aa Merge pull request #3177 from senjinthedragon/screenshot-log
Screenshots are named in local time, and the log says where they went
2026-10-29 21:41:49 +05:00
levzzz 49f4359fcf Merge pull request #3089 from Expected333/vr/view-set
render: draw from a ViewSet of per-eye views (VR groundwork)
2026-10-29 21:41:44 +05:00
levzzzandClaude Opus 5.5 f12d87b5d9 Merge main into hdr-screen: the HDR offer beside main's log, and the test on main's slider API
Main's graphics screen now takes the settings in layout and extra_rows, and a
slider press starts a drag (SliderDrag) that saves on release (#2711).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:40:34 +05:00
levzzzandClaude Opus 5.5 0b63d040ea Merge main into hdr-ui: hdrUiBrightness beside main's taa and upscaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:38:13 +05:00
levzzzandClaude Opus 5.5 b19d8659ff Merge main into hdr-black: settings now also save taa and upscaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:37:24 +05:00
levzzzandClaude Opus 5.5 f9b002f804 Merge horizon-rust/main into fix/spawner-mob-display
Resolves the conflict in game/remote.rs tick_remote: keeps both the
remote spawner tick and main's client_tick_beacons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:31:36 +05:00
Ludovic Marie 64e298952d Merge pull request #3054 from kacpherek/fix/elytra-flying-sound
🤖 Agent: PR triage (Ludovic's AI agent, Claude): main merged in (ff). ElytraOnPlayerSoundInstance.tick line for line (stop when removed or after 20 ticks without isFallFlying, volume clamp(speed/4), silent 20 ticks then a 20-tick fade-in, pitch 1+(v-0.8) above 0.8), started like LocalPlayer.onSyncedDataUpdated when gliding starts. #3227 (same port) closed as its duplicate. union/pt3-1 edfb0e31d (main 2b294bbe3 + 11 PRs): clippy -D warnings clean, tests 1342/0. CI parity on this head.
2026-10-29 17:22:36 +01:00
Ludovic Marie cad2a29d2a Merge pull request #3759 from horizon-rust/feat/official-server-launcher
🤖 Agent: merging. Horizon Official at the top of the launcher's Multiplayer list, once, sharing the game's marker (Ludovic, 2026-10-29). pr_ready --quick green (launcher 273 tests); verify and the launcher matrix green.
2026-10-29 17:14:12 +01:00
levzzz a7fa9ad7bf Merge pull request #2964 from kacpherek/fix/tutorial-hints
Show tutorial hints and track their progress
2026-10-29 21:09:53 +05:00
levzzz c9cfa4a192 Merge pull request #3391 from ThatCakeIsAPie/perf/far-saved-chunks
Far terrain: stop decoding saved chunks for every distant column
2026-10-29 21:08:48 +05:00
Ludovic Marie 8d4196a29b Merge pull request #3761 from horizon-rust/feat/official-server-game
🤖 Agent: merging. Horizon Official at the top of Multiplayer, once (Ludovic, 2026-10-29). pr_ready --quick green (945 tests); verify green; parity runs in CI.
2026-10-29 17:06:50 +01:00
levzzz 7c681e0ed2 Merge pull request #3336 from matyden/mobs/rabbit-ocelot-avoid-player
Rabbits and wild ocelots run from players
2026-10-29 21:04:29 +05:00
Ludovic MarieandClaude Opus 5.5 a11e54e7b8 Merge main into the branch
Keep #3543's hurt flash for other players beside the main-hand stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:01:41 +01:00
levzzz 42e7d94082 Merge pull request #3479 from ThatCakeIsAPie/feat/datapack-worldgen-4
Harness: chunks generated with a world's data packs against vanilla
2026-10-29 20:59:01 +05:00
levzzz 5f7341487f Merge pull request #3478 from ThatCakeIsAPie/feat/datapack-worldgen-3
Data packs: a world's packs change its world generation
2026-10-29 20:58:44 +05:00
levzzz a5fba2ddac Merge pull request #3477 from ThatCakeIsAPie/feat/datapack-worldgen-2
Worldgen: the generator loads a world's data packs over the bundles
2026-10-29 20:58:25 +05:00
levzzz f93b7cd0fa Merge pull request #3476 from ThatCakeIsAPie/feat/datapack-worldgen-1
Worldgen: data packs' entries over the prepared bundles
2026-10-29 20:58:07 +05:00
levzzzandClaude Opus 5.5 bda07e1a84 Tutorial: an in-frame host acting for a guest leaves its own tutorial alone
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:55:22 +05:00
levzzzandClaude Opus 5.5 f065bab2e4 Merge main into the branch; the zombie villager's cure step gets the level random
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:55:13 +05:00
levzzz cefa6153d1 Merge remote-tracking branch 'hr/main' into fix-3544 2026-10-29 20:54:41 +05:00
Ludovic Marie ae175cc968 Merge pull request #3769 from horizon-rust/fix/launcher-keychain-portal
🤖 Agent: launcher matrix 40903932985 green at this head (Linux build compiles the new keychain store); queued.
2026-10-29 16:54:22 +01:00
levzzz 42ad9192e3 Merge remote-tracking branch 'hr/main' into HEAD 2026-10-29 20:53:35 +05:00
levzzz 8065b2830a Merge pull request #2927 from hectoreccles/perf/terrain-rustgen-ports
Worldgen: cut aquifer, carver and fill costs on the terrain stage
2026-10-29 20:53:22 +05:00
levzzz 0b3cf8d4f0 Merge pull request #3390 from ThatCakeIsAPie/perf/far-tiles-coarse
Far tiles: with Reduced far detail, approximate wide cells
2026-10-29 20:53:00 +05:00
levzzz 93921eccc4 Merge pull request #2981 from kacpherek/fix/dialogs-packs-parity
Server dialogs and resource packs: draw every dialog control, open links, keep pack order and animation metadata like vanilla
2026-10-29 20:52:26 +05:00
levzzz d1f4cd22ce Merge pull request #3409 from degepie/fix/issue-3142-totem-activation
Totem of undying: the item activation animation on screen (#3142)
2026-10-29 20:51:50 +05:00
levzzz 29c7ce8c7c Merge pull request #3380 from degepie/fix/goat-horn-instrument-sync
Goat horns keep their instrument from the server (#3181)
2026-10-29 20:51:39 +05:00
levzzz 869ffec2a1 Merge pull request #3543 from degepie/fix/issue-2827-hurt-red-tint
Hurt players flash red for the other players too (#2827)
2026-10-29 20:51:29 +05:00
levzzz adfff67c88 Merge pull request #3410 from degepie/fix/issue-3223-tp-uuid-suggestion
Suggest the crosshair entity's UUID for entity arguments (#3223)
2026-10-29 20:51:17 +05:00
levzzz 732f6eff1b Merge pull request #2982 from kacpherek/fix/server-core-parity
Server core: keep-alive timeout, pause grace, watchdog and function permission like vanilla
2026-10-29 20:51:07 +05:00
levzzz 15469c5501 Merge pull request #2818 from sirrayi/fix/rate-limit-property
Dedicated server: apply server.properties rate-limit
2026-10-29 20:50:56 +05:00
Ludovic Marie 0ac347d5b0 Merge pull request #2823 from sirrayi/fix/port-pack-unsafe-names
🤖 Agent: verified by Ludovic's AI agent (Claude): output paths stay inside the output folder; main merged, verify green. Queued for the merger.
2026-10-29 16:44:46 +01:00
levzzzandClaude Opus 5.5 80405878b5 Cats and ocelots sprint when they dash
Cat and Ocelot.customServerAiStep set sprinting while the move control
runs at 1.33 (fleeing a player) and the crouching pose at 0.6 (tempted).
LivingEntity.setSprinting adds SPEED_MODIFIER_SPRINTING (+0.3F,
ADD_MULTIPLIED_TOTAL) to the movement speed, which Horizon only used for
the model pose, so a fleeing ocelot or stray cat walked 1.3x too slowly.

The step now runs between navigation and the move control, as in
Mob.serverAiStep, and sets Extra.sprinting and the pose. A sprinting mob
also gets the rest of vanilla's isSprinting: the speed modifier, the
sprint jump impulse, the 0.9 water slowdown, no fluid-falling gravity
and the two random draws of spawnSprintParticle. The server sends shared
flag 0x08, the CROUCHING pose and the sprinting modifier; the client
reads them, and the feline model takes its pose from them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:43:04 +05:00
ThatCakeIsAPieandClaude Opus 5.5 52cc53a4f9 Surface rules: an empty biome list keeps the name lookup
An empty `biome_is` list on a surface that does not number its biomes
compiled to the id comparison, since collecting no indices never asks
`biome_index`. It then failed with MissingBiomeProvider where vanilla and
main answer false (material_condition's codec-corpus condition 49). It
keeps the name lookup main uses. Review on #3366.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:41:38 -05:00
ElyTheCatandAntigravity 1cb3aa5af6 Linux: OpenGL falls back when TAA pipeline cannot compile
The OpenGL backend's GLSL translator (naga) does not support `textureLoad`
from depth textures. When starting on OpenGL (such as on Linux with Intel
HD 4000 via Mesa crocus), `Post::new` panicked while compiling `fs_taa`
in `world_post.wgsl` (`WGSL textureLoad from depth textures is not
supported in GLSL`), crashing the game on startup.

Wrap TAA pipeline creation in an error scope, matching the animated
sprites compute pipeline fallback in `animate.rs`. When TAA cannot
compile, log a warning and disable TAA, letting the world post pass fall
back to FXAA or direct presentation without panicking.

Co-Authored-By: Antigravity <[email protected]>
2026-10-29 19:10:48 +03:30
Ludovic MarieandClaude Opus 5.5 7db3ecb67f Launcher: keep Microsoft sign-ins on Linux desktops that set GTK_USE_PORTAL, and say when the keychain doesn't
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:39:36 +01:00
Ludovic MarieandClaude Opus 5.5 32967a2f0f Merge main into this branch; the ominous bottle test's registries get dialog_tags
main's new item_sync test builds a Dynamic by hand; it takes the rest
(dialog_tags) from Default, as this branch's other tests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:38:38 +01:00
Ludovic Marie 62a23ba025 Merge pull request #3325 from ThatCakeIsAPie/feat/create-world-data-packs
🤖 Agent: verified by Ludovic's AI agent (Claude): follows CreateWorldScreen's temp data pack folder flow; main merged, clippy and the datapack/title/pack screen tests pass, verify green. Queued for the merger.
2026-10-29 16:37:10 +01:00
Ludovic Marie fff16ae2ed Merge remote-tracking branch 'origin/main' into pt3/2981 2026-10-29 16:35:43 +01:00
Ludovic Marie 2cacd5561c Merge pull request #3725 from horizon-rust/feat/launcher-java-join
🤖 Agent: launcher matrix 40899770886 green at this head; queued.
2026-10-29 16:32:56 +01:00
Gitlab Main 8fdbad2d4b Merge origin/main into feat/audio-output-device 2026-10-29 20:58:32 +05:30
Ludovic MarieandClaude Opus 5.5 f5811c797a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:25:16 +01:00
Ludovic MarieandClaude Opus 5.5 34c5dce056 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:25:16 +01:00
Ludovic MarieandClaude Opus 5.5 5e6bed2e42 Merge main into this branch; the column fill uses the palette scan
#3253 (on main) replaced BlockSection's local_ids map with a scan of the
live palette and a last-lookup cache. fill_column now finds the state's id
with id_in, clears the cache when the old state's slot is freed, and sets
it when the section becomes uniform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:23:23 +01:00
Ludovic Marie 8bb23aa1f7 Merge remote-tracking branch 'origin/main' into pt3/2927 2026-10-29 16:21:14 +01:00
levzzz a491181961 Merge pull request #3253 from hectoreccles/perf/opt-01-storage
perf: storage primitives (dirty sections, packed index, palette scan, Fx maps)
2026-10-29 20:18:07 +05:00
levzzz 4f69f45167 Merge pull request #3450 from d3grando/fix-3319
End the dragon fight when /kill removes the dragon
2026-10-29 20:16:49 +05:00
levzzz 2f9d889dcc Merge pull request #3367 from ThatCakeIsAPie/perf/worldgen-decoration-reads
Decoration: inlined block reads, column scans, per-chunk heightmap reads
2026-10-29 20:16:24 +05:00
levzzz a459a00584 Merge pull request #3365 from ThatCakeIsAPie/perf/worldgen-biome-stage
Biome stage: build free neighbour biome chunks first, cheaper id lookups
2026-10-29 20:16:13 +05:00
levzzz ede6d6dfbd Merge pull request #3364 from ThatCakeIsAPie/perf/worldgen-structure-climate
Structure checks: one climate volume per column, and each worker keeps its samples
2026-10-29 20:16:02 +05:00
levzzz 2cf38dd095 Merge pull request #3368 from ThatCakeIsAPie/perf/far-tiles-biome-memo
Far tiles: resolve each biome quart once per tile, and a far-tile bench
2026-10-29 20:15:34 +05:00
Ludovic Marie 89f4b34c1f Merge remote-tracking branch 'origin/main' into feat/launcher-java-join
# Conflicts:
#	launcher/src/main.rs
2026-10-29 16:15:13 +01:00
Ludovic MarieandClaude Opus 5.5 929e75181b Launcher Multiplayer: Horizon Official on top of the list, once
The Multiplayer section puts "Horizon Official" (horizon.bunchy7s.dev) first in the instance's
servers.dat, as the game does: once (horizon_official_server.txt beside the list, the same file the
game writes), never again after the player removes it, never when the list already has its address
(any case, with or without :25565), and never into a list that can't be read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:12:59 +01:00
Federico TaglianiandClaude Sonnet 5.5 647e7c2aca Player model: /attribute minecraft:scale scales the model, not only the camera
LivingEntityRenderer.submit scales the whole pose stack by the entity's
scale. Horizon scaled the player's eye height, box and camera distance but
drew the model at scale 1, so a player with scale 2 looked half its size
from the pulled-back camera. The local player's model, and what hangs off it
(arms, held items, cape, elytra, armour), now takes the attribute; the
inventory preview divides it out as extractEntityInInventory does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:11:10 +01:00
martipopsandAntigravity db34a746d1 Render: viewports and layout model for shared windows (split_layout)
- Add split_layout with place, place_ordered, ordered, and rows
- Calculate viewports for 1 to 16 players sharing a window (halves, thirds, quarters, balanced rows)
- Add 8 unit tests verifying boundary tiling, non-overlapping views, and area preservation
- Re-export split_layout in horizon-client

Co-Authored-By: Antigravity <[email protected]>
2026-10-29 11:10:42 -04:00
Ludovic Marie 84c5e0b63b Merge pull request #3561 from d3grando/fix-3243
🤖 Agent: read in full (pr-triage-1). A keyboard key bound to attack/use keeps clicking while held, as KeyboardHandler.keyPress sends every repeat to KeyMapping.set/click; horizon-ctl 'repeat' keeps the key held. Unit test added.
2026-10-29 16:07:52 +01:00
Ludovic MarieandClaude Opus 5.5 77a6cf7182 Multiplayer: Horizon Official, bunchy7s's survival server, listed once at the top
At startup, when a person is playing (not a server, benchmark or automated run), the game puts
"Horizon Official" (horizon.bunchy7s.dev) first in servers.dat, once: horizon_official_server.txt
beside it remembers that it was offered, so a player who deletes it never gets it back, and a list
that already has its address (any case, with or without :25565) is left as it is.

servers::prepend inserts the entry at the root level, so hidden entries and fields the codec
doesn't know are kept, and leaves an unreadable file alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:59:26 +01:00
Ludovic Marie 7e42cc8ece Merge pull request #2745 from levzzz5154/fix/flowing-water-mobs
🤖 Agent: verified by Ludovic's AI agent (Claude): the review points are addressed (tagged pigs, enclosed arenas, cleanup); flow-push and bubble-column pass on the server thread and in-frame (current carries 2.8/2.7 blocks vs 0 in still water; the column lifts higher than plain water). Tools only. Queued for the merger. Re-run at 9039a268f (arenas now built at y 100, husk line back to main's): lan_e2e flow-push and bubble-column PASS on the server thread (flowing 2.87 vs still 0.0; column 1.404 vs plain 0.785) and in-frame (2.776 vs 0.0; 1.42 vs 0.725).
2026-10-29 15:54:46 +01:00
leifiyooandClaude Haiku 5.5 bb6305da25 Keep creative display drops on a server connection
A throw on an item tab's display slot (Q or a right-click in the creative
screen) dropped the stack with a local Player.drop. On a server connection
(single player's server thread, LAN, multiplayer) that item entity was
never sent to the server, so it vanished. Send the stack as
SetCreativeModeSlot to slot -1 instead, as the survival tab's throw and
vanilla's handleCreativeModeItemDrop already do.

Vanilla: CreativeModeInventoryScreen.slotClicked (item tab THROW),
MultiPlayerGameMode.handleCreativeModeItemDrop.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-29 15:54:24 +01:00
Ludovic Marie 0a95d1c047 Merge pull request #3404 from degepie/fix/issue-3306-zombify-villager
🤖 Agent: verified by Ludovic's AI agent (Claude) on union/pt2-a 7e322f591 (main + 8 PRs): fmt, workspace clippy -D warnings, horizon-client/protocol/command/client-core/world tests, fast build; lan_e2e hurt, cure-discount, commands, piston-push, hands, helduse on the server thread and in-frame, plus portals (server thread): all pass (cure-discount: 14 wheat for the cured villager's emerald, host and guest). Queued; the merger waits for CI parity on this head.
2026-10-29 15:51:13 +01:00
martipopsandAntigravity dd7414486b Input: pointer ownership and view offset per player
- Track keyboard_mouse ownership and view_at viewport offset on Game
- Ignore cursor grabs, visibility changes, and pointer warps when not owning keyboard/mouse
- Offset cursor warps by view_at in shared windows
- Track physical_buttons and clear on focus loss
- Add capture unit tests for controller-only games and viewport offsets

Co-Authored-By: Antigravity <[email protected]>
2026-10-29 10:47:58 -04:00
reservedbytes d385d188f3 fix(core): atan2 matches StrictMath 2026-10-29 13:47:57 -01:00
iLostName d81f99ba8f far terrain: chunks past a shrunk render distance stay drawn until their tiles are ready
Lowering the render distance (the Performance preset after 32 or 64) let the coarsest far tiles stand in for the chunks it left: the tiles planned there had been inside the full-detail area, so never sampled, and the 64-block cells of the roots showed flat plateaus with stone walls for minutes. Those columns now stay left out, their meshes drawn, until the tile planned over each is ready.
2026-10-29 15:37:26 +01:00
levzzz 0dea3a89e2 Merge remote-tracking branch 'hr/main' into pr-3336 2026-10-29 19:36:08 +05:00
Ludovic Marie 2286d42b7f Merge pull request #2973 from kacpherek/fix/advancement-triggers-summon-loot-craft
🤖 Agent: requeued by contrib-gate-28 after merging main (B9 render crate split; stopped on a conflict or a known flake). fmt, clippy -D warnings and tests pass on the new head; proven earlier by cg26/cg27.
2026-10-29 15:31:29 +01:00
levzzz b869bdcacd Merge pull request #3591 from d3grando/fix-3185
Wolves: any target makes a wolf angry, so an alerted pack looks angry
2026-10-29 19:25:29 +05:00
Claude ba6575314c Draw the empty main hand on the main arm, and hide it while invisible
renderArmWithItem draws an empty main hand with renderPlayerArm on the
player's main arm (the left one when the main hand is set to left) and
only when the player isn't invisible. hand_geometry always drew the right
arm and drew it while invisible. The map arms (#3444) already follow
both rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-30 00:23:45 +10:00
GIGABAIT93andClaude Opus 5.5 f383fdf74c Server resource packs: keep the overlay when the pack is only touched
Joining a server whose resource pack is already in the download cache
marks the file used (HttpUtil.updateModificationTime, ported as touch),
and the overlay cache keyed a ZIP pack by its stamp, modified time
included. Every join therefore rebuilt the whole overlay and reloaded
from thousands of new files: on Windows, where Defender scans each, a
join waited about a minute on "Preparing resources" (a 1.2 MB Nexo
pack on tensa.co.ua). A ZIP is now known by its bytes, one file read;
directory packs keep their stamps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:22:06 +02:00
Joansitoh 1582cc638f Merge origin/main into refactor/launcher-fields-share-line-edit
The Post-exit field main added is a LineEdit too.
2026-10-29 15:15:34 +01:00
umcekoandClaude Opus 5.5 5c66b27c9a Content mods: items patch their template's data components
An item file may carry `components`, a 26.3 data component patch over its
template's: `minecraft:<type>` sets one, `!minecraft:<type>` with `{}`
removes it. A modded sword gets its own damage and durability, a tool its
mining speed, without a template per stat. Types no vanilla item has are
refused, so a typo fails the load. Issue #3305, item 3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
Ludovic MarieandClaude Opus 5.5 1a01f5ef59 Merge main into the branch
Main added block_model_vertices_tinted (one tint for every tinted face)
for mob_blocks.rs; it now calls this branch's tinted_block_model_vertices
with that tint for tint indices >= 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:10:01 +01:00
ThatCakeIsAPie a717d6d583 Merge feat/upgrade-datapack-3: refuse pack entries that leave the pack 2026-10-29 09:08:50 -05:00
ThatCakeIsAPie 1ffe686350 Merge feat/upgrade-datapack-2b: refuse pack entries that leave the pack 2026-10-29 09:08:50 -05:00
ThatCakeIsAPie 217216e263 Merge feat/upgrade-datapack-2a: refuse pack entries that leave the pack 2026-10-29 09:08:48 -05:00
ThatCakeIsAPie 825f8f8845 Merge feat/upgrade-datapack-1: refuse pack entries that leave the pack 2026-10-29 09:08:47 -05:00
ThatCakeIsAPieandClaude Opus 5.5 0771f59bca upgrade-datapack: refuse pack entries that leave the pack
A zip entry named `../x` or `/x` (or `C:\x` on Windows) was written as it
was, outside `<world>/datapacks/<name>-26.3/`. `read_pack` now takes each
entry's name from `enclosed_name()` and refuses a pack with one that has
anything but plain components, and `upgrade_into` refuses to write such a
path. Review on #3369.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:08:46 -05:00
Ludovic MarieandClaude Opus 5.5 c455bb6ec7 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:04:30 +01:00
Joansitoh 17864b867d Merge remote-tracking branch 'origin/main' into refactor/launcher-fields-share-line-edit 2026-10-29 14:56:12 +01:00
Joansitoh 09146bfecf Launcher: rustfmt 2026-10-29 14:56:09 +01:00
Ludovic MarieandClaude Opus 5.5 f2337d0a73 Launcher: a server's Join starts a Java instance into it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:52:27 +01:00
levzzzandClaude Opus 5.5 e852db5e60 A wolf angry at the player stays angry across a reload
On the server thread a reloaded wolf ticks before the player joins, and
the anger ended because no player counted as no valid target. Vanilla's
updatePersistentAnger ends it only once it finds the player and the player
is in creative or spectator, or the difficulty is peaceful.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:50:37 +05:00
levzzz f781769177 Merge remote-tracking branch 'hr/main' into pr-3591 2026-10-29 18:49:06 +05:00
Joansitoh 3e83f8a48a Merge origin/main into refactor/launcher-fields-share-line-edit
The proxy field and the new Java-instance, launch, switch and multiplayer
fields use LineEdit too.
2026-10-29 14:46:50 +01:00
martipopsandCodex f9a97f264f Add a compositor for offscreen frames on one GPU device
Expose renderer frames and surface handoff, with a standalone two-view GPU smoke example. Part 1 of the splitscreen series; normal game rendering is unchanged.

Co-Authored-By: Codex <[email protected]>
2026-10-29 09:34:38 -04:00
degepie 894848c17b Merge remote-tracking branch 'origin/main' into fix/goat-horn-instrument-sync 2026-10-29 18:29:19 +05:00
jaszkusandjaszkus's AI agent 4c1f8f24bd Creative inventory: the Destroy Item tooltip follows the language
CreativeModeInventoryScreen builds the destroy slot's tooltip from inventory.binSlot; the screen drew the English literal instead, so it stayed English in every language. Read the key through the same translate helper the tab titles use, keeping the English text as the fallback.

Co-Authored-By: jaszkus's AI agent <[email protected]>
2026-10-29 14:29:05 +01:00
Claude f4f9897c9e Extras: show your own name tag in third person
A new extra, Own name tag (ownNameTag, off by default), under a Third
person header on the Extras page. With it on, the player's own model in
third person gets the name tag other players see: the profile name in
its team's colours, the below-name score, faded while crouching. It goes
through the same checks as other players' tags (team name tag
visibility, distance, F1) and hides in first person, while sleeping, in
spectator mode and while invisible. Vanilla never names the camera
entity, so this stays off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-29 23:26:36 +10:00
Ludovic MarieandClaude Opus 5.5 201ea8b393 Merge main into the branch
Main's hud_disconnected() now resets the boss overlay; the item
activation reset stays after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:19:40 +01:00
Alexander KrylovandHermes Agent 9cbde2b525 Launcher: a Nightly row in Versions reads as its commit
Every row on that page is a Nightly now, and its title was the install
folder name (nightly-2026-10-29-4baa571) because version_tag only strips
the v of a release tag. nightly::label turns it into "Nightly · 4baa571";
the date stays beside it, where both call sites already print it.

Fix #3700.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 16:12:53 +03:00
levzzzandClaude Opus 5.5 35eb60e2f1 Merge main into launcher-simplified-chinese: keep Spanish and Chinese
Both languages stay in Language::ALL; the PR's loop over every language
replaces main's fixed list in the completeness test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:12:09 +05:00
degepieandClaude Opus 5.5 8537e29c92 Merge remote-tracking branch 'origin/main' into fix/issue-3137-suspicious-stew
Conflicts:
- game/stations.rs: keep both tooltip lines, the ominous bottle's before
  the suspicious stew's, in ItemStack.addDetailsToTooltip's component order.
- item_tooltip.rs: keep both tests; the stew test now expects main's
  zero-padded durations ("00:03", "00:07").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 18:06:00 +05:00
Ludovic MarieandClaude Opus 5.5 191b66303a Merge main into this branch
tools/port_publish.py: main already writes the store path with Posix
separators and ~ (the same fix), so main's version is kept; the roadmap
UTF-8 reads and writes remain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:05:50 +01:00
Ludovic MarieandClaude Opus 5.5 92b8883039 Merge main into the branch
Keep main's target_point (absolute coordinates, block hits only) beside
the crosshair entity's UUID for entity suggestions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:56:51 +01:00
Ludovic Marie c784e572d7 Merge remote-tracking branch 'origin/main' into pt3/3253 2026-10-29 13:56:14 +01:00
levzzzandClaude Opus 5.5 479d10e794 Tutorial hints are recorded by the window only: the server thread no longer saves options.txt from its stale copy of the settings when a guest picks up a log or breaks one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:56:06 +05:00
Ludovic Marie 4812518327 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-29 13:53:24 +01:00
Ludovic MarieandClaude Opus 5.5 7daca92c4a rustfmt: sulfur cubes join the baby-flag group
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:53:01 +01:00
Ludovic MarieandClaude Opus 5.5 0c06082dfa Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:05 +01:00
Ludovic MarieandClaude Opus 5.5 e2de446128 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:02 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) 8d93530931 Review: the death forgiveness follows tellNeutralMobsThatIDied
Three corrections from the PR review, checked against 26.3:

- The area is the dying player's block cell grown 32 blocks sideways and
  10 up and down (new AABB(blockPosition()).inflate(32, 10, 32)), not
  the player's box grown 10 on every axis, which never reached the mobs
  11 to 32 blocks out.
- Only a mob whose persistent anger is at the dying player is forgiven
  (NeutralMob.playerDied's getPersistentAngerTarget gate): a wolf angry
  at a skeleton keeps its anger instead of losing the timer behind a
  live target, a state vanilla never has. The clear is then full — the
  last hurt by, the persistent target, the target and the end time —
  with the polar bear ending its flag2 through its own stop.
- The NeutralMob classes are 26.3's six: bee, enderman, iron golem,
  polar bear, wolf, zombified piglin. The panda, spider and trader
  llama are not implementers any more.

The enderman's gate is its player target (its stare aggro), the polar
bear's is flag2 behind the target, the rest anger and a player target,
the bee its own state's angry_at.

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-29 07:47:32 -05:00
levzzzandClaude Opus 5.5 f7efb0d710 The launcher's Simplified Chinese covers the strings added on main: native mods, moving an instance to the Trash, and the build's new time estimate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:45:38 +05:00
iLostName 75600ca046 chunk loading: a worker frees the meshes the frame has uploaded
Freeing the hundreds of meshes a chunk border brings made the render thread wait on the allocator behind the busy meshing and generation workers, up to 20 ms of a frame at each border while flying at render distance 32. The renderer now hands them back to Streaming, whose workers free them ahead of other jobs, as vanilla's compile task closes its mesh data on its own thread. Refs #1890.
2026-10-29 13:45:28 +01:00
levzzz 9da77d9d4b Merge remote-tracking branch 'hr/main' into pr-2795
# Conflicts:
#	launcher/src/i18n/tests.rs
2026-10-29 17:41:21 +05:00
repositorykreml1n 01791aba51 Add files via upload 2026-10-29 15:41:10 +03:00
levzzz b5b762b840 Merge remote-tracking branch 'hr/main' into pr-2964
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-29 17:41:04 +05:00
Federico TaglianiandClaude Sonnet 5.5 0a770a0e14 Merge main into fix/mod-structure-suggestions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 13:40:17 +01:00
Aria FigueredoandGPT-6.1 Terra 4808d801fe Allocate mod item IDs in deterministic registry order
Co-Authored-By: GPT-6.1 Terra <[email protected]>
2026-10-29 07:31:30 -05:00
Shreyas d27a7cbce3 Sulfur cubes: align babyhood and egg offspring with vanilla 26.3
- Route sulfur cube baby flag through baby_index (entry 16) in lan.rs
- Remove client-side baby state derivation from size in remote_mob_data.rs
- Revert finalize.rs age_boundary_reached and set_baby to vanilla behavior
- Ensure spawn_egg_offspring sets size 1 on new sulfur cube offspring
- Drop lists.rs change from this branch
2026-10-29 18:00:54 +05:30
levzzzandClaude Opus 5.5 9039a268f6 Build the fluid arenas up in the air, and leave the husks to main
Both fluid checks built their walls at the host's feet, in the host's chunk,
so a player standing there could be shut in stone and suffocate. They now
build at y 100, as check_piston_push does, and the last clear takes the
floor away as well. The boat dismount check's husks go back to NoAI, as on
main: that change belongs to no fluid check.

flow-push, bubble-column, digging and frames pass in that order on the
server thread and --in-frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:23:08 +05:00
levzzzandClaude Opus 5.5 c566e69c6c Tick dropped items again, and keep a cooling-down entity out of a gateway
The first commit replaced the tick_items span with the gateway handler, so
dropped items stopped falling, merging and despawning in every mode. The
call is back, next to the gateway's.

An entity that has just come through a portal carries PortalCooldown 300.
EndGatewayBlock.entityInside still triggers the gateway's own cooldown, but
Entity.setAsInsidePortal then only restarts the entity's cooldown, whatever
the portal: the gateway now asks the shared portal state first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:21:13 +05:00
Ludovic MarieandClaude Opus 5.5 461a8757e3 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:19:44 +01:00
Federico TaglianiandClaude Sonnet 5.5 d9e70d5ca7 Commands: suggest the templates of every namespace, not only minecraft's
/place template listed worldgen/structure/minecraft only, so a content
mod's templates (worldgen/structure/<ns>/...) were never suggested.
Also drop the unused templates additions from the content merge.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 13:12:07 +01:00
Ludovic MarieandClaude Opus 5.5 3b261d1081 Merge main into the branch
Keep main's 'names' check beside flow-push and bubble-column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:07:56 +01:00
hashtheboardandClaude Opus 5.5 c487bbce9e Merge origin/main into vr/view-set
gfx.rs: the frame's view_proj comes from the ViewSet's primary view, then
gets main's TAA jitter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:59:32 +01:00
Federico TaglianiandClaude Sonnet 5.5 9c5377a571 Mods: structures, sets, pools and templates from content mods reach command suggestions
The content merge wrote a mod's worldgen into the worldgen bundles but
never into command-registries.json, so /locate structure and /place
structure/jigsaw/template suggested vanilla ids only, though the commands
themselves accepted the mod's.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 12:58:28 +01:00
d3grandoandClaude Opus 5.5 63b8018505 Type AltGr characters into chat and search boxes on Windows
Windows reports AltGr as Ctrl plus Alt, and a layout's third-level
characters (ABNT2 `/` on Ctrl+Alt+Q, German `@ { [`, Polish letters)
arrive as text while a Control key is down. The chat box, the command
block's command, the world options' fields and the recipe and mod-panel
search boxes dropped any typed text while Control was held, so none of
these characters could be typed.

Vanilla's KeyboardHandler.textInput hands every code point to charTyped
whatever modifiers are held; EditBox.charTyped only checks
StringUtil.isAllowedChatCharacter. Text now reaches those fields unless
the edit modifier is held without Alt (where AltGr is Ctrl+Alt), so
Ctrl and Cmd shortcuts still type nothing.

Fixes #3001

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:46:23 +07:00
Federico TaglianiandClaude Sonnet 5.5 9c6325c1f8 Mods: Options > Mods > Settings opens the mod's page at the title screen
open_mod_screen returned early while the title was up, so the Settings
button at the title did nothing, and the mod screen was neither drawn nor
given clicks or keys there. Draw it like Options (before the title), route
its clicks and keys, and blur the panorama behind it. A mod's own screen
(ui.open-screen) still waits for a world.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 12:45:56 +01:00
Ludovic MarieandClaude Opus 5.5 dc8f01ea19 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:51 +01:00
Ludovic MarieandClaude Opus 5.5 b65c319b2a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:50 +01:00
Ludovic MarieandClaude Opus 5.5 8fb51b0c4a Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:50 +01:00
Ludovic MarieandClaude Opus 5.5 0a00df7640 Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:28:49 +01:00
d3grandoandClaude Opus 5.5 675298eac2 Screenshots on OpenGL draw the frame where it can be read back
OpenGL swapchain images offer RENDER_ATTACHMENT only, so the surface is
configured without COPY_SRC, but the screenshot path copied the swapchain
image into the readback buffer anyway: wgpu's validation aborted the game
("Usage flags RENDER_ATTACHMENT ... do not contain required usage flags
COPY_SRC") on every screenshot under the GL backend.

A frame that is read back on such a surface is now drawn into a texture
that can be copied from, read back from there, and then drawn onto the
swapchain image (wgpu::util::TextureBlitter), so the window still shows
it. Other frames, and surfaces with COPY_SRC, draw straight to the
swapchain as before. The check in write_screenshot that refused every
readback on these surfaces goes with it.

Fixes #3510

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:23:20 +07:00
d3grandoandClaude Opus 5.5 8b8ab7fd5d The mouse wheel selects a bundle's item in the creative inventory
The wheel over a bundle only reached bundle_scroll on the survival
inventory and container screens. On the creative screen it always
scrolled the item grid, and the hover tick that clears the selection
(onStopHovering) didn't run there either.

CreativeModeInventoryScreen.mouseScrolled calls
AbstractContainerScreen.mouseScrolled first, which offers the wheel to
the hovered slot's ItemSlotMouseActions (BundleMouseActions), and only
scrolls the grid when none took it. takes_bundle_wheel names the
screens that do this (inventory and container menus, and creative), and
both the wheel dispatch and tick_bundle_hover use it.

Fixes #3211

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:23:06 +07:00
d3grandoandClaude Opus 5.5 2f3f8d7a7f Draw lightning bolts: sample inside the white sprite
The bolt quads (RenderTypes.lightning has no texture) sampled the atlas at
the white sprite's corner (u0, v0). That point is on the sprite's border,
where the atlas's nearest sampler picks the neighbouring transparent texels
for most fragments, so a bolt came out as a few scattered pixels in
singleplayer and on servers alike. Sample the middle of the sprite, as the
clouds do.

Fixes #3171

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:22:54 +07:00
degepieandClaude Opus 5.5 7e459e5fb1 Frogs long jump: level and unseeded randoms, activity gate TODO
Address the review on #3544:
- goals::Cx carries the level random (the one the brains already get);
  the weighted jump-candidate pick and the three TIME_BETWEEN_LONG_JUMPS
  samples draw from it, as LongJumpToRandomPos/LongJumpMidJump do.
- The angle shuffle in calculateOptimalJumpVector uses a fresh unseeded
  random (Collections.shuffle's global one), not the frog's.
- The preferred-block roll stays on the frog's random (vanilla uses the
  body's random there).
- TODO in LongJump::can_use for FrogAi.initJumpActivity's TEMPTING_PLAYER /
  BREED_TARGET gate and LAY_SPAWN priority: frogs have no tempting,
  breeding or frogspawn here yet.

Other mobs only get the new field threaded through; nothing else reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 16:15:54 +05:00
SolomonNumb1 0dcc9a15fd smithing cleanup 2026-10-29 16:14:17 +05:00
d3grandoandClaude Opus 5.5 17e0143745 Shader packs: a program the shader compiler panics on no longer crashes the game
Photon v1.4's gbuffers programs make naga's SPIR-V writer panic
("Expression [89] is not cached!") while wgpu-hal's Vulkan backend makes
the render pipeline in PackPost::new. Nothing caught it, so the game
crashed. The pack stays picked in horizon-graphics.txt, so every later
world load crashed again until the file was edited by hand (#3525).

- crash::contain runs a closure under catch_unwind and returns its
  panic as an error. The panic hook logs such a panic with its stack
  and makes no crash report.
- pack_post::checked, which builds every pack program's pipeline under
  a validation error scope, contains a panic too. The pass is skipped
  with a note, as a validation error already skips it. The rest of the
  pack runs, and the device keeps working.
- Any other panic while loading a pack (translation, PackPost::new)
  turns the pack off. Nothing runs in its place, it is unpicked and
  saved so the next start does not load it again, and the chat says so
  in red, as a refused Horizon pack already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:11:14 +07:00
d3grandoandClaude Opus 5.5 8469dcc953 Graphics settings: the scrollbar can be held and dragged
On the Simple and Advanced graphics pages, the scrollbar was drawn but
GraphicsUi::click never checked for it. A press on it did nothing, and
a drag only moved a held slider, so the list could only be scrolled
with the wheel (#3552).

GraphicsUi now keeps its scroll in ui::Scroll, the AbstractScrollArea
port the other lists use:
- a press on the scrollbar column holds it, silently (updateScrolling);
- dragging moves the list with the pointer, and going above or below
  the list jumps to an end (mouseDragged);
- releasing lets go (onRelease).
The column is computed once, for both drawing and input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:09:08 +07:00
Ludovic MarieandClaude Opus 5.5 dffd3973bc Merge main into perf/far-tiles-biome-memo
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:12 +01:00
Ludovic MarieandClaude Opus 5.5 84185641bc Merge main into perf/worldgen-surface-biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic MarieandClaude Opus 5.5 e60455d12c Merge main into perf/worldgen-biome-stage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
Ludovic MarieandClaude Opus 5.5 043f9c7b7b Merge main into perf/worldgen-structure-climate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:07:11 +01:00
d3grandoandClaude Opus 5.5 9f28fe8903 startup: report why resource loading failed instead of "loader stopped"
The loader's result was taken by App::about_to_wait to end loading, but the
event loop polls once more after exit(): the loader had finished, its channel
was closed, and poll_menu_resources replaced the real error with "resource
loader stopped before finishing". Every failed load (missing or stale prepared
files) ended with that message.

- Loading keeps its first error and stops polling once Done has arrived.
- A loader that really panicked reports its panic message.
- Before any window or loader, a missing horizon-resources.json names the
  folder it was looked for in and how to prepare it (the launcher, or
  horizon-prepare --prepare).

Fixes #3597

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:07:05 +07:00
iLostName 88a001b7fa resource packs: a zip that stores pack.mcmeta as pack.mcmeta/ loads, as ZipFile.getEntry finds it
Java's ZipFile.getEntry falls back to the folder entry `name/` when no file has the name, and vanilla reads its bytes like a file's; Horizon left folder entries out, so Loka's server pack had no metadata and the reload failed. Fixes #3413.
2026-10-29 11:50:45 +01:00
Ludovic Marie e932a2da61 Merge remote-tracking branch 'origin/main' into upd/2924
# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-10-29 11:47:35 +01:00
Ludovic Marie edd11b6103 Merge remote-tracking branch 'origin/main' into upd/3401 2026-10-29 11:47:06 +01:00
Ludovic Marie 73ae9f6cf1 Merge branch 'upd/3478' into upd/3479 2026-10-29 11:46:40 +01:00
Ludovic Marie 69fc342a1a Merge remote-tracking branch 'origin/main' into upd/3478
# Conflicts:
#	crates/horizon-client/src/game/world_loading.rs
#	crates/horizon-client/src/vanillagen.rs
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:46:37 +01:00
Ludovic Marie ad7904946e Merge remote-tracking branch 'origin/main' into upd/3477
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:45:59 +01:00
iLostName 2677a397e5 invisibility: the player's own body is hidden in third person and the inventory, its armour still drawn
The model check looked up `minecraft:invisibility`, but the player's effects are kept without the namespace, so the body always showed. As LivingEntityRenderer.submit does, the body is now skipped unless the player's team sees friendly invisibles, while armour, elytra and held items still draw. Fixes #3615.
2026-10-29 11:42:52 +01:00
SU4G3andClaude Opus 5.5 2445108f10 horizon-ctl drop: let the server throw the stack on a client of a server
On a server, and in single player on its server thread, the window's Game
is a client: `drop` called drop_held there, which spawned a client-only
item entity and left the server's inventory untouched (the server still
counted the dropped items). It now goes through remote_drop, as the Q key
does: predict the removal and send PlayerAction DROP_ITEM / DROP_ALL_ITEMS.

The agent-control e2e drops a plank and asks the server how many are left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:27:53 +03:00
Mik00000 e0f11804f2 Address review: drop legacy readers; keep flag sync and entity_data placement 2026-10-29 12:17:39 +02:00
GIGABAIT93andClaude Opus 5.5 88ee948bc2 Game: Horizon's own strings in Ukrainian
crates/horizon-client/assets/horizon/lang/uk_ua.json translates the
game's 13 own strings (the controller options), bundled like ru_ru;
vanilla's uk_ua covers the rest of the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:15:43 +02:00
GIGABAIT93andClaude Opus 5.5 e7e5413667 Launcher: Ukrainian
launcher/lang/uk.json translates the launcher's 194 strings, Horizon's
own, written from the English; Language::Ukrainian ("uk", Українська)
joins the picker, the system language pick and `settings language`. The
translation test now covers every language but English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:15:38 +02:00
Ludovic Marie ca1b200c79 Merge remote-tracking branch 'origin/main' into upd/3391
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:09:33 +01:00
Ludovic Marie b603db6176 Merge remote-tracking branch 'origin/main' into upd/3390
# Conflicts:
#	crates/horizon-world/src/worldgen/generator.rs
2026-10-29 11:09:18 +01:00
Ludovic Marie 779b773530 Merge remote-tracking branch 'origin/main' into upd/3367
# Conflicts:
#	crates/horizon-world/src/worldgen/generator_decoration.rs
2026-10-29 11:08:25 +01:00
Mik00000 7fb1f77870 Address review: drop overlapping spectral arrow paths and remove double arms 2026-10-29 12:06:20 +02:00
GIGABAIT93andClaude Opus 5.5 8a9db2dc7b Fonts: Pixelify Sans gets the Cyrillic І
Ukrainian headings, buttons and navigation need the capital І (U+0406),
which upstream doesn't map, so it came from the fallback font, smooth
and taller than the pixel letters around it. As #2844 did for О, it
takes each weight's own Latin I glyph, the same shape. Only cmap changes
(and head's checksum adjustment); every other table, the timestamps
included, is byte-identical to the files it replaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:06:06 +02:00
Mik00000 4d13a4ebcc Address review comments: empty Spawner default, remove legacy fallbacks, use block light 2026-10-29 12:00:54 +02:00
Ludovic Marie 1d01a2d696 Merge remote-tracking branch 'origin/main' into upd/3325 2026-10-29 10:51:09 +01:00
GIGABAIT93andClaude Opus 5.5 ea01f563b4 Pack cache tests: wait for the clock's next tick after each build
The first fix covered same-length rewrites through Fixture::write only;
directory_inputs_config_and_owned_output_recovery still failed about
one run in seven under WSL2, through its own fs::write and set_times
and through files added to a directory in the build's tick. Waiting for
the filesystem's clock to move after each rebuild_matches covers every
edit that follows, as a person's next edit comes after the build. The
wait writes new bytes each time (NTFS keeps the time of a write that
changes nothing) and gives up after a second.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:35:18 +02:00
Ludovic MarieandClaude Opus 5.5 49cf77a312 Merge main into this branch, stub the tutorial in the remote mining test
game.rs/remote.rs: main's creative screen reopen (#3187) and code of conduct
reset are kept beside this branch's tutorial steps. tests/test_remote_mining.py
compiles remote.rs's mining against its own small Game, which now needs a
no-op tutorial_destroy (verify failed with E0599 there).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:33:29 +01:00
Ludovic MarieandClaude Opus 5.5 d026f1eb37 Merge main into this branch
net.rs: main's configuration loop now hands the code of conduct to the game
(Event::CodeOfConduct) instead of accepting it; this branch's UpdateTags arm is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:28:23 +01:00
Ludovic Marie 5ff61e1c42 Merge remote-tracking branch 'origin/main' into upd/2823 2026-10-29 10:26:31 +01:00
Ludovic MarieandClaude Opus 5.5 f44042e0eb Sound device: vanilla's own option names, and a missing device asks once
options.audioDevice and options.audioDevice.default come from vanilla's
language files ("Device", "System Default"): the game's own lang no longer
overrides them. Audio keeps the device asked for, so a saved device that is
unplugged plays on the default without reopening the stream at every
settings change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:05 +01:00
Ludovic MarieandClaude Opus 5.5 4a2a5b9977 Merge main into this branch after the render crate split (#3186)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:05 +01:00
levzzzandOpenAI Codex 6534db4f6d Specify the equipment regression fixture's mob registry type
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:24:18 +05:00
GIGABAIT93andClaude Opus 5.5 479f53624c Python tools: read and write UTF-8 in roadmap.py and port_publish.py
roadmap.py reads and writes ROADMAP.md and the roadmap TOML as UTF-8, so
build writes the · as git has it and build --check doesn't call a fresh
checkout out of date on Windows. port_publish.py decodes gh's and
node's output as UTF-8, so an issue body's arrows survive an update, and
writes the store path as #3441 does, so the two pull requests agree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:23:53 +02:00
levzzzandOpenAI Codex 69689b3fdd Cover named equipment replacement through save and reload
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:23:48 +05:00
GIGABAIT93andClaude Opus 5.5 5410e79811 Pack cache tests: rewrite files as an edit would, a clock tick later
On Unix the overlay cache compares stamps (length, mtime, ctime, inode),
and Linux takes them from a coarse clock, so the fixture's same-length
rewrites within one tick looked unchanged:
normalized_and_external_overlay_roots_keep_merged_bytes_current failed
about three runs in five on upstream main under WSL2. Fixture::write now
waits until the file's stamp moves when the bytes change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:19:22 +02:00
levzzzandOpenAI Codex c6dfae6dd7 Keep live enchantments when displaying and saving named equipment
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:18:51 +05:00
Ludovic MarieandClaude Opus 5.5 d7662a97da Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:35 +01:00
Ludovic MarieandClaude Opus 5.5 4623d2e59f Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:33 +01:00
Ludovic Marie 04cd6ece23 Drop the test that only exercised std::mem::replace (the fix is the one line in as_guest) 2026-10-29 10:16:08 +01:00
Ludovic Marie 9218dc3e7d Leave horizon-server's lists test as on main (not part of this fix) 2026-10-29 10:15:47 +01:00
Ludovic Marie 5b66bd0ec0 Merge remote-tracking branch 'origin/main' into upd/3148 2026-10-29 10:15:46 +01:00
levzzzandOpenAI Codex f75896ec45 Retry unloaded gateway arrivals and identify the fixture entities by tag
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:13:49 +05:00
levzzzandOpenAI Codex ae72e1f6ca Add a two-path saved vehicle tree and passenger restoration audit
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:50 +05:00
levzzzandOpenAI Codex 164cfd6b11 Restore server riders after their chunk is sent and refresh entity pairing
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:19 +05:00
levzzzandOpenAI Codex a2e111a461 Keep fluid-test pigs mobile with zero AI movement speed
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:12:18 +05:00
degepieandClaude Opus 5.5 e4568abf6e Title buttons and creative tabs show the pointing hand under the mouse
26.3 asks for a cursor from every hovered widget (AbstractWidget
handleCursor: the pointing hand, or not-allowed when inactive) and from
the creative inventory's unselected tab buttons
(CreativeModeInventoryScreen.extractTabButton: the pointing hand while
the whole-pixel mouse is strictly inside the 26x32 button). #1347 did
this for ui::button and the pack screen, but the title screen draws its
own buttons and the creative tabs never asked, so both kept the arrow.

The title screen's button loop now asks through widgets::widget_cursor
with the mouse test apart from the enabled check, and draw_creative asks
for the hand through the new Creative::tab_wants_hand.

Fixes #3040.
Fixes #3042.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 14:07:23 +05:00
Ludovic MarieandClaude Opus 5.5 64981e95b7 Screenshots: the name's doc says local time, as the code now does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:07:01 +01:00
levzzzandOpenAI Codex 7d0e5aa953 Do not save replaced main-hand components from the raw entity
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:04:47 +05:00
levzzzandOpenAI Codex 09a9735630 Keep unsupported saved vehicle types pending without respawning them
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:04:46 +05:00
levzzzandOpenAI Codex a80973335c Merge current main before rerunning End gateway cargo proof
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:03:47 +05:00
levzzzandOpenAI Codex fe867a6132 Track tagged fluid-test pigs and always clean enclosed arenas
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:02:58 +05:00
degepieandClaude Opus 5.5 d32e7ff887 Draw the phantom's model inside its box
PhantomRenderer.scale was not ported: the phantom's model is laid out about
1.3 blocks above the feet and vanilla moves it down (and 0.1875 along z)
after the size scale, so Horizon drew it above its 0.9x0.5 box. Port the
scale (1 + 0.15 * size) and the translate, and PhantomRenderer.setupRotations'
pitch.

Fixes #3008.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 14:02:41 +05:00
levzzzandOpenAI Codex ace27c11a9 Keep named held stacks current and use full death-message display names
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:01:23 +05:00
levzzzandOpenAI Codex 8742e1768e Merge current main before isolating fluid checks
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:01:20 +05:00
levzzzandOpenAI Codex c91ca07115 Merge current main and use its preset-aware terrain interface
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:50 +05:00
levzzzandOpenAI Codex c001f56d0f Merge current main before death-message review fixes
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:45 +05:00
d3grandoandClaude Opus 5.5 e086b2cfdd Wolves: any target makes a wolf angry, so an alerted pack looks angry
Wolf.aiStep ends with NeutralMob.updatePersistentAnger(level, true): a
wolf with a target is angry at it, and the 20-39 s timer
(PERSISTENT_ANGER_TIME) starts over every tick it holds one. Horizon only
set the anger in hurtServer when the player hit the wolf, so the pack
mates HurtByTargetGoal alerted attacked without the angry texture, and the
random draws of an angry wolf came in the wrong order. isAngryAt(player)
now reads the anger's target, and stopBeingAngry ends it when the time
runs out, the target mob dies, or the player is in creative, spectator
or peaceful. Saving a wolf keeps that anger target instead of replacing
it with the current target.

The mob_ai harness prints a wolf's anger and gets a wolf_hurt scenario.

Fixes #3185

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:58:42 +07:00
levzzzandOpenAI Codex b36a63fb39 Merge current main and retain vanilla features save status
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:03 +05:00
levzzzandOpenAI Codex f86c946a3c Merge current main and preserve saved vanilla vehicle trees
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:57:42 +05:00
GIGABAIT93andClaude Opus 5.5 0f4880bacd Python tools and tests: the same results on Windows
port_publish names the version store with forward slashes, so an issue
reads the same whoever publishes it (test_port_publish expected
~/code/... and Windows wrote ~\code\...), and writes the issue body as
UTF-8 for gh whatever the code page. test_roadmap reads ROADMAP.md as
UTF-8. All 408 Python tests now pass on Windows, with or without
PYTHONUTF8.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:55:09 +02:00
Username59138andClaude Opus 5.5 3451e4e29a Command blocks: keep the command in the edit screen, suggest as vanilla
Command blocks barely worked from the default single player (the window
is a client of its server thread), a LAN guest or a dedicated server:

- ServerPlayer.openCommandBlock: the server now sends the block's data
  (saveCustomOnly) to the player whose use opened it, and the open
  screen refills from it (handleBlockEntityData, updateGui). The chunk's
  update tag leaves the command out, so the screen showed an empty box
  and Done then wiped the block's command.
- Screen.extractBackground of an isInGameUi screen: the dimmed
  gradient, not the in-world menu background.
- CommandSuggestions in the command box with the arguments
  AbstractCommandBlockEditScreen passes (commands without a slash,
  completions from the start, 7 lines under the box, fill 0x80000000):
  the chat's suggestions take a SuggestLayout. Server completions
  (ask_server) reach the box too.
- horizon-ctl key presses reach the command block screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:53:14 +03:00
levzzzandOpenAI Codex bd59c0bab2 Pick up validated standard macros for body hooks
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:47:44 +05:00
levzzzandOpenAI Codex d3cb2f5c29 Pick up HEAD macro preservation in the launcher mixins pin
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:47:44 +05:00
degepieandClaude Opus 5.5 81445f5218 Splash sounds and particles when the player falls into water
Singleplayer and multiplayer run the player through the remote client
path, which dropped the motion's Splash and SplashParticle events. In
vanilla the local player's water-entry splash (Entity.doWaterSplashEffect)
is heard only on its own client: Player.playSound passes the player as
the exception, so the server's copy skips it, and the particles are the
client level's own. Play them from the remote tick as the local tick does.

Fixes #3028.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 13:39:03 +05:00
SenjinandClaude Opus 5.5 ef2ec4c0dc CI: run the checks again (horizon-server's lists test got no answer from Mojang's services)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:33:06 +01:00
d3grandoandClaude Opus 5.5 e2146572a6 Repeat attack and use clicks while a key bound to them is held
KeyboardHandler.keyPress sends a held key's repeats to KeyMapping.set and
KeyMapping.click like the press, so a keyboard key bound to key.attack
keeps attacking while it is held. Horizon dropped the repeats of keys
bound to a mouse mapping, so such a key hit once. horizon-ctl's
`key KEY repeat` now keeps a mouse-mapped key held too, as the window's
repeat does.

Fixes #3243

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:10:33 +07:00
d3grandoandClaude Opus 5.5 d94e4cb7a4 Jukebox: a server's client plays the song of level event 1010
A jukebox on a server (a multiplayer server, a LAN host, or single
player's own server thread) plays its song for the clients with level
event 1010, whose data is the song's id in the jukebox_song registry.
LevelEventHandler looks the song up in the registry the server sent and
calls playJukeboxSong: the sound, the "Now Playing" overlay and the
dancing parrots. Horizon only made the parrots dance, and started songs
only from its own ticking jukeboxes, which a server's client never has,
so the note particles showed and nothing was heard.

Level event 1010 now starts the song it names, and 1011 stops it
(stopJukeboxSongAndNotifyNearby). A server's client keeps its song until
1011 or until the song ends, instead of dropping it at once because its
own copy of the jukebox isn't playing.

Fixes #3173

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:09:38 +07:00
NikitaRTN 2b32b9142f Net: guard non-blocking disconnect against a stalled reader 2026-10-29 14:00:42 +06:00
Senjin a0964a8010 Merge remote-tracking branch 'origin/main' into hdr-wayland 2026-10-29 08:32:57 +01:00
Senjin d91bd61281 Merge remote-tracking branch 'origin/main' into hdr-screen 2026-10-29 08:32:57 +01:00
Senjin 289c2df527 Merge remote-tracking branch 'origin/main' into hdr-ui 2026-10-29 08:32:56 +01:00
Senjin 5134f8459e Merge remote-tracking branch 'origin/main' into hdr-black 2026-10-29 08:32:56 +01:00
時穂andCodex 6b96bb96b6 Merge main into launcher-simplified-chinese
Keep German and Simplified Chinese together, retain token-based translation
checks, and refresh Chinese strings for the current launcher.

Co-Authored-By: Codex <[email protected]>
2026-10-29 16:29:05 +09:00
Alexander KrylovandHermes Agent 5485e189f8 Options: Horizon's own captions read the bundled language
The options pages, the mod and shader pack lists, the agent button and the
multiplayer friend screens drew their own labels as literals, so a client with
ru_ru stayed English there. They read `horizon.*` keys now, and the bundled
namespace carries both languages for every one of them.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-29 08:35:09 +03:00
Ludovic MarieandClaude Opus 5.5 bc33470d5c Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:15 +01:00
Ludovic MarieandClaude Opus 5.5 c48c2f2167 Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 8ce20ba5bd Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:12 +01:00
Ludovic MarieandClaude Opus 5.5 74d986ca44 Merge main into this branch
game/golems.rs: main's #3123 marks a built iron golem player-created; this
branch's SUMMONED_ENTITY trigger runs after it, as in
CarvedPumpkinBlock.trySpawnGolem (setPlayerCreated, then spawnGolemInWorld).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:05 +01:00
JustKarkatandJustKarkat's AI agent (ZCode) bdac04d6ca A dying player's forgiveness reaches the neutral mobs
ServerPlayer.die asks the level for every NeutralMob within 10 blocks of
the dying player and stops its anger when the forgive_dead_players
gamerule (on by default) allows. Nothing here ran the forgiveness, so a
wolf, an iron golem, a zombified piglin or an enderman stayed angry
through a death and attacked again at once on respawn.

The anger and a player target go; a target that is not the player (an
iron golem defending its village) is not the anger. Bees keep their
anger in their own state and end it there.

Fixes #3155.

Co-Authored-By: JustKarkat's AI agent (ZCode)
2026-10-28 23:57:18 -05:00
hashtheboardandClaude Opus 5.5 cd1d9ba23b Merge origin/main into vr/view-set
gfx moved to horizon-client-render; view.rs follows it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:44:23 +01:00
d3grandoandClaude Opus 5.5 44d9bddebd Open shulker boxes with the shulker box menu on servers
ShulkerBoxBlockEntity.createMenu opens a ShulkerBoxMenu
(minecraft:shulker_box), whose ShulkerBoxSlots refuse shulker boxes. The
server (single player on its server thread, LAN, dedicated) sent a
generic_9x3 chest menu instead, so it took a shulker box put into a
shulker box, and a vanilla client drew a chest screen.

lan_open_container now sends the block entity's menu type from
horizon_client::containers::block_menu_type; the server already refuses
shulker boxes in that menu, and the client already draws it.

New harness tools/test_storage_menu_parity.py (VanillaStorageMenuOracle,
storage_menu_fixture): 31/31 blocks, 14/31 with the old menu type. New
lan_e2e check shulker_menu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:34 +07:00
Ludovic MarieandClaude Opus 5.5 984273f5c8 Merge main into this branch after the render crate split (#3186)
containers.rs moved to horizon-client-render (#3186): this branch's SkullSkin fallback (the "Name" key and a plain string profile) is carried over to the moved file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:41:28 +01:00
Ludovic MarieandClaude Opus 5.5 9f666edc44 Merge main into this branch after the render crate split (#3186)
lib.rs: hud, hud_layout, hud_overlays and inventory_effects moved to horizon-client-render; only this branch's pub mod item_activation stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:40:18 +01:00
Gitlab Main 48e731483a options: choose the audio output device in Music & Sounds (#1035)
Adds audio output device selection matching vanilla options.txt format:
- Bind soundDevice option in Settings with serialization roundtrip
- Enumerate output devices and support live stream switching in Audio
- Add SoundDevice option button to Music & Sounds page cycling devices
- Apply audio device changes dynamically on options save and startup
- Unit tests for settings parsing and options UI button cycling
2026-10-29 09:13:13 +05:30
SenjinandClaude Opus 5.5 cd70f518e9 HDR Auto: what Hyprland 0.57 reports, in the docs and the code comment
Tested on Hyprland 0.57 (always-on HDR, NVIDIA): it reports reference white
203 and 0..1060 nits, so Auto gives Paper White 203 / Peak 1060, and it shows
HDR windows below its desktop's SDR white (sdr_max_luminance 430, which the
protocol doesn't carry): a white browser page is brighter than the game's
white and than a 1000-nit patch in mpv.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:35:38 +01:00
d3grandoandClaude Opus 5.5 cc243fabd8 perf_bench: pass vanilla the manifest's -XX flags
The 26.3 manifest gives every OS -XX:StackShadowPages=32, and the official launcher passes it.
perf_bench only took the manifest's OS-conditional arguments, so vanilla started without it. On
Windows about half of those starts crash before the title screen: an access violation under
shaderc.dll while the client compiles its shaders, with no hs_err file. Measured on Windows 11
(Ryzen 7 9800X3D, RTX 5080, Java 25.0.5), 8 starts each through run_vanilla: 4 crashed without the
flag, none with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:29:52 +07:00
d3grandoandClaude Opus 5.5 f44af70080 perf_bench: run on Windows
tools/perf_bench.py stopped on Windows before or right after the first game:

- the control socket is AF_UNIX at /tmp, which Python on Windows doesn't have; the game
  there listens on a localhost TCP port named in a .json endpoint file (control.rs), so
  the driver now uses that transport when there are no Unix sockets;
- memory was read with `ps`; Windows uses the process's working set (GetProcessMemoryInfo);
- os.getloadavg doesn't exist; the report leaves load_avg_1m out there, as horizon-bench does;
- hardware() read /etc/os-release and /proc; Windows gets `ver`, the CPU brand string
  and the total RAM;
- nvidia-smi reports per-process memory as [N/A] under WDDM, which raised ValueError;
- the binary is horizon-client.exe.

A release binary passed with --build reports its own version (from its first log line)
instead of this checkout's version and commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:29:52 +07:00
Ludovic MarieandClaude Opus 5.5 f7300bd96b Merge main into fix/multiplayer-safety-notice
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:12:57 +01:00
SenjinandClaude Opus 5.5 8202f02f4e HDR: log what the compositor reports once per change
The renderer is made anew a few times while the game starts, and each asked
the compositor again and logged the same line; a slider dragged in the
desktop's HDR settings logged a line per step. Now a line comes only when the
values changed, and a change within a second of the last line waits until the
values have held for a second, so only the value they settled on is logged.
The values themselves still apply at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:50:57 +01:00
Ludovic MarieandClaude Opus 5.5 b6cd09442e World icon: wait for more than 10 rendered sections
GameRenderer.takeAutoScreenshot saves only when countRenderedSections() > 10
and every section is rendered. A window that joined its server thread
had nothing pending before its first chunks, so the icon was only sky.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:48:51 +01:00
Ludovic MarieandClaude Opus 5.5 8428ff0f59 Merge main into fix/advancement-triggers-summon-loot-craft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:43:53 +01:00
Ludovic MarieandClaude Opus 5.5 e3b1ae512f Single player: the world icon keeps its save folder through the async start
Since #3011 the window joins the server thread in poll_integrated_start,
where open_integrated's folder is no longer in scope: Starting carries it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:37:33 +01:00
Ludovic Marie 5b4dd8b72e Merge main into fix/world-select-icons 2026-10-29 01:37:27 +01:00
degepieandClaude Opus 5.5 5f269772d9 Frogs long jump: FrogAi's long jump activity (fixes #2841)
Frogs had no long jump on the server, so they only ever walked. Port the
LONG_JUMP activity as one goal: LongJumpToPreferredBlock searches the
9x5x9 box (weighted by squared distance, lily pads and big dripleaf first
half the time) for a spot FrogAi.isAcceptableLandingSpot accepts and an
8-block path cannot reach, faces it for 40 ticks and leaps along the
LongJumpUtil arc (now with its collision check); LongJumpMidJump holds the
LONG_JUMPING pose without friction until the frog lands. The
LONG_JUMP_COOLDOWN_TICKS memory counts down and is reset as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 05:23:58 +05:00
degepieandClaude Opus 5.5 955e7fe48d Hurt players flash red for the other players too
The server broadcast LivingEntity.hurtServer's damage event for mobs only,
so in multiplayer and on LAN (and single player's server thread) nobody saw
another player turn red when hit. The LAN flush now sends it for the host
and each guest whose hurt time restarted (a hit that was not blocked by a
shield), to the players around and the hurt player itself. An in-frame
host's models of its guests also carry their hurt time.

Fixes #2827.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 05:19:11 +05:00
d3grandoandClaude Opus 5.5 7211d029ab Swing the hand when Q drops a stack from an inventory slot on a server
On a server (single player included) a menu click is predicted by the
menu engine and sent, and what the prediction dropped was ignored, so
Q over a slot threw the item without the arm swing. Vanilla's
AbstractContainerMenu.doClick drops with Prediction.PREDICTED, and
LivingEntity.drop then swings the main hand on the client itself; the
server shows that swing only to the other players.

The prediction loop moves to predict_clicks, which also says whether a
click dropped something; remote_menu_ops swings the main hand then.

Fixes #3357

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:17:59 +07:00
d3grandoandClaude Opus 5.5 48342a7ef3 End the dragon fight when /kill removes the dragon
kill_dragon only dropped the entity, so the fight, still not killed,
spawned a fresh dragon on its next tick and never opened the exit
portal. EnderDragon.kill calls dragonFight.setDragonKilled after
removing the dragon: call dragon_killed there too, in the End.

The command execution corpus gets an End group that kills the fight's
dragon and probes the dragon, the exit portal and the egg.

Fixes #3319

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:08:10 +07:00
Senjin 25e00a0ecf Merge remote-tracking branch 'origin/main' into hdr-wayland
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/options.rs
2026-10-29 01:05:23 +01:00
d3grandoandClaude Opus 5.5 1d2d495686 Shields: an enchanted banner shield keeps its patterns under the glint
An enchanted shield with a banner turned white: after the patterns, the
renderer drew the shield base again with the foil for vanilla's
`patternedShieldGlint` pass. In vanilla that pass draws the glint alone
(`RenderPipelines.GLINT` with only the glint texture), but a foil quad
here is its texture with the glint over it, so the plain base covered the
base colour and every pattern.

Every layer of a patterned shield now carries the foil instead, and the
extra pass is gone. The GUI adds the glint masked by each layer's alpha
and the world shader composites it per layer, so each pixel ends with
the glint once over the layers below it, as vanilla's single glint pass
over the finished plate gives.

Vanilla: ShieldSpecialRenderer.submit, BannerRenderer.submitPatterns,
RenderTypes.patternedShieldGlint.

Fixes #3407

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:55:53 +07:00
Senjin 3ea82790ea Merge remote-tracking branch 'origin/main' into hdr-ui
# Conflicts:
#	crates/horizon-client/src/game/options.rs
#	crates/horizon-client/src/gfx.rs
2026-10-29 00:45:46 +01:00
Senjin fb0454d001 Merge remote-tracking branch 'origin/main' into hdr-black
# Conflicts:
#	crates/horizon-client/src/game/options.rs
#	crates/horizon-client/src/gfx.rs
2026-10-29 00:43:18 +01:00
ThatCakeIsAPieandClaude Opus 5.5 a849545f63 Harness: chunks generated with a world's data packs against vanilla
test_datapack_worldgen_parity.py runs the whole-chunk oracle and
chunk_dump with HORIZON_DATAPACKS: the oracle selects every pack of the
folder after vanilla through vanilla's pack repository, chunk_dump reads
them through worldgen::overrides into GeneratorData::load_with.
tools/fixtures/datapacks holds Horizon's own pack (MIT), overriding a
noise, a density function, the overworld noise settings, a placed
feature and a biome tag, and adding a noise and two density functions.
Each site must match vanilla with the pack and differ from Horizon
without it. HORIZON_PARITY_DATAPACKS compares another folder of packs.

docs/world-generation.md describes what a pack changes and what Horizon
leaves out; the README lists world generation among what data packs do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:53 -05:00
ThatCakeIsAPieandClaude Opus 5.5 b0e65dec06 Data packs: a world's packs change its world generation
The enabled packs of <world>/datapacks (in datapacks.json's order) now
reach the chunk generator: their worldgen files replace and join the
prepared bundles, and the log lists what Horizon leaves out (new biomes,
dimension types, folders the generator does not read, noise taller than
the level). Generator data is cached per set of pack entries, beside the
plain bundle the world screens preload. datapack::enabled_ids reads which
packs are on, as /datapack does.

A world without data packs reads the same bundle as before. Sapling
growth and bone meal still place vanilla's features.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:53 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d1ef7b4fb0 Worldgen: data packs' entries over the prepared bundles
WorldgenOverrides maps a data pack's worldgen files (noise, density
functions, noise settings, material rules and conditions, carvers,
features, placed features, structures, sets, pools, processor lists,
biomes, level stems) to the prepared bundle each joins, the highest pack
winning, and its block, fluid and biome tag files to the tag bundles in
pack order, as vanilla builds its registries from the enabled packs.
Carvers and features use 26.3's folder names (worldgen/carver,
worldgen/feature). A pack's new biome is left out, with the tag entries
and climate points that name it. Dimension types are reported, not
applied: each level keeps its vanilla build height for now.

Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:52 -05:00
ThatCakeIsAPieandClaude Opus 5.5 25662576ff Worldgen: the generator loads a world's data packs over the bundles
GeneratorData::load_with reads every bundle through WorldgenOverrides
(the loader threads included), so a data pack's noises, density
functions, noise settings, rules, carvers, features, structures and
biome changes reach generation. Pack tag files join the prepared tags;
a tag that names something missing is left out with a warning, as
vanilla's TagLoader does, where the prepared tags alone must still all
resolve. A pack's level stem replaces the world preset's dimension.
Noise settings that reach past the dimension's build height fill only
the part inside it (NoiseSettings.clampToHeightAccessor), and the
generator says so, since every level keeps its vanilla height for now.

load is load_with with no packs, so vanilla generation is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:12:52 -05:00
SenjinandClaude Opus 5.5 31ead52d21 HDR: follow the compositor's changes while the game runs
The renderer keeps the window's surface feedback and looks at it every frame
(only events winit already read; no waiting). On preferred_changed it asks
again, and the game applies its settings, so Auto follows a move to another
output or a recalibrated desktop without a restart. Docs: the Auto values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 00:08:10 +01:00
JacobRWebbandClaude Opus 5.5 ee90b3b34b Moving blocks take their light from the cells their faces look at
A finished piston's block is drawn over the chunk mesh until that mesh
shows it, but it was lit with the light stored at the moving piston's own
cell. Once the placed block stands there and the edit is relit, that cell
is opaque and holds 0: the pushed or pulled block and the piston base
flashed black for the frames until the mesh came up (#3002).

Vanilla's PistonHeadRenderer.createMovingBlock gives each block a
MovingBlockRenderState (the lone block at the cell it moves from, the
level's light engine), and MovingBlockFeatureRenderer tesselates it with
ModelBlockRenderer.tesselateBlock: in tesselateFlat each quad reads
BlockModelLighter.getLightCoords at its cull-face neighbour, or
prepareQuadFlat's position (the neighbour when cubic, else the block).
Moving blocks now follow that through MeshTables::flat_light, the chunk
mesher's own flat-light rules; a finished one is lit as the block placed
at its cell, which it stands in for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:46:24 -05:00
SenjinandClaude Opus 5.5 7ef092fabc HDR: Auto paper white, at the desktop's SDR white
Paper White 0 is Auto, the new default: where the system puts SDR white (on
Wayland the compositor's reference white, 203 nits in HDR output's scale), else
203. Both Auto values come from one report the renderer makes at surface open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:42:34 +01:00
degepie 8740d3897c Merge remote-tracking branch 'origin/main' into fix/goat-horn-instrument-sync
# Conflicts:
#	crates/horizon-client/src/lan/guest.rs
2026-10-29 03:34:06 +05:00
JacobRWebbandClaude Opus 5.5 a72f06cfab Test the moving piston's saved fields against vanilla's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:37 -05:00
JacobRWebbandClaude Opus 5.5 fab9c37413 Moving pistons save facing as a byte, as Direction.LEGACY_ID_CODEC does
PistonMovingBlockEntity.saveAdditional stores facing through
Direction.LEGACY_ID_CODEC, Codec.BYTE of get3DDataValue. The block-entity
canonicaliser rewrote it as an int too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:27:45 -05:00
SenjinandClaude Opus 5.5 dc418c7623 HDR: Auto peak brightness from what the compositor reports
Peak Brightness 0 is Auto, the new default: the peak the system reports for
the display (on Wayland, the surface's preferred image description, in HDR
output's scale), else 1000. The slider's lowest step is Auto; 200..2000 stay
manual.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:23:15 +01:00
degepie 57a6f4e9aa Merge remote-tracking branch 'origin/main' into fix/goat-horn-instrument-sync
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
#	crates/horizon-protocol/src/item.rs
2026-10-29 03:06:31 +05:00
degepie ac7e491f3b Merge remote-tracking branch 'origin/main' into fix/issue-3137-suspicious-stew
# Conflicts:
#	crates/horizon-protocol/src/item.rs
2026-10-29 03:04:30 +05:00
Ludovic MarieandClaude Opus 5.5 d3559e2a5f Proceed past the safety notice in the F5 server list test
main's f5_reloads_the_server_list (4c4fcedf4) came after this branch: its first Multiplayer click now opens SafetyScreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:00:28 +01:00
Ludovic MarieandClaude Opus 5.5 c90893c29c Merge main into fix/multiplayer-safety-notice
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:00:18 +01:00
Ludovic MarieandClaude Opus 5.5 b85a9e8350 Merge main into fix/server-core-parity
main added the pack prompt and server dialogs to the screens that stay open when the window loses focus;
they move into pause_if_inactive with the rest of that check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 22:54:25 +01:00
degepieandClaude Opus 5.5 99207f16b1 Suggest the crosshair entity's UUID for entity arguments (#3223)
EntityArgument.listSuggestions adds SharedSuggestionProvider.getSelectedEntities
to the player names unless the argument takes players only;
ClientSuggestionProvider.getSelectedEntities is the UUID of the entity under
the crosshair. The chat screen now fills Registries::selected_entities from
the nearest entity hit before any block.

For the UUID to name the entity back, two server-side gaps are closed:
- the entity tracker sent a made-up UUID (entity_uuid) in AddEntity; it now
  sends the entity's own UUID (ClientboundAddEntityPacket: entity.getUUID()),
  assigned on first tracking;
- a UUID in an entity argument also set playerName, which findEntities checks
  first among players, so `/tp <uuid> ...` found nothing. parseNameOrUUID
  sets entityUUID alone.

The chat_screen and command_parse oracles now return a selected entity from
getSelectedEntities, and the fixtures read it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:49:02 +05:00
degepieandClaude Opus 5.5 e7f746782f Totem of undying: the item activation animation on screen
ClientPacketListener.handleEntityEvent 35 now calls
LocalPlayer.displayItemActivation(findTotem(player)) for the player's own
event. ItemActivation (40 ticks, two random offsets) ticks in
GameRenderer.tick and resets in GameRenderer.resetData.
ScreenEffectRenderer.renderItemActivationAnimation draws it in the hand
pass with a FIXED item at full brightness, ITEMS_3D lighting and no
camera transform or bob. Only F1 hides it.

Fixes #3142.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:46:24 +05:00
degepieandClaude Opus 5.5 cb1c3499bf Keep suspicious stew effects across the wire and list them in creative
A stew taken from the creative inventory gave no effect when eaten and
its tooltip listed none (#3137). The protocol read the
`suspicious_stew_effects` component past without decoding it, so the
creative client's `SetCreativeModeSlot` sent the server a bare stew and
the server's copy had nothing for `SuspiciousStewEffects.onConsume` to
apply. Decode and encode it (`Entry.STREAM_CODEC`: mob effect id,
duration) both ways, and add `SuspiciousStewEffects.addToTooltip`'s
creative-only effect lines (`PotionContents.addPotionTooltip`).

Fixes #3137.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:29:33 +05:00
JacobRWebbandClaude Opus 5.5 4a094bee90 Pistons: moving blocks keep their tint, survive saves and leave no black ghost
- Moving blocks are tinted per face with the chunk mesher's block colours
  (MeshTables::block_tints), not the terrain particle tint, which is none
  for grass blocks: a pushed grass block was drawn gray.
- PistonMovingBlockEntity is saved with its chunk as vanilla's
  minecraft:piston block entity and loaded back into the level's moving
  pistons, so blocks caught mid-move finish it after a rejoin instead of
  leaving an invisible, solid moving_piston (#3310).
- A server client drops its moving piston when the server changes that
  block (LevelChunk.setBlockState removes the block entity), instead of
  animating it for five more death ticks over the placed block, black and
  z-fighting (#3002).
- A finished piston stays drawn until the chunk mesh shows its block, and
  no longer, so no frame shows neither.

Fixes #3310
Fixes #3002

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:29:10 -05:00
degepieandClaude Opus 5.5 66e1fc5079 Credit the curing player of a zombie villager on the server thread
ZombieVillager.mobInteract records the interacting player as the
conversion starter and finishConversion credits the player
level.getPlayerByUUID finds: the ZOMBIE_VILLAGER_CURED gossip and the
cured_zombie_villager trigger. Horizon used the server Game's own
level.player_uuid for both, so on the server thread (and for LAN guests)
the gossip went to a UUID nobody trades as and the player got no
discount and no Zombie Doctor (#3306).

The starter is now the acting player, and the cure credits the host or
the placed guest in this level with that UUID. lan_e2e gains a
cure-discount check: host and guest each cure one and pay 14 wheat
instead of 20.

Fixes #3306.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:27:31 +05:00
degepieandClaude Opus 5.5 c404ef5e2a Goat horns keep their instrument from the server
The client read InstrumentComponent past without keeping it, so every goat
horn a server sent arrived with no minecraft:instrument and its tooltip fell
back to "Ponder" (#3181). Decode the holder like ArmorTrim's: a reference
becomes Component::Instrument(registry id) and is named from the synced
minecraft:instrument registry in item_sync. A direct instrument stays Raw.
The LAN host and creative client send it back by the same id.

The item wire harness gains an "instrument registry" fixture, and its probe
treats Instrument like Trim (a byte-exact id, not a lossy type), so the
round trip is checked byte for byte.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 01:14:59 +05:00
ThatCakeIsAPieandClaude Opus 5.5 85bfbc7d46 upgrade-datapack: docs, and checks against vanilla 26.2 and 26.3
- docs/datapack-upgrade.md: how to run `horizon-port-pack
  upgrade-datapack`, every rewrite and how it is checked, and what is not
  rewritten; linked from docs/README.md and the command's help.
- tools/datapack_upgrade_rosetta.py writes vanilla 26.2's own worldgen out
  as a pack, upgrades it and compares it with vanilla 26.3's file by file:
  registries whose format changed and whose content did not must come out
  identical.
- tools/datapack_upgrade_fidelity.py starts vanilla's 26.2 and 26.3
  dedicated servers on the same seed, the first with a pack and the second
  with its upgrade, force loads the same chunks and compares the saved
  blocks; without packs it measures what 26.3 itself changed.

Both need the 26.2 server jar the player downloaded; nothing is fetched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:24 -05:00
ThatCakeIsAPieandClaude Opus 5.5 f983223354 port-pack: upgrade-datapack rewrites features and placements
The fourth part of the 26.2 to 26.3 data pack upgrade.

- Configured features move to `feature/` with `config` unwrapped, block
  state providers take their 26.3 names (a `simple_state_provider` is its
  state), and nested and inline placed features are upgraded with them.
  Feature types 26.3 replaced by compositions of other features are listed
  in the report and left as they are.
- Placement `random_offset` becomes `offset` with one provider per axis
  (x and z both from `xz_spread`).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 3d39bbdaf1 port-pack: upgrade-datapack rewrites noise settings, dimensions and presets
The third part of the 26.2 to 26.3 data pack upgrade: noise settings in
26.3's form, with the parts 26.2 hard-coded written out as data.

- The beardifier, which 26.2's NoiseChunk added, is added to the final
  density.
- The aquifer router fields move to `aquifers`, whose `exclusion` spells
  out 26.2's `isDeepDarkRegion` (erosion < -0.225 and depth > 0.9) from
  the pack's own erosion and depth; `surface_level` is the preliminary
  surface, and `chunk_surface_level` its per-chunk 16 x 1 interpolation.
- 26.2's ore veinifier becomes two `ore_vein` material rules (copper,
  iron) with OreVeinifier's heights, thresholds, edge roundoff, solidness,
  richness and gap, ahead of the surface rule: 26.2 placed veins while
  filling the noise and its surface pass only replaced the default block.
  The rule is written as a named `material_rule` under the settings' id.
- The climate functions are named, and the spawn target refers to them by
  id (`depth` and `offset` dropped, as vanilla did).
- `size_horizontal`/`size_vertical` are dropped: each `interpolated` holds
  its cell.
- Inline noise settings in dimensions and world presets get the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 2aca6fe06c port-pack: upgrade-datapack rewrites density functions
The second part of the 26.2 to 26.3 data pack upgrade: named density
functions in 26.3's form.

- Inputs are named `input`/`left`/`right`; `invert` is `reciprocal`,
  `y_clamped_gradient` a `gradient` on y, `shifted_noise` a `noise` with
  shifts, `end_islands` `end_outer_islands`, and `constant` its number.
- 26.2's `flat_cache` held each column's value at y = 0, so it becomes a
  `cache` around a `slice` at y = 0 when its input depends on y (through
  the pack's own functions); 26.2's other caches only memoise in 26.3.
- `interpolated` takes its cell from the noise settings whose router
  reaches it (`size_horizontal * 4`, `size_vertical * 4`), which 26.2 read
  from the settings, or vanilla's overworld cell when none does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:23 -05:00
ThatCakeIsAPieandClaude Opus 5.5 598668e4bc Far terrain: stop decoding saved chunks for every distant column
SavedLodSampler::grid put saved chunks over every far tile of 16 blocks
a column or finer: for each column it opened the region file and
decoded the whole saved chunk, and each sampling thread kept only 128.
In a save with many thousands of chunks, the far view stalled for many
minutes after its first one.

- SaveDir keeps which chunks each region file holds (10 s), so columns
  over unsaved ground no longer open region files.
- Decoded saved chunks are shared by the sampling threads (1024, 30 s).
- With Reduced far detail, tiles coarser than 4 blocks a column (about
  1 km out and beyond) keep the generator's terrain, which saved chunks
  were made from; edits that far away show when their tiles refine on
  approach. Full keeps saved chunks up to 16 blocks a column, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:22 -05:00
ThatCakeIsAPieandClaude Opus 5.5 a1a249e2f6 Far tiles: approximate only with Reduced far detail
The previous two commits trade the exact far tile for speed in wide
cells. They now apply only with Reduced far detail (FarDetail::Reduced,
which already "changes the picture"); Full keeps the exact tiles.

- GeneratorWorker::lod_grid takes `approximate`: one biome per 4x4
  columns, the coarser vertical lattice, and density only around the
  surface (with shared 2D planes) happen only when it is set. Without it
  the tile is sampled as before.
- LodSampler::set_approximate, and Lod::set_reduced_detail sets it for
  the sampling threads; tiles sampled after a change follow it.
- lod_grid_bench --approximate samples as Reduced does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:21 -05:00
ThatCakeIsAPieandClaude Opus 5.5 eb5e0bb914 Far tiles: sample density only around the surface, share 2D planes
lod_grid sampled the density lattice over the whole build height. It now
finds each tile's surface band on a 64-block lattice made of the fine
lattice's points, then samples the fine lattice from 64 below the lowest
surface to 128 above the highest (and above sea level); below the band a
column is one span of the default block. Thin parts above an air sample
of the coarse lattice (floating islands, overhang lips) can be missed.

Most of a far tile's density work is 2D noise (y-fixed slices), which
the two passes would each compute: SamplerContext::with_planes keeps a
y-fixed slice's plane for its x/z lattice, so the band pass reuses the
coarse pass's. Opt-in, and only far sampling turns it on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:21 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d93c3cdd82 Far tiles: one biome per 4x4 columns, a coarser vertical lattice
Approximations for wide far cells, where they are hard to see at their
distance:

- One biome for 4x4 of a tile's columns, the first one asked (its
  surface's), in cells up to 64 blocks wide: far cells don't show a biome
  change a few blocks down, and climate changes over hundreds of blocks.
  Painting asks through the biome zoom, which strays into neighbouring
  quarts; per quart, those strays were most of a tile's climate samples.
- The interpolated density on a vertical lattice of 16 blocks for cells 32
  blocks wide or more, and of 32 from 128, instead of 8.

Distant tiles' biomes and some surface blocks differ from the per-quart
answers, and in the widest tiles the coarser lattice moves the surface.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:20 -05:00
ThatCakeIsAPieandClaude Opus 5.5 81b3803017 port-pack: upgrade-datapack, a 26.2 data pack's registries in 26.3's formats
Vanilla 26.3 refuses a world generation data pack written for 26.2: the
registries changed format. `horizon-port-pack upgrade-datapack <pack>
(<out> | --world <world>)` reads a pack (zip or folder) and writes it in
26.3's formats, with a report of what it rewrote, what has no faithful 26.3
form and which types it does not know. Each rewrite is the one vanilla's own
data went through between 26.2 and 26.3.

This first part handles the registries that need no context:
- noise parameters: `firstOctave`/`amplitudes` become `base_octave`,
  `octave_count`, `amplitude_modifiers` and the base amplitude of
  `NormalNoise.createParity`, which keeps 26.2's normalisation;
- block states: `{Name, Properties}` become `{id, properties}`, or the id;
- configured carvers move to `carver/` with `config` unwrapped and the cave
  carvers' hard-coded count, thickness and room scale written out;
- biome spawners and spawn probability become 26.3's gameplay attributes;
- structure spawn overrides and the dimension type's bed rule.

Density functions, noise settings and features follow in their own
changes. serde_json gains `float_roundtrip` so the numbers the upgrade
writes back are the ones it read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
ThatCakeIsAPieandClaude Opus 5.5 91ed783e8d Far tiles: resolve each biome quart once per tile
lod_grid paints eight depths of every far column through the surface
rule, and each depth asked the biome resolver again: a full climate
sample and parameter search per query, though neighbouring columns and
depths share quarts. The tile now keeps the answers per quart
(Biomes::Memo), for painting and for the columns' surface biome.

lod_grid_bench, vanilla seed 1234: surface heights, blocks and biomes
are the same at every level. At level 6 (64 blocks a column) some
painted blocks below the surface differ: the tile's cached sampler
context answers a repeated quart from what it sampled before, so the
order of queries mattered there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
ThatCakeIsAPieandClaude Opus 5.5 bc3a724c01 lod_grid_bench: far terrain sampling cost per tile level
Times GeneratorWorker::lod_grid as the client's far terrain calls it (16 x
16 columns a tile) for each tile level, with two checksums: everything the
tiles hold (an exact speedup keeps it) and the surface and water heights
alone (the terrain's shape, which an approximation of materials or biomes
keeps). --dump writes each column's height, block and biome.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:19 -05:00
Lyle ColeandClaude Opus 5.5 51244a64f6 Decoration: column scans, per-chunk heightmap reads, zoom-free uniform biomes
Three read paths in decoration:
- Speleothem clusters (and large dripstone) scan each column up and down
  for the floor and ceiling, up to 512 blocks, one region read per block.
  FeatureWorld::scan_column walks a chunk's column directly: an all-air
  section is tested once (the predicate is a pure state test), a mixed
  one is read without a section lookup per block. Same blocks, same stop.
- OreFeature's any_height_at_least read each column of its square through
  a region lookup and a checked heightmap read; now each chunk's part of
  the square is read from its heightmap in one pass (any column will do,
  so order is free).
- Placement biome checks ran the biome zoom (eight hashed corner
  distances) for every position. When the 2x2x2 quarts the zoom picks
  from lie in biome sections that each hold one biome, the same one, the
  answer is that biome whatever the zoom picks; per-section uniformity is
  worked out once per region.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:18 -05:00
Lyle ColeandClaude Opus 5.5 1d25065363 Decoration: cut per-region and per-shape-update setup
Three costs that came before any feature work:
- the biomes present around the chunk sorted every quart of the nine
  chunks' columns into a BTreeSet; a bitset gives the same ids in the same
  ascending order;
- every updateShape call built and dropped a whole Ticks (tick maps,
  deadline index) just to capture the ticks it schedules; capturing
  leaves the rest untouched, so the region keeps one;
- the nine stand-in chunks left in the proto chunks while the region
  holds their terrain were allocated at full height; one section does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:18 -05:00
ThatCakeIsAPieandClaude Opus 5.5 22f0e30d07 Block reads: inline the region-to-palette path
A block read in decoration (and the surface stage's reads) went through
out-of-line calls: the region's chunk lookup, TerrainChunk::block,
TerrainBlocks::block and BlockSection::get, whose io::Result error path
kept it from inlining. They now inline into the feature code (the fast
build has no cross-crate LTO, so the core accessors need #[inline]), and
TerrainBlocks reads the section through an infallible accessor: its index
is a local index, always below 4096. #1790 takes the division out of
PackedStorage::read; this leaves storage alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:17 -05:00
Lyle ColeandClaude Opus 5.5 c8f484e040 Surface biomes: keep each quart cell's corner jitter and biomes
The surface stage's per-chunk cell cache only proved cells whose eight
quart corners hold one biome; a block in any other cell ran the full
eight-corner zoom mix and a stored lookup again. Now a mixed cell keeps
its corners' jitter (BiomeZoom::corner_jitter, one mix per cell) and
their eight biomes, so each block of the cell only redoes the distance
comparison (BiomeZoom::select_corner, same arithmetic and tie order); the
cell of the previous lookup is kept too, as columns walk down in y. A
test checks the split zoom against quart_at_block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:16 -05:00
ThatCakeIsAPieandClaude Opus 5.5 d4a1ebaa33 Surface rules: compare biome ids, not names
Every solid block of every column asks the rule tree's biome conditions
for its biome. Each one cloned and dropped the biome's Arc<str> (an atomic
count all generator threads share) and looked its name up in a string set.
Queries that number their biomes (the generator's) now answer with the
index, and biome conditions keep a bit per index; others compare names as
before. Same answers: the generator's name -> id map is exact.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:16 -05:00
Lyle ColeandClaude Opus 5.5 5a75089f61 Biome stage: biome ids by bitset, names by a last-entry memo
Two lookups in the biome stage:
- the biome ids present around the chunk were gathered by inserting
  every quart of the nine neighbour chunks into a BTreeSet; a bitset
  gives the same ids in the same ascending order;
- each quart's biome name was looked up in the name -> id map. Names
  point into the biome source's parameter list and neighbouring quarts
  mostly resolve to the same entry, so the last entry's id is kept and
  the map is asked only when the name changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:15 -05:00
Lyle ColeandClaude Opus 5.5 483ad5209c Biome stage: build free neighbour biome chunks before waiting
Each chunk needs the biome chunks of its 3x3 neighbourhood, built once by
whichever thread gets there first (per-chunk work lock). With several
workers on neighbouring chunks, a thread often blocked on a neighbour
another thread was building while other neighbours were still unbuilt.
The stage now first builds (or reads) every neighbour whose lock is free
(try_lock), then waits for the ones still being built. Each biome chunk
is still built exactly once, deterministically; only the order of the
work changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:15 -05:00
Lyle ColeandClaude Opus 5.5 91369d11c5 Structure column checks: sample the column's climate in one volume
couldStructureExistInColumn (jigsaw projection, onTopOfChunkCenter,
temples, mansions) samples the column through createResolverForChunk:
one bulk climate volume of 1 x n x 1 quarts, then each quart from the
bottom. Horizon took one scalar climate sample per quart instead, and a
column with no valid biome walks all of them, so a few such start chunks
held most of the structure stage while neighbours waited on them. The
column is now one prepared volume through the biome stage's resolver
path, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:14 -05:00
ThatCakeIsAPieandClaude Opus 5.5 82eeba7753 Structure checks: keep each worker's single noise-biome samples
Structure placement asks the biome of one quart at a time: several
structures test the same quarts of a start chunk, and column checks walk
whole columns when no quart matches. Each answer is a full climate sample
and parameter search. Workers now keep the answers, up to 65536 quarts;
the generator is seed-bound, so they cannot go stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 14:57:14 -05:00
degepieandClaude Opus 5.5 de25583ee5 perf_bench: compile the driver mod with JAVA_HOME's javac
The vanilla runs start `$JAVA_HOME/bin/java`, but the driver mod was
built with whatever `javac` came first on PATH. With JAVA_HOME on a JDK 25
and an older default JDK on PATH (21 on Ubuntu 24.04, for example),
`javac --release 25` fails with "release version 25 not supported" before
any game starts. Use JAVA_HOME's javac when JAVA_HOME is set, as the java
lookups below already do; without it, PATH is used as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 00:21:18 +05:00
matydenandClaude Opus 5.5 8170b8742b Rabbits and wild ocelots run from players
Rabbits fell back to the generic goal list, and ocelots had no goals
of their own, so neither fled. Rabbit.registerGoals now registers its
three RabbitAvoidEntityGoals (players within 8, wolves within 10,
monsters within 4, never for the killer bunny) with panic at 2.2.
Ocelot.registerGoals plus reassessTrustingGoals gives ocelots
OcelotAvoidEntityGoal (players within 16), OcelotTemptGoal, the leap
and attack goals and their chicken and baby turtle targets.

Not ported yet: rabbit RaidGardenGoal and ClimbOnTopOfPowderSnowGoal,
and ocelot trust, so every ocelot is wild.

Refs #2780

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:05:31 +02:00
Shreyas 2ac85c50eb lan: register default size 1 for AbstractCubeMob metadata 2026-10-29 00:11:11 +05:30
ThatCakeIsAPieandClaude Opus 5.5 24e316855e Create World: the Data Packs screen
The More tab's Data Packs button opens vanilla's PackSelectionScreen (the
resource pack screen's port, titled dataPack.title) on a new mcworld-
folder: its packs, with their icons, and the built-in one, re-listed when
the folder changes. Create New World copies the folder's packs into the
world's datapacks/ and writes datapacks.json with the selection, as
CreateWorldScreen.copyTempDataPackDirToNewWorld does. Cancel deletes the
folder. Worlds made without opening the screen are unchanged.

The pack screen parity harness also runs the screen under dataPack.title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:50:12 -05:00
Hector Eccles e41443c64c fix: address review findings
- palette lookups only match live slots, not freed ones still on the free list (#3253)
2026-10-28 13:42:04 -04:00
Hector EcclesandOpenCode d46bed5c36 perf: hash the world's integer bookkeeping maps with Fx
World.modified, inhabited, chest_openers and block_names used std SipHash
on small integer keys, and modified/inhabited rehash as they grow while an
area unloads. Use the existing crate::fast_hash maps; none of their
iterations is order-sensitive (save order comes from the batch list and
ChunkExtras::block_entity_order). chunks_with_block_entities takes the
names map by hasher so the faster map still fits.

BlockEntities::map stays std HashMap: crate::lan::chunks::body takes the
default-hasher map by type and lan/** is outside this change.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 9b9d045150 perf: find section palette ids with a scan and a last-state cache
BlockSection kept a HashMap of state ids per section (24 tables and
allocations per proto chunk) and hashed on every worldgen write. Resident
palettes are almost always tiny, and the resident container already scans
them; use the same scan with a one-entry last lookup so a run of one state
is a compare. set/compact behaviour and the palette, counts and free list
are unchanged.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode 1e5cc9031f perf: share the multiply-shift index between paletted storage and PackedStorage
PackedStorage divided by its per-word width on every read and write, while
PalettedContainer already used an exact multiply-shift. Move MAGIC/locate/
mask into storage.rs and use them from both; only storages past 16,384
entries keep the division path.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Hector EcclesandOpenCode ee351caed8 perf: mark only the distinct sections a block write dirties
A write looped 27 block offsets through the dirty-section map; all but a
few hashed the same keys. Mark the exact section set instead: one key in
the interior, the adjacent section on a section face, a neighbour column
on a chunk border, and the diagonal where both borders meet (at most eight
keys). The mesher still sees the same 3x3x3 neighbourhood.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 11:07:24 -04:00
Senjin 8ebcf6c13d WIP HDR: ask the compositor for the window's luminances (log only) 2026-10-28 16:00:54 +01:00
Mik00000 08b9765278 Ensure mob effect IDs are namespaced so tipped arrows tick damage on living mobs 2026-10-28 16:09:27 +02:00
SenjinandClaude Opus 5.5 380d5ebe9f HDR: its settings get a screen of their own
Paper White and Peak Brightness sat among the display settings, where players
without an HDR display took them for settings of their own display. Display now
has only HDR Settings..., greyed where the display path offers no HDR output (the
renderer tells the settings when it opens its surface, `graphics::offer_hdr`).
It opens Graphics: HDR: the HDR switch, then its sliders, greyed while it is off.

Where HDR isn't offered, `horizon-graphics.txt`'s HDR settings are left as saved
and not used: the renderer already keeps HDR off there, and the screen no longer
opens, so a game folder copied from another computer, or a display swapped for
an SDR one, shows nothing that does nothing; an HDR display brings them back.

The screen leaves room for a calibration step and an in-game shortcut to it
later. horizon-ctl opens it as `options graphics-hdr`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:58:55 +01:00
SenjinandClaude Opus 5.5 70e3fe006d Screenshots are named in local time, and the log says where they went
screenshot_name worked the date and time out from the Unix clock by hand, in
UTC; vanilla's Util.getFilenameFormattedDateTime is local time, as Horizon's own
log is, so a screenshot taken at 12:28 in CET was named 11.28 (and near midnight
elsewhere, the day before). The log now also has the full path of a saved
screenshot; vanilla's chat line, which names only the file, is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:07:27 +01:00
SenjinandClaude Opus 5.5 18c23a6df3 HDR: UI Brightness, and the HUD and menus never lift
Pixels counted as the world when they matched it darkened evenly (the vignette),
and grey text over the white sun matches that: menu text in front of the sun was
lifted to the peak. Once the world is kept for the last pass, the frame's alpha
is now zeroed; the hand, HUD and menus raise it where they draw (the vignettes
leave it), and after the hand a blend pass scales its alpha down to 1/255. The
last pass lifts nothing covered, keeps the hand at the world's brightness (it's
the player's, not UI), and scales the rest by UI Brightness.

UI Brightness (Display, 100% by default) is the HUD and menus as a share of paper
white, so pure white labels needn't sit at the world's white. At 100% the picture
is as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:57:35 +01:00
Shreyas 43f2497f1f Sulfur cubes: synchronize size metadata and adult egg spawns
Include sulfur cubes in AbstractCubeMob size metadata serialization and
remote data handling so adult cubes retain their size across the network.
Adult sulfur cubes spawned from spawn eggs now correctly render and act
as adults rather than defaulting to size 1 babies.

Vanilla reference: AbstractCubeMob.ID_SIZE, SulfurCube.setSpawnSize.

Fixes #3151
2026-10-28 17:13:45 +05:30
Mik00000 445183a7df Display mob inside spawners and trial spawners (fixes #3107) 2026-10-28 13:22:28 +02:00
Shreyas 380d71d8a8 lists: fall back to offline resolution when services do not answer in tests 2026-10-28 16:31:23 +05:30
Mik00000 85f53198ce Allow bows and crossbows to shoot spectral and tipped arrows (#3071) 2026-10-28 12:37:54 +02:00
SenjinandClaude Opus 5.5 5479b0362f HDR: a black level setting, for displays whose black isn't 0
HDR output told the display its content goes down to 0 nits, which only an OLED
shows. Black Level (Display, 0 by default) is the display's darkest black: the
last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ
(the peak stays, the lift fades out by the fourth power above black, so midtones
barely move and 100 nits and up don't), and the HDR metadata gives it as the
minimum luminance. At 0 the picture is exactly as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:32:28 +01:00
Shreyas 0543cf0345 Select the picked hotbar slot in singleplayer and LAN
Preserve the guest's updated hotbar selection when restoring the host
player state after an action. The previous restore sequence discarded
the guest's new selection while keeping the inventory changes.

The held-slot response now uses the selected slot produced by the pick,
so integrated-server and LAN players select the picked block or spawn
egg. Keep the host's selection independent of the guest's selection.

Vanilla reference: ServerGamePacketListenerImpl.tryPickItem,
Minecraft.pickBlockOrEntity, and ClientPacketListener.handleSetHeldSlot.

Fixes #3031
2026-10-28 16:00:22 +05:30
Mik00000 13e441e235 Fix invisible item frames and custom head skins 2026-10-28 12:00:31 +02:00
hashtheboardandClaude Opus 5.5 c9a8361493 render: draw from a ViewSet of per-eye views (VR groundwork)
The renderer now builds its camera from `View` (rotation, lens, aspect,
tan(fov/2)) gathered in a `ViewSet`: one view on a flat screen, and the
view culling and back-face removal use. A headset can later supply one
view per eye without touching draw_frame's consumers.

No visible change: the products keep their order (projection * pose *
rotation), and a test pins the screen view to the old formula bit for bit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:51 +01:00
Kacpherek 345f64e47c Import the Sounds trait for the elytra sound 2026-10-28 06:23:27 +01:00
Kacpherek 8f84c2cd48 Play the elytra's flying sound while gliding
Horizon had no ElytraOnPlayerSoundInstance, so gliding and diving were silent. The loop starts when the player starts gliding, fades in after a second, and follows the player's whole speed, so a straight dive is the loudest.
2026-10-28 06:16:05 +01:00
Kacpherek e0a48b3f66 Document the 2..128 render distance range 2026-10-28 05:56:49 +01:00
Kacpherek d35efa25ec Accept render distances up to 128 from the command line and horizon-ctl
--render-distance and horizon-ctl render-distance check against the same cap as the options.
2026-10-28 05:56:48 +01:00
Kacpherek b8b15b1682 Raise the render distance cap from 64 to 128 chunks
The options slider and options.txt now accept 2..128. 128 is where the server protocol's view distance (an i8) tops out.
2026-10-28 05:56:48 +01:00
Kacpherek 05f8ec650d Pass the safety notice in the direct connection test
Multiplayer now opens SafetyScreen first, so the test proceeds through it.
2026-10-28 05:45:51 +01:00
時穂andCodex 2702542c73 launcher: complete Chinese strings and strengthen translation checks
Fill the 17 missing Microsoft sign-in, resource setup and Nightly strings.
Scan Rust tokens for multiline translation calls and require shared catalog
keys in every language, including labels translated through widgets or constants.

Co-Authored-By: Codex <[email protected]>
2026-10-28 13:37:54 +09:00
Ludovic MarieandClaude Opus 5.5 f049a08e48 packs: keep the available packs in one id order, as PackRepository does
PackRepository.discoverAvailable puts the packs of every source into a
single TreeMap by id, so the folder's file/ packs sort among the built-in
ones rather than after them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 23:02:52 +01:00
Ludovic MarieandClaude Opus 5.5 3e930fc74b Launcher reference: the language setting lists zh
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:55:39 +01:00
Ludovic MarieandClaude Opus 5.5 04ec03745d Launcher Chinese: drop the coming-soon strings main removed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:52:52 +01:00
Ludovic MarieandClaude Opus 5.5 6a6b5957eb Launcher Chinese: the nightly strings main added since
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:52:29 +01:00
Ludovic Marie 8c18d9429f Merge remote-tracking branch 'origin/main' into cg24/f2795 2026-10-27 22:50:42 +01:00
Ludovic MarieandClaude Opus 5.5 44448d941f Merge #2706 into #2927: the unchanged-block skip reads the column cache
#2706 lands first; with #2927's surface column cache the check reads the
cached state instead of decoding the section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:29:15 +01:00
Ludovic MarieandClaude Opus 5.5 2ee238e5f1 Merge #2931 into #2924: both add vanilla 26.3 options to options_ui.rs
The controls list and the mouse list sit side by side; #2931 lands
first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:53 +01:00
Ludovic MarieandClaude Opus 5.5 8237c15741 Merge bubble-column-entities (#2723) into flowing-water-mobs
Both add a LAN check at the same place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:58 +01:00
Kacpherek 7711bfcdc5 Fail a dialog whose body or input cannot be read
Dialog.DIRECT_CODEC rejects the whole dialog for an unreadable body or input, a single option with no or two initial entries, or a text default longer than its limit. Horizon dropped the bad part and showed the rest.
2026-10-27 22:26:43 +01:00
Kacpherek 2ac08efcb2 Draw dialog sliders, dropdowns, item bodies and dialog lists
The dialog screen skipped number range and single option inputs, item bodies and the entries of a dialog_list, so a server menu showed only its Back button. Draw them as DialogScreen does: a slider and a cycle button, the item widget with its description, and one button per listed dialog (external title, opens it). The client now keeps the server's dialog tags to resolve a #tag list.
2026-10-27 22:26:43 +01:00
Kacpherek 559b83626f Open links from dialog buttons through the link confirmation
DialogScreen hands an open_url to Screen.clickUrlAction (Web Links, Prompt on Links, ConfirmLinkScreen over the screen the dialog leaves). A suggest_command has no screen to take text; change_page is logged as an error.
2026-10-27 22:26:43 +01:00
Kacpherek c2915208fd List built-in packs before folder packs in the pack screen
PackRepository keeps its sources in order (built-ins, then resourcepacks/), each sorted by id. Horizon sorted every id together.
2026-10-27 22:26:43 +01:00
Kacpherek 04bc30606d Take a texture's .mcmeta only from its own pack or a higher one
FallbackResourceManager ignores a .mcmeta from a pack below the one that supplies the file. Horizon used the top .mcmeta of any pack, so an animation could run on a pack's static PNG.
2026-10-27 22:26:43 +01:00
Kacpherek 6893bcff88 Cap server command suggestions at 1000
ServerCommandSuggestionsProvider keeps the first 1000 suggestions of a request; Horizon sent them all.
2026-10-27 22:26:43 +01:00
Kacpherek aae98d3db5 Send vanilla's error text for an invalid login name
There is no multiplayer.disconnect.invalid_name key. Vanilla's validState throws and Connection.exceptionCaught sends disconnect.genericReason with the exception.
2026-10-27 22:26:43 +01:00
Ludovic MarieandClaude Opus 5.5 6bb648db1e Controls: the three switches nothing reads yet show as unavailable
Operator Items Tab, Quit Shortcuts and Control-click are stored and saved
like vanilla's, but no code reads them yet: grey them out like the other
placeholders instead of offering a switch that does nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Ludovic MarieandClaude Opus 5.5 b4de9dc23c Leave the current's push to main's
main pushes mobs with the fluid current since #2554 (kacpherek, in
update_fluids, with Entity.isPushedByFluid: fish, axolotls, turtles,
frogs and swimming drowned stay put); with both, a mob was pushed twice and
the water creatures were pushed too. The flow-push LAN check stays: it
covers main's push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Ludovic MarieandClaude Opus 5.5 4a4cad6864 Merge main into flowing-water-mobs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Kacpherek fece4c6a76 Run function commands at function-permission-level
Function lines parsed at whatever level the caller had, so a tick or load function did nothing in a world without cheats and an owner's function could run /stop. Vanilla compiles every function line at the library's permission (function-permission-level, 2 elsewhere).
2026-10-27 22:26:19 +01:00
Kacpherek a0b19a4820 Stop a dedicated server whose tick runs past max-tick-time
max-tick-time was read and never used. Vanilla's ServerWatchdog (DedicatedServer.initServer, when the value is above 0) logs and writes a crash report once a tick is that far past its due time, then exits with status 1.
2026-10-27 22:26:19 +01:00
Kacpherek 02b1d1f430 Pause on lost focus only after 500 ms without it
Horizon paused the moment the window lost focus. Vanilla's pauseIfInactive waits until focus has been missing for more than 500 ms, so a brief focus steal does not pause.
2026-10-27 22:26:19 +01:00
Ludovic MarieandClaude Opus 5.5 75c149afde Leave the bubble column push to main's
main pushes mobs in a bubble column since #2555 (kacpherek, Entity's
applyEffectsFromBlocks after the move); with both, a mob got the push
twice. The bubble-column LAN check stays: it covers main's push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:17 +01:00
Ludovic MarieandClaude Opus 5.5 6f785d1a28 Merge main into bubble-column-entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:11 +01:00
Ludovic MarieandClaude Opus 5.5 e1d825312f Read the flat world's horizon from WorldGen's preset
main's dark disc and void fog read WorldGen.flat, which is now the preset
(seen by sirrayi's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:52 +01:00
Ludovic MarieandClaude Opus 5.5 5ea61ed3d8 Merge main into world-types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:45 +01:00
Ludovic MarieandClaude Opus 5.5 231b309634 Name the forced terrain ring where the protos are kept
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:24:48 +01:00
Kacpherek 052dac817a Drop a guest that misses a keep-alive for 15 seconds
The server waited 30 s for the answer. Vanilla disconnects with a timeout when the last keep-alive is still pending 15 s after it was sent; 30 s is only the read timeout.
2026-10-27 22:19:12 +01:00
Kacpherek 3dc958611c Fire recipe_crafted when a crafting, stonecutter or smithing result is taken
Vanilla's ServerPlayer.triggerRecipeCrafted passes the recipe and the stacks that went in; ResultSlot, StonecutterMenu and SmithingMenu award it. This lets trim_with_any_armor_pattern and craft_decorated_pot_using_only_sherds complete, and smithing and stonecutter results now unlock their recipe too.
2026-10-27 22:02:48 +01:00
Kacpherek 33c15a6d79 Fire player_generates_container_loot when a loot roll is opened
Vanilla's RandomizableContainer.unpackLootTable fires it for chests, trapped chests and chest minecarts, and BrushableBlockEntity does for the first brush of suspicious sand or gravel. This lets loot_bastion and salvage_sherd complete; opening marks the roll unpacked so it fires once.
2026-10-27 22:02:31 +01:00
Kacpherek 797482183c Show tutorial hints and track their progress 2026-10-27 22:02:18 +01:00
Kacpherek f6209003de Fire summoned_entity for built golems and the respawned dragon
Vanilla fires SUMMONED_ENTITY in CarvedPumpkinBlock.spawnGolemInWorld for players within 5 blocks of the new golem, and in EnderDragonFight.setRespawnStage(END) for the new dragon's fight players. This lets summon_iron_golem and respawn_dragon complete.
2026-10-27 22:02:13 +01:00
Kacpherek 694568a89c Show the multiplayer safety notice on the first Multiplayer click
Horizon opened the server list at once and never read skipMultiplayerWarning. Vanilla TitleScreen shows SafetyScreen first, with a do-not-show-again box saved to options.txt, unless that option is set.
2026-10-27 21:56:00 +01:00
Hector Eccles 20d7d111e5 Merge branch 'ludovic111:main' into perf/terrain-rustgen-ports 2026-10-27 15:39:08 -04:00
Hector EcclesandOpenCode fb769e55e0 Worldgen: rustfmt the terrain performance changes
Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-27 15:25:06 -04:00
Hector EcclesandOpenCode 01d0170821 Worldgen: keep the skipped aquifer decisions' pending fluid flag
The fill no longer calls compute_substance for positive density or for air
above the aquifer's skip height. Both old paths cleared the aquifer's pending
fluid update; reproduce that side effect so the terrain-fill probe's
should_schedule_fluid_update matches vanilla (it caught the difference).

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-27 15:01:42 -04:00
Hector EcclesandOpenCode e71f98adde Worldgen: write positive-density fill runs through the section once
A positive-density run is exactly the default block, so the fill no longer
calls set_block_raw and the settled heightmap checks per block. One palette
lookup per section segment writes the run, its non-air count follows the air
blocks it replaced, and the heightmap is updated at the run's top only, as
rustgen's fill_initial_solid_cell does for uniform cells.

Noise 1.74 -> 1.57 ms/chunk one thread and 2.27 -> 2.10 at 13; terrain +4.4%
(min) and +1.2% (min) at 13. Block snapshots identical.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-27 14:39:52 -04:00
ElyTheCat 80df20837b Options: add Java 26.3 controls 2026-10-27 22:09:31 +03:30
masa-masa-0314andZCode 2092c5a2e8 Worlds: take the save's icon.png from the rendered frame
GameRenderer.tryTakeScreenshotIfNeeded saves a world icon the Select
World list never had: while a local world is on screen without one, the
frame is grabbed after the level and the hand (before the HUD), centre
cropped to a square, averaged down to 64x64 and written to the save's
icon.png once a second until it sticks. The list already drew the icon
when a save had one; nothing ever created it.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 02:29:39 +08:00
Hector EcclesandOpenCode e319dd578f Worldgen: resolve each surface column once into a reused buffer
The surface walk read every column from paletted sections at least twice (the
top-down pass and the stone-depth look-behind). Column now keeps the resolved
ids for the read range, refilled into one buffer per chunk and updated by
set_block, as rustgen's SurfaceSystem does with read_column.

Surface 1.46 -> 1.41 ms/chunk one thread, 4.19 -> 3.89 at 13; terrain +1.4%
(min 2.6%) and +3.7% at 13. Block snapshots identical.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-27 14:20:16 -04:00
Hector EcclesandOpenCode 0f596d9e3c Worldgen: one material worker per carving pass
Every restored dirt block under a carved cell built a fresh
MaterialDensitySources and MaterialConditions, compiled the whole surface
rule and allocated its bindings. A carver now builds one worker per pass and
moves only the 1x1x1 bindings to the cell, as rustgen's CarvingContext does.

Carvers 0.44 -> 0.40 ms/chunk on one thread and 0.62 -> 0.49 at 13 threads;
terrain +0.6% one thread and +0.98% (median) at 13. Block snapshots identical.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-27 14:14:48 -04:00
Hector EcclesandOpenCode 7671219f1a Worldgen: skip the aquifer for solid and high air blocks in the fill
The fill called compute_substance for every block although the aquifer only
does work for blocks below its skip height. Positive density always yields the
default block, and above both the skip height and the fluid level a
non-positive density is air, so the fill now decides both cases itself.

One-thread terrain 4.30 -> 4.08 s (noise 1.95 -> 1.75 ms/chunk); 13-thread
noise 2.51 -> 2.27 ms/chunk. Block snapshots are identical.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-27 14:04:59 -04:00
levzzz b901415c80 Merge remote-tracking branch 'upstream/main' into fix/forceload-rings 2026-10-27 20:32:05 +05:00
levzzzandminimax-m3.1-flash da877fe1db Give a forced chunk vanilla's rings
A `/forceload`ed chunk holds a FORCED ticket at level 31, and vanilla's
level rises one per ring, so the chunk itself and the two rings around it
are level chunks with their light, the third ring has its features and the
fourth only its terrain. Horizon took the forced chunk's reach from the
caller's margin instead, so only the chunk itself ever finished and its
neighbours stayed proto chunks.

The reach now follows the ticket levels: the full ring is two chunks out,
the features ring three and the terrain ring four, and the protos of the
terrain ring are kept so the features ring can be decorated from them. A
decorated proto chunk is saved as `minecraft:initialize_light`, the status
vanilla gives a chunk that has its features but no light.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 20:18:42 +05:00
sirrayiandDevin 6143e258ab Release notes: a Contributors heading and a rule before the disclaimer
The "Thanks to ..." line read as part of the section before it (often Known issues), on GitHub and on playhorizon.dev/releases. It now has its own heading, and a horizontal rule sets the disclaimer apart. Fixes #2314.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 14:55:35 +00:00
sirrayiandDevin 690033b71b port-pack: keep pack-index file names and jar entries inside the output
A pack index's file name (the last /-part of an mrpack path, a CurseForge or modlist jar name) was joined onto jars/ unchecked: on Windows ..\..\x.dll or C:\x escaped it. Jar entries were checked for .. only between /. Refuse names with \, : or that are . or ..

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:45:29 +00:00
levzzzandminimax-m3.1-flash f9ca0a76cf The death message names the cause's item when it has a custom name
`DamageSource.getLocalizedDeathMessage` uses the `<id>.item` message when
the cause holds an item with a custom name, with the item's name as a
third argument: "Steve was shot by Zombie using My Sword". Horizon always
used the two-argument message, so the item was never named.

The mob's main hand item now keeps its custom name, and the death message
picks the message and its arguments from it.

Fixes #2802

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:38:37 +05:00
sirrayiandDevin 4fea5fafe5 Dedicated server: apply server.properties rate-limit
rate-limit was parsed but never used. Average each player's packets a second as Connection.tickSecond does and kick past the limit with disconnect.exceeded_packet_rate, as RateKickingConnection does.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 13:36:52 +00:00
levzzzandminimax-m3.1-flash 1232745064 Keep a traveller that waits for an End gateway's exit
The traveller was dropped from the queue before the exit was resolved, so a
failure to resolve it lost the entity for good. The traveller now waits and
the failure is retried, with the reason in the log.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:34:07 +05:00
levzzzandminimax-m3.1-flash 3382b666ce Add the audit that drives a cow and a stack through an End gateway
The script plays the End in a scratch home: it places a gateway beside the
spawn platform, puts a cow and a seven-diamond stack in it, and checks that
both leave it and land at the gateway's exit with their data, in the
server-thread and the in-frame singleplayer.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:27:43 +05:00
1F10250001andClaude Opus 5.5 66969d8a93 launcher: Simplified Chinese
Adds Simplified Chinese (zh) beside English, French and Russian: the 160 strings
of launcher/lang, written for Horizon, and the language in Settings > Launcher,
the system pick (zh-Hans-CN and other zh locales) and `settings language zh`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:29:17 +09:00
levzzzandminimax-m3.1-flash 92c073de61 Merge upstream/main into fix/end-gateway-entities
Upstream now keeps a gateway's exit in the gateway's own block entity
(TheEndGatewayBlockEntity.setExitPosition), so the player travels to the
exit the gateway was given (#1517). The entity travel stays: the mobs and
the dropped items go through the same gateway, and the exit the first
traveller found is reused, so the player and the entities land in the same
place and only one island is built for a gateway.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:27:46 +05:00
levzzzandminimax-m3.1-flash 139de824aa Re-run the checks after the audit script fix
The pull request's last push did not start a run; this empty commit
starts one on the same tree.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:10:44 +05:00
Ludovic MarieandClaude Opus 5.5 ed01a93df6 Merge main into fix/world-types
main's Superflat Customize screen (#2214) still set the removed WorldOptions.flat: it now picks WorldPreset::Flat,
and the agent's customize-flat state reads the preset. structure_mobs.rs's test passes the new preset argument
(minecraft:normal) to VanillaTerrain::for_dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:39:11 +01:00
levzzzandminimax-m3.1-flash 1a56e74324 Fix the f-string in the end cargo audit script
The braces were one too many, so the file did not parse and the Python
tests failed on it. The command it builds is the same now: the item
stack {Item:{id:"minecraft:diamond",count:3}}.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:35:44 +05:00
Claude Codeandminimax-m3.1-flash 25b84dfc2e Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
levzzzandminimax-m3.1-flash a2098c2ffa Let mobs and dropped items through End gateways
Only the player was checked for End gateways, so a cow or a dropped
stack standing in one stayed there for good. The entities now go
through a gateway the way the player does: the block their box swept
through takes them in (unless the gateway is cooling down, as
EndGatewayBlock.entityInside does), they travel on the next tick
(Entity.handlePortal, with the End gateway's transition time of 0) and
land where the gateway leads once its chunks have loaded, keeping their
data and counts.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash d98dc8edc9 Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash bf05176692 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

The world-type button now cycles through the presets vanilla's
WorldPresetTags.NORMAL tag holds (normal, flat, large_biomes, amplified,
single_biome_surface), the chosen preset is written into the new world's
world_gen_settings.dat with the generator
worldgen/world_preset/<preset>.json has, and the generator is built from
that preset, so an amplified world gets the amplified noise settings.
A world opened again reads its preset back from its generator.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
levzzzandminimax-m3.1-flash 4a9bb0ce17 Format the branch with the CI's rustfmt
The CI's rustfmt (stable) writes these signatures on one line; the local
toolchain's rustfmt split them, so the check job failed on the formatting
alone.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:06:37 +05:00
levzzz cd5216e012 Merge remote-tracking branch 'upstream/main' into fix/flowing-water-mobs 2026-10-27 15:38:53 +05:00
levzzzandminimax-m3.1-flash 786c2482a1 Carry mobs along with a fluid current
A mob standing in flowing water was not carried along by it: the fluid machinery that computes a current (horizon-world's entity_fluid, the port of vanilla's EntityFluidInteraction) was wired to the player, the dropped items, the arrows and the primed TNT, but the mobs use a reduced fluid model of their own and never felt a current.

The mobs now run the same per-tick fluid update as the other bodies (Entity.updateFluidInteraction): the flow of the fluid they stand in is accumulated over the blocks of their box and added to their velocity, with the water scale 0.014 and the lava scale 0.007 (0.00233 without the fast lava dimension attribute), the current of a player being the average of the flow vectors and of any other body its direction.

The new flow-push LAN case puts a mob in a flowing water block and a second one in a pool of still sources, and checks that only the mob in the current is carried along.

The branch also drops the unreachable second "minecraft:rabbit" arm in crates/horizon-client/src/entity_nbt.rs, which clippy rejects upstream.

Fixes #2549

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:38:51 +05:00
levzzzandminimax-m3.1-flash 0cac3a9153 Remove the duplicate rabbit arm in entity_nbt
The merge of #2585 left two "minecraft:rabbit" arms in the same match, so
the second one is unreachable and clippy's -D warnings rejects the build
(upstream's own checks job is red on it since 0e793be2a). The first arm
already writes RabbitType and MoreCarrotTicks, so the second one is dead
code; its note about the legacy id moves to the arm that runs.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 15:15:54 +05:00
levzzz 9a84148376 Merge remote-tracking branch 'upstream/main' into fix/bubble-column-entities 2026-10-27 14:57:01 +05:00
levzzzandminimax-m3.1-flash 5678030da2 Carry mobs up and down with a bubble column
A mob standing in a bubble column was not moved by it: the block's
`entityInside` had no counterpart on the entity side, so only the local
player (whose motion code has it) and the arrows were affected. The mobs
now get the same push vanilla gives them in `BubbleColumnBlock.entityInside`
-> `Entity.onAboveBubbleColumn` / `Entity.onInsideBubbleColumn`: the body's
velocity along the vertical axis is raised by 0.1 (0.06 when the block above
the column is solid) and capped at 1.8 (0.7), or lowered by 0.03 and floored
at -0.9 (-0.3) for a column that drags down, and the fall distance is reset
inside a column.

The new `bubble-column` LAN case puts a mob in a soul sand column and a
second one in plain water, and checks that only the mob in the column is
carried up.

Fixes #2546

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 14:57:01 +05:00
levzzz a3489d33b4 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 10:53:42 +05:00
Joansitoh 9458e6e99e Launcher: every text field uses the shared LineEdit
Join a friend, the mods search and the Servers fields (create, players,
console, MOTD, port, token, email) had their own String and their own
key handling, so none of them could move the caret, select or undo.
They use LineEdit now, and type_key and clip, which only they used, go.
2026-10-27 01:00:11 +01:00
levzzz 9c16d40db1 Merge remote-tracking branch 'upstream/main' into fix/root-vehicle-restore 2026-10-27 04:54:43 +05:00
Claude Code 730184c64f Riding a horse survives a save and a reopen
The player's file now names the vehicle it rides, the way vanilla's
ServerPlayer.saveParentVehicle does, and the ride comes back on the next
login in both single player's modes.

- The player compound holds a "RootVehicle" compound whose "Attach" is the
  UUID of the vehicle at the root of the rider chain (Entity.getRootVehicle).
  Horizon keeps the vehicle in the world, so the file names it instead of
  holding a second copy of it: the horse and its equipment stay in the
  chunk's entity file, and nothing is duplicated.
- The single player's own player writes the vehicle it rides, and a server's
  guests write theirs; on the next login the vehicle is looked up by its
  UUID among the entities in the world, and the player rides it again.
- A guest whose file names a vehicle gets back on it once the vehicle is
  loaded, as ServerPlayer.loadAndSpawnParentVehicle does.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:40:43 +05:00
320 changed files with 18997 additions and 2135 deletions

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
+16 -13
View File
@@ -13,13 +13,15 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Ludovic's agents use
`Co-Authored-By: Claude <model> <[email protected]>` and
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
@@ -74,7 +76,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
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
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -167,17 +169,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
Generated
+3
View File
@@ -1527,6 +1527,9 @@ name = "horizon-hdr"
version = "0.1.0"
dependencies = [
"ash",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wgpu",
]
+1 -1
View File
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
_ => string("minecraft:air"),
},
);
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
f.put(
"facing",
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
);
f.float("progress", 0.0);
f.flag("extending", false);
f.flag("source", false);
@@ -873,6 +877,23 @@ mod tests {
);
}
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
#[test]
fn moving_piston_fields_match_vanilla() {
let out = canon(
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
facing:5,progress:0.5f,extending:1b,source:0b}",
);
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
}
#[test]
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
let out = canon(
+15
View File
@@ -60,6 +60,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
@@ -266,6 +269,18 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
let uk = Language::load(&[&empty], "uk_ua");
assert_eq!(
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
}
#[test]
+2 -1
View File
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
IDS.get_or_init(|| {
EFFECTS
.iter()
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
})
.collect()
})
.get(id)
.get(key)
.copied()
.unwrap_or(id)
}
@@ -54,6 +54,9 @@ pub struct Registries {
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
/// entity under the crosshair (`Entity.getStringUUID`), if any.
pub selected_entities: Vec<String>,
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
pub target_point: Option<[String; 3]>,
@@ -1563,3 +1563,49 @@ mod tests {
);
}
}
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
/// name gets the `<id>.item` message, with the item's name as a third
/// argument.
pub fn death_message_args(
key: &str,
victim: &Text,
cause: &Text,
item: Option<&Text>,
) -> (String, Vec<Text>) {
match item {
Some(item) => (
format!("{key}.item"),
vec![victim.clone(), cause.clone(), item.clone()],
),
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
}
}
#[cfg(test)]
mod death_message_tests {
use super::*;
#[test]
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
let victim = Text::literal("Steve");
let cause = Text::literal("Zombie");
// DamageSource.getLocalizedDeathMessage: the item variant, with the
// item's name as the third argument.
let (key, args) = death_message_args(
"death.attack.arrow",
&victim,
&cause,
Some(&Text::literal("My Sword")),
);
assert_eq!(key, "death.attack.arrow.item");
assert_eq!(args.len(), 3);
assert_eq!(args[0].plain(), "Steve");
assert_eq!(args[1].plain(), "Zombie");
assert_eq!(args[2].plain(), "My Sword");
// Without a custom name the message keeps its two arguments.
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
assert_eq!(key, "death.attack.arrow");
assert_eq!(args.len(), 2);
}
}
@@ -60,6 +60,19 @@ pub fn edit_shortcut(
})
}
/// Windows reports AltGr as Ctrl plus Alt: the system presses a left Control
/// in front of the right Alt.
pub const ALTGR_IS_CTRL_ALT: bool = cfg!(windows);
/// Whether a key's text reaches the screen (`KeyboardHandler.textInput`, then
/// `charTyped` per code point). The edit modifier's shortcuts type nothing,
/// but `textInput` takes no modifiers: with AltGr held (Ctrl plus Alt where
/// `altgr_is_ctrl_alt`) the layout's third-level characters are text, as `/`
/// on ABNT2, `@`, `{` and `[` on German or the Polish letters.
pub fn types_text(edit_modifier: bool, alt: bool, altgr_is_ctrl_alt: bool) -> bool {
!edit_modifier || (altgr_is_ctrl_alt && alt)
}
/// `StringUtil.isAllowedChatCharacter`.
pub fn is_allowed_chat_character(c: char) -> bool {
c != '§' && c >= ' ' && c != '\u{7f}'
@@ -303,6 +316,22 @@ pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool
mod tests {
use super::*;
#[test]
fn altgr_types_text_where_it_is_ctrl_alt() {
// Plain keys and Shift type.
assert!(types_text(false, false, true));
assert!(types_text(false, false, false));
// Ctrl (Cmd) shortcuts do not.
assert!(!types_text(true, false, true));
assert!(!types_text(true, false, false));
// AltGr on Windows is Ctrl+Alt: ABNT2 `/`, German `@` type.
assert!(types_text(true, true, true));
// Where AltGr is its own modifier, Ctrl+Alt is a shortcut.
assert!(!types_text(true, true, false));
// Alt alone (macOS Option, Linux AltGr) types.
assert!(types_text(false, true, false));
}
#[test]
fn shortcuts_need_the_edit_modifier_alone() {
let c = Some('c');
@@ -0,0 +1,151 @@
//! Two offscreen frames presented in one window, without game resources or player data.
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
use horizon_client_render::gfx::compositor::Compositor;
use std::sync::Arc;
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
struct Frames {
window: Arc<Window>,
compositor: Compositor,
textures: [wgpu::Texture; 2],
}
impl Frames {
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
let window = Arc::new(
event_loop.create_window(
Window::default_attributes()
.with_title("Compositor: red and blue views")
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
)?,
);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
Box::new(window.clone()),
));
let surface = instance.create_surface(window.clone())?;
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
..Default::default()
}))?;
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
let size = wgpu::Extent3d {
width: 320,
height: 360,
depth_or_array_layers: 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Offscreen view"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
&pixel.repeat(320 * 360),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(320 * 4),
rows_per_image: Some(360),
},
size,
);
texture
});
let compositor = Compositor::new(
surface,
&adapter,
&device,
&queue,
window.inner_size().into(),
)?;
Ok(Self {
window,
compositor,
textures,
})
}
}
#[derive(Default)]
struct Demo {
frames: Option<Frames>,
error: Option<String>,
presented: usize,
smoke: bool,
}
impl ApplicationHandler for Demo {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.frames.is_none() {
match Frames::new(event_loop) {
Ok(frames) => self.frames = Some(frames),
Err(error) => {
self.error = Some(error.to_string());
event_loop.exit();
}
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
let Some(f) = &mut self.frames else { return };
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
// Uncovered pixels are black; resize smaller to exercise clipping.
WindowEvent::RedrawRequested
if f.compositor
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
{
self.presented += 1;
if self.smoke && self.presented == 10 {
f.compositor.set_vsync(false);
f.compositor.resize(0, 0);
assert!(!f.compositor.present(&[]));
f.compositor.resize(u32::MAX, u32::MAX);
assert!(!f.compositor.present(&[]));
f.compositor.resize(400, 240);
let _ = f
.window
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
}
if self.smoke && self.presented == 20 {
println!("compositor: 20 frames, resize, suspension and clipping passed");
event_loop.exit();
}
}
_ => {}
}
}
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
if let Some(f) = &self.frames {
f.window.request_redraw();
}
}
}
fn main() -> horizon_client_render::Result<()> {
let mut demo = Demo {
smoke: std::env::args().any(|a| a == "--smoke"),
..Default::default()
};
EventLoop::new()?.run_app(&mut demo)?;
if let Some(error) = demo.error {
return Err(error.into());
}
if demo.smoke && demo.presented < 20 {
return Err("smoke test closed early".into());
}
Ok(())
}
+91 -5
View File
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
type Stream = std::convert::Infallible;
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
@@ -543,15 +544,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
)?;
Ok(stream)
}
/// Available audio output device names.
#[cfg(feature = "audio")]
pub fn output_devices() -> Vec<String> {
if output_disabled(|name| std::env::var_os(name)).is_some() {
return Vec::new();
}
let host = cpal::default_host();
let mut names = Vec::new();
if let Ok(devices) = host.output_devices() {
for device in devices {
if let Ok(name) = device.name()
&& !names.contains(&name)
{
names.push(name);
}
}
}
names
}
#[cfg(not(feature = "audio"))]
pub fn output_devices() -> Vec<String> {
Vec::new()
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
Self::with_device(root, "")
}
pub fn with_device(root: &Path, device_name: &str) -> Self {
Self::with_output(root, true, device_name)
}
/// No output device: the dedicated server.
pub fn silent(root: &Path) -> Self {
Self::with_output(root, false)
Self::with_output(root, false, "")
}
fn with_output(root: &Path, output: bool) -> Self {
/// The output device asked for (`soundDevice`), empty for the system's default; the
/// default plays when that one can't be opened, as in vanilla.
pub fn device(&self) -> &str {
&self.device_name
}
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
let registry = SoundRegistry::load(root);
if output && registry.is_empty() {
// The game then plays nothing on a working output (#1035, #1076).
@@ -564,7 +598,7 @@ impl Audio {
}
let mixer = Arc::new(Mutex::new(Mixer::default()));
let opened = if output {
Self::open(mixer.clone())
Self::open_named(mixer.clone(), target_device)
} else {
Err("no output on a dedicated server".into())
};
@@ -581,6 +615,7 @@ impl Audio {
engine.loaded = live;
Self {
_stream: stream,
device_name: target_device.to_owned(),
engine,
backend: MixerBackend {
mixer,
@@ -602,10 +637,61 @@ impl Audio {
}
}
#[cfg(not(feature = "audio"))]
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
Err("this build has no sound output (built without the `audio` feature)".into())
}
#[cfg(feature = "audio")]
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
if !target.is_empty()
&& let Ok(devices) = host.output_devices()
{
for device in devices {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if name == target {
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
Err(e) => {
log::warn!(
"Horizon: could not open audio device '{target}': {e}; falling back to default"
);
}
}
break;
}
}
}
Self::open(mixer)
}
#[cfg(feature = "audio")]
pub fn set_device(&mut self, name: &str) -> Result<()> {
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
self.device_name = name.to_owned();
return Ok(());
}
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
self.device_name = name.to_owned();
self.backend.live = true;
self.engine.loaded = true;
Ok(())
}
#[cfg(not(feature = "audio"))]
pub fn set_device(&mut self, name: &str) -> Result<()> {
self.device_name = name.to_owned();
Ok(())
}
#[cfg(feature = "audio")]
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
@@ -256,6 +256,10 @@ impl Biomes {
.copied()
.unwrap_or(0)
}
/// Whether the game has the biome `name`.
pub fn contains(&self, name: &str) -> bool {
self.index.contains_key(name)
}
pub fn get(&self, id: u16) -> &BiomeInfo {
&self.list[usize::from(id).min(self.list.len() - 1)]
}
+16 -6
View File
@@ -50,20 +50,30 @@ pub fn tooltip(
Category::Neutral,
)];
}
def.effects
.iter()
effect_lines(def.effects.iter().copied(), scale, translate)
}
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
/// scaled duration, for (effect id, duration, amplifier) entries.
pub fn effect_lines<'a>(
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
scale: f32,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, Category)> {
effects
.into_iter()
.map(|(e, duration, amp)| {
let e = e.trim_start_matches("minecraft:");
let mut line =
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
if *amp > 0 {
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
if amp > 0 {
line.push(' ');
line.push_str(
&translate(&format!("potion.potency.{amp}"))
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
);
}
let instant = effect(e).is_some_and(|d| d.instant);
let scaled = (*duration as f32 * scale) as i32;
let scaled = (duration as f32 * scale) as i32;
if !instant && scaled > 20 {
line = format!("{line} ({})", format_duration(scaled));
}
+177 -43
View File
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
pub registries: &'a Registries,
}
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
/// parse and where they are drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SuggestLayout {
/// `commandsOnly`: the input is a command without a leading `/`.
pub commands_only: bool,
/// `onlyShowIfCursorPastError`: completions from the command's start,
/// not only past the `/`.
pub only_past_error: bool,
/// `suggestionLineLimit`.
pub line_limit: usize,
/// `anchorToBottom`: the list and usage over a box at the bottom, else
/// under one at the top (y 72).
pub anchor_to_bottom: bool,
/// `fillColor`: behind the list and the usage lines.
pub fill: i32,
}
impl SuggestLayout {
/// ChatScreen.init's `CommandSuggestions`.
pub const CHAT: Self = Self {
commands_only: false,
only_past_error: false,
line_limit: 10,
anchor_to_bottom: true,
fill: FILL,
};
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
pub const COMMAND_BLOCK: Self = Self {
commands_only: true,
only_past_error: true,
line_limit: 7,
anchor_to_bottom: false,
fill: i32::MIN,
};
}
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
/// the suggestion list, Tab completion, history).
@@ -680,6 +716,7 @@ pub struct ChatScreen {
pub auto_suggestions: bool,
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
pub abilities: ChatAbilities,
pub layout: SuggestLayout,
}
/// Local completions waiting for the server's reply to request `id`.
struct Awaiting {
@@ -750,6 +787,43 @@ impl ChatScreen {
keep_suggestions: false,
auto_suggestions: true,
abilities,
layout: SuggestLayout::CHAT,
};
s.update_command_info(m, c);
s
}
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
/// focused, holding `command`, with suggestions from the start.
pub fn command_block(
m: &Measure,
c: &Commands,
command: &str,
width: i32,
height: i32,
) -> Self {
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
input.max_length = 32500;
input.focused = true;
input.set_value(m, command);
let mut s = Self {
input,
width,
height,
history_buffer: String::new(),
history_pos: 0,
parse: None,
pending: None,
awaiting: None,
request: None,
suggestions: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
allow_suggestions: true,
keep_suggestions: false,
auto_suggestions: true,
abilities: ChatAbilities::new(ChatVisibility::Full),
layout: SuggestLayout::COMMAND_BLOCK,
};
s.update_command_info(m, c);
s
@@ -768,7 +842,11 @@ impl ChatScreen {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
let abilities = self.abilities;
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
} else {
Self::command_block(m, c, &value, width, height)
};
self.auto_suggestions = auto;
}
}
@@ -814,11 +892,18 @@ impl ChatScreen {
self.usage.clear();
let cursor = self.input.cursor;
let cursor_b = byte_at(&command, cursor);
if command.starts_with('/') {
let slash = command.starts_with('/');
if self.layout.commands_only || slash {
let start = usize::from(slash);
if self.parse.is_none() {
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
}
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
let from = if self.layout.only_past_error {
start
} else {
1
};
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
let parse = self.parse.as_ref().expect("parsed above");
let (range, list, ask) =
c.dispatcher
@@ -1002,7 +1087,11 @@ impl ChatScreen {
0,
self.input.screen_x(m, 0) + self.input.inner_width() - width,
);
let y = self.height - 12;
let y = if self.layout.anchor_to_bottom {
self.height - 12
} else {
72
};
// sortSuggestions: those starting with the word at the cursor first.
let partial = &value[..byte_at(&value, self.input.cursor)];
let word = partial[last_word_index(partial)..].to_lowercase();
@@ -1011,9 +1100,16 @@ impl ChatScreen {
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
});
first.extend(rest);
let n = first.len().min(10) as i32;
// SuggestionsList: beside a bordered box, or over an unbordered one.
let n = first.len().min(self.layout.line_limit) as i32;
let bordered = i32::from(self.input.bordered);
let top = if self.layout.anchor_to_bottom {
y - 3 - n * 12
} else {
y - bordered
};
let mut l = SuggestionList {
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
rect: [x - (1 - bordered), top, width + 1, n * 12],
original: value,
list: first,
offset: 0,
@@ -1042,11 +1138,12 @@ impl ChatScreen {
}
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
self.select(l, l.current as isize + dir);
let max = l.list.len().saturating_sub(10);
let limit = self.layout.line_limit;
let max = l.list.len().saturating_sub(limit);
if l.current < l.offset {
l.offset = l.current.min(max);
} else if l.current > l.offset + 9 {
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
} else if l.current >= l.offset + limit {
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
}
}
/// `SuggestionsList.useSuggestion`.
@@ -1074,7 +1171,57 @@ impl ChatScreen {
ctrl: bool,
) -> ChatAction {
use winit::keyboard::KeyCode as K;
// CommandSuggestions.keyPressed.
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
self.field_key(m, c, key, shift, ctrl);
}
}
ChatAction::None
}
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
/// following an edit: whether the box took the key.
pub fn field_key(
&mut self,
m: &Measure,
c: &Commands,
key: winit::keyboard::KeyCode,
shift: bool,
ctrl: bool,
) -> bool {
match self.input.key(m, key, shift, ctrl) {
Some(changed) => {
if changed {
self.edited(m, c);
}
true
}
None => false,
}
}
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
/// and Tab to show it. True when the suggestions took the key.
pub fn suggestions_key(
&mut self,
m: &Measure,
c: &Commands,
key: winit::keyboard::KeyCode,
shift: bool,
) -> bool {
use winit::keyboard::KeyCode as K;
if let Some(mut l) = self.suggestions.take() {
let handled = match key {
K::ArrowUp | K::ArrowDown => {
@@ -1091,37 +1238,18 @@ impl ChatScreen {
}
K::Escape => {
self.input.suggestion = None;
return ChatAction::None;
return true;
}
_ => false,
};
self.suggestions = Some(l);
if handled {
return ChatAction::None;
}
handled
} else if key == K::Tab {
self.show_suggestions(m);
return ChatAction::None;
true
} else {
false
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
if self.input.key(m, key, shift, ctrl) == Some(true) {
self.edited(m, c);
}
}
}
ChatAction::None
}
/// `ChatScreen.moveInHistory`.
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
@@ -1175,7 +1303,7 @@ impl ChatScreen {
if !l.contains(mouse.0, mouse.1) {
return false;
}
let max = l.list.len().saturating_sub(10) as i32;
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
@@ -1246,7 +1374,9 @@ impl ChatScreen {
) -> Vec<Draw> {
let (w, h) = (self.width, self.height);
let mut out = Vec::new();
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
if self.layout.anchor_to_bottom {
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
}
let format = |s: &str, o: usize| self.format(s, o);
self.input.draw(&mut out, m, blink_ms, Some(&format));
if let Some(mut l) = self.suggestions.take() {
@@ -1254,14 +1384,18 @@ impl ChatScreen {
self.suggestions = Some(l);
} else {
for (i, line) in self.usage.iter().enumerate() {
let y = h - 27 - 12 * i as i32;
let y = if self.layout.anchor_to_bottom {
h - 27 - 12 * i as i32
} else {
72 + 12 * i as i32
};
d::fill(
&mut out,
self.usage_position - 1,
y,
self.usage_position + self.usage_width + 1,
y + 12,
FILL,
self.layout.fill,
);
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
}
@@ -1275,7 +1409,7 @@ impl ChatScreen {
l: &mut SuggestionList,
mouse: (i32, i32),
) {
let limit = l.list.len().min(10);
let limit = l.list.len().min(self.layout.line_limit);
let [rx, ry, rw, rh] = l.rect;
let has_prev = l.offset > 0;
let has_next = l.list.len() > l.offset + limit;
@@ -1284,8 +1418,8 @@ impl ChatScreen {
l.last_mouse = mouse;
}
if has_prev || has_next {
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
if has_prev {
for x in (0..rw).step_by(2) {
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
@@ -1300,7 +1434,7 @@ impl ChatScreen {
let mut hovered = false;
for i in 0..limit {
let y = ry + 12 * i as i32;
d::fill(out, rx, y, rx + rw, y + 12, FILL);
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
if moved {
self.select(l, (i + l.offset) as isize);
@@ -486,6 +486,24 @@ pub fn create_experience(
xp
}
/// The `MenuType` the block entity of a one-block storage container opens
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
pub fn block_menu_type(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
"minecraft:generic_9x3"
}
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
"minecraft:hopper" => "minecraft:hopper",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
+87 -5
View File
@@ -3,6 +3,7 @@
//! Written to `crash-reports/crash-<yyyy-MM-dd_HH.mm.ss>-client.txt` in the game folder when
//! the main thread panics or `main` gets an error back. It leaves the machine only scrubbed, and
//! only if the player allows crash reports (`telemetry`).
use std::cell::{Cell, RefCell};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -14,6 +15,51 @@ use chrono::{DateTime, Local};
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
static REPORTED: AtomicBool = AtomicBool::new(false);
thread_local! {
/// How many `contain` calls this thread is inside.
static CONTAINED: Cell<u32> = const { Cell::new(0) };
/// The message of the last panic `contain` caught on this thread, from the hook.
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
}
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
/// it end the game. For work that is not the game's own and whose failure the caller recovers
/// from, such as handing a shader pack's programs to the GPU driver's compiler: the panic goes
/// to the log with its stack, and no crash report is made for it.
pub fn contain<T>(f: impl FnOnce() -> T) -> Result<T, String> {
CONTAINED.with(|c| c.set(c.get() + 1));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
CONTAINED.with(|c| c.set(c.get() - 1));
result.map_err(|payload| {
CAUGHT
.with(|c| c.borrow_mut().take())
.unwrap_or_else(|| payload_text(&*payload))
})
}
/// For the hook: a panic inside `contain` is logged with its stack and kept for `contain` to
/// return. False for any other panic.
fn contained_panic(info: &std::panic::PanicHookInfo) -> bool {
if CONTAINED.with(Cell::get) == 0 {
return false;
}
let message = panic_message(info);
let stack = stack_lines(
&std::backtrace::Backtrace::force_capture().to_string(),
true,
);
log::error!(
"Recovered from an error: {message}\n{}",
stack
.lines()
.map(|l| format!("\tat {l}"))
.collect::<Vec<_>>()
.join("\n")
);
CAUGHT.with(|c| *c.borrow_mut() = Some(message));
true
}
/// Sets one line of the report's details (replacing the last value under `key`).
pub fn set_detail(key: &'static str, value: impl Into<String>) {
let mut details = DETAILS.lock().unwrap_or_else(|e| e.into_inner());
@@ -308,13 +354,35 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let payload = info.payload();
let text = payload
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
thread: thread_name(),
side,
details: system_details(),
log: crate::logging::recent_lines(),
},
)
}
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "Box<dyn Any>".into());
.unwrap_or_else(|| "Box<dyn Any>".into())
}
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let text = payload_text(info.payload());
match info.location() {
Some(at) => format!("panicked at {at}:\n{text}"),
None => format!("panicked:\n{text}"),
@@ -417,10 +485,13 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic.
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if contained_panic(info) {
return;
}
default(info);
let main = std::thread::current().name() == Some("main");
if main {
@@ -609,6 +680,17 @@ mod tests {
);
}
#[test]
fn contained_panics_come_back_as_errors() {
assert_eq!(contain(|| 7), Ok(7));
let error = contain(|| -> u8 { panic!("a pack's program") }).unwrap_err();
assert!(error.contains("a pack's program"), "{error}");
// Nested, and the thread is no longer inside one after.
let outer = contain(|| contain(|| -> u8 { panic!("inner") }));
assert!(outer.unwrap().unwrap_err().contains("inner"));
assert_eq!(CONTAINED.with(Cell::get), 0);
}
#[test]
fn the_debug_crash_is_reported_as_vanillas() {
let error: Box<dyn std::error::Error> = DebugCrash::new().into();
+288 -1
View File
@@ -2,7 +2,10 @@
//! the parts of them commands use: `.mcfunction` functions (with macros and
//! line continuation), function tags and predicates.
use crate::zip::Zip;
use horizon_world::nbt::Tag;
use horizon_world::{
nbt::Tag,
worldgen::overrides::{self, PackFile, WorldgenOverrides},
};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
@@ -125,6 +128,114 @@ pub fn discover(world: &Path) -> Vec<DataPack> {
packs
}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
/// built-in one (`resources` holds its `pack.png`), which can be unselected
/// and stays at the bottom. Horizon has no experiments, so their feature
/// packs are not offered.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::PackInfo> {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
packs::Position,
text::{Style, Text},
};
let format = horizon_core::version::DATA_PACK_FORMAT;
let mut infos: Vec<PackInfo> = crate::packs::folder(dir, format)
.into_iter()
.map(|p| PackInfo {
icon: p.icon_png().as_deref().and_then(load_icon),
id: p.id,
title: p.title,
description: p.raw_description,
description_style: p.description_style,
source: Source::World,
compat: p.compatibility,
required: false,
position: Position::Top,
fixed: false,
})
.collect();
infos.push(PackInfo {
id: "vanilla".into(),
title: Text::translate("dataPack.vanilla.name", &[]),
description: Text::translate("dataPack.vanilla.description", &[]),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon: std::fs::read(resources.join("pack.png"))
.ok()
.as_deref()
.and_then(load_icon),
required: false,
position: Position::Bottom,
fixed: false,
});
infos
}
/// A new empty folder for Create World's packs
/// (`Files.createTempDirectory("mcworld-")`).
pub fn temp_folder() -> std::io::Result<PathBuf> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir = std::env::temp_dir().join(format!("mcworld-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option<std::time::SystemTime>)> {
let mut stamp: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.map(|e| {
let modified = e.metadata().and_then(|m| m.modified()).ok();
(e.file_name().to_string_lossy().into_owned(), modified)
})
.collect();
stamp.sort();
stamp
}
/// Create World's packs into the new world before it opens
/// (`copyTempDataPackDirToNewWorld`): every pack of `dir` goes to
/// `<world>/datapacks`, and `datapacks.json` enables those `selected`
/// (lowest priority first) and disables the rest.
pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), String> {
let err = |p: &Path, e: std::io::Error| format!("{}: {e}", p.display());
let target = world.join("datapacks");
std::fs::create_dir_all(&target).map_err(|e| err(&target, e))?;
let mut disabled = Vec::new();
for pack in crate::packs::folder(dir, horizon_core::version::DATA_PACK_FORMAT) {
let (crate::packs::Source::Dir(from) | crate::packs::Source::Zip(from)) = &pack.source;
copy(from, &target.join(pack.id.trim_start_matches("file/")))?;
if !selected.contains(&pack.id) {
disabled.push(pack.id);
}
}
if !selected.iter().any(|id| id == "vanilla") {
disabled.push("vanilla".into());
}
let json = serde_json::json!({ "enabled": selected, "disabled": disabled });
let file = world.join("datapacks.json");
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
}
/// A file, or a folder and everything in it.
fn copy(from: &Path, to: &Path) -> Result<(), String> {
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
if !from.is_dir() {
return std::fs::copy(from, to).map(drop).map_err(err);
}
std::fs::create_dir_all(to).map_err(err)?;
for e in std::fs::read_dir(from).map_err(err)?.flatten() {
copy(&e.path(), &to.join(e.file_name()))?;
}
Ok(())
}
// ---- functions ---------------------------------------------------------------
/// A `.mcfunction`: plain command lines and macro lines.
@@ -421,9 +532,138 @@ fn hash_normalize(s: &str) -> String {
}
}
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `<world>/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
let saved: Option<serde_json::Value> = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
let list = |key: &str| -> Vec<String> {
saved
.as_ref()
.and_then(|v| v[key].as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
};
let disabled = list("disabled");
let mut enabled: Vec<String> = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
.collect();
for p in packs {
if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
enabled.push(p.id.clone());
}
}
enabled
}
// ---- worldgen ----------------------------------------------------------------
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
/// left out: the prepared bundles are it) for the chunk generator, and what
/// it leaves out, for the log. `biomes` says which biomes the game has: a
/// pack's new biome is left out (the game has no colours, sounds or id for it).
pub fn worldgen_overrides(
packs: &[&DataPack],
biomes: impl Fn(&str) -> bool,
) -> (WorldgenOverrides, Vec<String>) {
let mut out = WorldgenOverrides::default();
let mut not_read: BTreeMap<String, usize> = BTreeMap::new();
let mut warnings = Vec::new();
for pack in packs {
for file in pack.list_kinds(&overrides::PACK_KINDS) {
let read = || String::from_utf8(pack.read(&file)?).ok();
match out.add_file(&pack.id, &file, read) {
PackFile::Read | PackFile::Other => {}
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
the dimension keeps its vanilla build height",
pack.id
)),
PackFile::NotRead(kind) => {
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
}
PackFile::Unreadable => warnings.push(format!("{}: {file}: unreadable", pack.id)),
}
}
}
warnings.extend(out.finish(biomes));
for (kind, files) in not_read {
warnings.push(format!("{kind}: {files} files the generator does not read"));
}
(out, warnings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let write = |pack: &str, p: &str, t: &str| {
let path = dir.join("datapacks").join(pack).join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
for pack in ["a", "b", "off"] {
write(pack, "pack.mcmeta", "{}");
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
write(
pack,
"data/minecraft/tags/worldgen/biome/is_hill.json",
"{\"values\":[]}",
);
}
write("a", "data/minecraft/worldgen/biome/new_peaks.json", "{}");
write("a", "data/minecraft/worldgen/biome/plains.json", "{}");
write(
"a",
"data/minecraft/worldgen/world_preset/normal.json",
"{}",
);
write(
"b",
"data/minecraft/dimension_type/overworld.json",
"{\"min_y\":-64,\"height\":2096}",
);
// `b` is enabled first, so `a` (enabled after it) wins.
std::fs::write(
dir.join("datapacks.json"),
"{\"enabled\":[\"file/b\",\"file/a\"],\"disabled\":[\"file/off\"]}",
)
.unwrap();
let packs = discover(&dir);
let enabled = enabled_ids(&dir, &packs);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
.map(|id| packs.iter().find(|p| &p.id == id).unwrap())
.collect();
let (o, warnings) = worldgen_overrides(&enabled, |b| b == "minecraft:plains");
assert_eq!(
o.entries("noises").collect::<Vec<_>>(),
[("minecraft:cave", "a")]
);
assert_eq!(
o.entries("biomes").collect::<Vec<_>>(),
[("minecraft:plains", "{}")]
);
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
assert_eq!(tag_packs, ["file/b", "file/a"]);
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("height 2096"));
assert!(warnings[1].contains("minecraft:new_peaks"));
assert!(warnings[2].contains("worldgen/world_preset"));
let _ = std::fs::remove_dir_all(&dir);
}
use horizon_world::nbt::parse_snbt;
#[test]
@@ -566,4 +806,51 @@ mod tests {
assert_eq!(p.list_kinds(&kinds), wanted);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_world_installs_its_packs() {
let root = std::env::temp_dir().join(format!("hz-datapack-install-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (dir, world) = (root.join("mcworld"), root.join("world"));
let write = |pack: &str, p: &str, t: &str| {
let path = dir.join(pack).join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write("fns", "data/x/function/f.mcfunction", "say hi");
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
.unwrap();
std::io::Write::write_all(&mut zip, br#"{"pack":{"pack_format":48}}"#).unwrap();
zip.finish().unwrap();
let infos = screen_packs(&dir, &root);
let ids: Vec<&str> = infos.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip", "vanilla"]);
let compat: Vec<_> = infos.iter().map(|p| p.compat).collect();
use crate::pack_screen::Compat;
assert_eq!(
compat,
[
Compat::Compatible,
Compat::Compatible,
Compat::TooOld,
Compat::Compatible
]
);
let selected = ["vanilla", "file/old.zip", "file/fns"].map(String::from);
install(&dir, &world, &selected).unwrap();
let packs = discover(&world);
let ids: Vec<&str> = packs.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["file/fns", "file/off", "file/old.zip"]);
let saved: serde_json::Value =
serde_json::from_slice(&std::fs::read(world.join("datapacks.json")).unwrap()).unwrap();
assert_eq!(
saved,
serde_json::json!({"enabled": selected, "disabled": ["file/off"]})
);
let _ = std::fs::remove_dir_all(&root);
}
}
+22 -4
View File
@@ -97,6 +97,8 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
@@ -137,6 +139,7 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
own_name_tag: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
@@ -211,6 +214,7 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
@@ -225,7 +229,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -251,6 +255,7 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.own_name_tag),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
@@ -378,6 +383,7 @@ impl Cps {
pub enum Item {
Fullbright,
DynamicLights,
OwnNameTag,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
@@ -396,6 +402,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Third person"),
Ok(OwnNameTag),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -410,6 +418,7 @@ impl Item {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::AutoReconnect => "Auto-reconnect",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
@@ -426,7 +435,7 @@ impl Item {
Some(match self {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
return None;
}
@@ -439,6 +448,9 @@ impl Item {
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::OwnNameTag => {
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
}
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
@@ -471,7 +483,9 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
_ => None,
}
}
@@ -480,6 +494,7 @@ impl Item {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
@@ -506,6 +521,7 @@ impl Item {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
@@ -534,7 +550,7 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -558,6 +574,8 @@ mod tests {
e.minimap_rotate = true;
e.minimap_corner = Corner::BottomLeft;
e.auto_reconnect = true;
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
+314 -70
View File
@@ -43,6 +43,7 @@ pub use entity_batch::{
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
WATER_MASK_OVERLAY,
};
pub use view::{View, ViewSet};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -69,6 +70,7 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
mod animate;
mod arena;
pub mod compositor;
mod cull;
mod end_sky;
mod flat;
@@ -92,12 +94,14 @@ pub mod pack_uniforms;
mod pack_weather;
mod page_runs;
pub mod panorama;
mod readback;
mod resident;
mod stars;
mod sunrise_fan;
mod timing;
pub mod translucency;
pub(crate) mod upload;
mod view;
mod wgsl_prefix;
mod world_post;
@@ -353,6 +357,14 @@ struct BorderUniform {
color: [f32; 4],
offset: [f32; 4],
}
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
/// or the icon the Select World list shows.
pub struct ScreenshotRequest {
pub path: PathBuf,
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
/// and average it down to this size. `None` saves the frame as drawn.
pub size: Option<[u32; 2]>,
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
@@ -380,6 +392,11 @@ pub struct Renderer {
pub last_wait: std::time::Duration,
/// CPU time of the last `render`, waits excluded.
pub last_encode: std::time::Duration,
/// What the system says about the window's display, watched only while HDR output
/// is on; before `window`, so it's dropped first.
display: DisplayWatch,
/// The watch was just made: the settings' Auto values take what it reported.
display_fresh: bool,
window: Arc<Window>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
@@ -566,6 +583,8 @@ pub struct Renderer {
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
readback: readback::Readback,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
pub drawn_sections: usize,
@@ -575,8 +594,8 @@ pub struct Renderer {
pub section_draw_calls: usize,
pub uploaded_bytes: u64,
started: Instant,
/// Write the next presented frame to this PNG path (control socket).
pub screenshot: Option<PathBuf>,
/// The next frame to write to a PNG path (control socket, F2, world icon).
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Brightness option (lightmap gamma), 0..1.
@@ -1168,6 +1187,7 @@ impl Renderer {
}
surface.configure(&device, &config);
let hdr_output = hdr::output(&caps);
crate::graphics::offer_hdr(hdr_output.is_some());
if previous.is_none() {
log::info!(
"Horizon renderer: HDR output {}",
@@ -2580,6 +2600,8 @@ impl Renderer {
));
}
Ok(Self {
display: DisplayWatch::default(),
display_fresh: false,
instance,
adapter,
surface,
@@ -2722,6 +2744,7 @@ impl Renderer {
pack_terrain_flat: false,
hud,
menu_blur: None,
readback: readback::Readback::default(),
info,
suspended: !drawable_size(size.width, size.height, max_side),
drawn_sections: 0,
@@ -3241,7 +3264,7 @@ impl Renderer {
) {
surface.configure(device, config);
if let Some(hdr) = hdr {
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak);
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
}
}
fn configure_surface(&self) {
@@ -3256,7 +3279,7 @@ impl Renderer {
}
fn send_hdr_metadata(&self) {
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak);
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
@@ -3286,7 +3309,15 @@ impl Renderer {
}
/// 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, marker: &std::path::Path) {
pub fn set_hdr(
&mut self,
on: bool,
paper_white: f32,
peak: f32,
black: f32,
ui: f32,
marker: &std::path::Path,
) {
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
if output.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
@@ -3297,6 +3328,16 @@ impl Renderer {
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
// Only HDR output asks the system about the display, so with HDR off the
// game never waits on the compositor for it.
self.display = if output.is_some() {
let (watch, now) = DisplayWatch::new(&self.window);
crate::graphics::report(now);
self.display_fresh = true;
watch
} else {
DisplayWatch::default()
};
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
@@ -3309,16 +3350,35 @@ impl Renderer {
None => log::info!("Horizon renderer: HDR off"),
}
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = ui;
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
{
hdr.paper_white = paper_white;
hdr.peak = peak;
hdr.black = black;
self.send_hdr_metadata();
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
pub fn display_changed(&mut self) -> bool {
flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(now) = self.display.changed() else {
return false;
};
crate::graphics::report(now);
true
}
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
let taa = taa && self.world_post.supports_taa();
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias && !taa;
self.world_post.taa = taa;
@@ -3518,14 +3578,14 @@ impl Renderer {
&self.lightmap_sampler,
);
}
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
self.projection(
/// The views `render` draws `frame` from.
pub fn views(&self, frame: &Frame) -> ViewSet {
ViewSet::single(self.screen_view(
frame.fov_degrees,
frame.yaw,
frame.pitch,
frame.bob * frame.spin,
frame.depth_far,
)
))
}
/// The view frustum `render` will cull with, for culling geometry built
/// before the frame (entities, block entities) the same way.
@@ -3537,13 +3597,14 @@ impl Renderer {
bob: Mat4,
depth_far: f32,
) -> Frustum {
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
Frustum {
planes: frustum(self.projection(fov_degrees, yaw, pitch, bob, depth_far)),
planes: frustum(views.cull().view_proj(bob)),
}
}
fn projection(&self, fov_degrees: f32, yaw: f32, pitch: f32, bob: Mat4, far: f32) -> Mat4 {
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
world_projection(fov_degrees, aspect, far) * bob * crate::camera::view_matrix(yaw, pitch)
View::screen(yaw, pitch, fov_degrees, aspect, far)
}
fn update_lightmap(
&mut self,
@@ -3675,7 +3736,12 @@ impl Renderer {
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
(Some(f), None) => f.texture.clone(),
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
// into a texture that has it, then onto the swapchain.
(Some(f), None) => self
.readback
.target(&self.device, &f.texture, self.screenshot.is_some())
.unwrap_or_else(|| f.texture.clone()),
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
};
// One command buffer for the frame, after the section copies staged
@@ -3703,7 +3769,9 @@ impl Renderer {
frame.block_flicker,
frame.boss_darken,
);
let view_proj = self.view_projection(frame);
let views = self.views(frame);
let pose = frame.bob * frame.spin;
let view_proj = views.primary().view_proj(pose);
// TAA runs where the world post pass does, not under a shader pack's own.
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
@@ -3723,8 +3791,7 @@ impl Renderer {
frame.pitch,
);
}
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let view = views.primary().view;
let sun_angle = frame.sky.sun_angle.to_radians();
// Celestial bodies rotate around the X axis; angle 0 = noon.
let sun = Vec3::new(0.0, sun_angle.cos(), -sun_angle.sin());
@@ -3777,8 +3844,8 @@ impl Renderer {
misc: [
1.0 - frame.sky.sky_light_factor,
frame.game_time,
aspect,
(frame.fov_degrees.to_radians() / 2.0).tan(),
views.primary().aspect,
views.primary().tan_half_fov,
],
inverse_view: view.inverse().to_cols_array_2d(),
sunrise: frame.sky.sunrise_color,
@@ -3841,14 +3908,15 @@ impl Renderer {
bytemuck::bytes_of(&crosshair_globals),
);
// Frustum culling and camera-relative origins.
let cull_proj = views.cull().view_proj(pose);
let view = Frustum {
planes: frustum(view_proj),
planes: frustum(cull_proj),
};
let camera = frame.camera;
let camera_section = camera_section(camera);
// Solid quads facing away from the eye are left out: the bob moves
// the eye off `camera`, so it is found from the projection.
let eye = projection_center(view_proj).filter(|_| self.sides_on);
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
let culling = !frame.title && frame.cave_culling;
if culling {
crate::spans::time("occlusion", || {
@@ -5195,6 +5263,10 @@ impl Renderer {
((world_quads + additive_quads) * 4) as i32,
0..1,
);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder);
}
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
@@ -5203,6 +5275,13 @@ impl Renderer {
&self.entity_group,
&self.indices,
);
// The world icon grabs here: after the level and the hand, before
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
// runs right after `renderLevel`).
let readback = self
.screenshot
.take_if(|request| request.size.is_some())
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -5306,24 +5385,20 @@ impl Renderer {
(&view_target, self.config.format.remove_srgb_suffix()),
);
}
let readback = self.screenshot.take().and_then(|path| {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
Some((path, self.copy_frame(&mut encoder, &target_texture)))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
let readback = readback.or_else(|| {
self.screenshot
.take()
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
});
self.uploads.finish(&encoder);
if has_section_copies {
self.section_copy_bytes = 0;
self.section_uploads.finish(&encoder);
}
if let Some(f) = &surface_frame {
self.readback
.present(&self.device, &mut encoder, &f.texture);
}
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
}
@@ -5344,9 +5419,9 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some((path, buffer)) = readback {
if let Some((request, buffer)) = readback {
self.screenshot_result = Some(
self.write_screenshot(&path, &buffer)
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
@@ -5396,43 +5471,68 @@ impl Renderer {
Ok(true)
}
}
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
fn padded_row(width: u32) -> u32 {
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
}
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
fn copy_frame(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
[width, height]: [u32; 2],
) -> wgpu::Buffer {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon screenshot"),
size: u64::from(padded_row(width)) * u64::from(height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_row(width)),
rows_per_image: None,
},
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
buffer
}
impl Renderer {
fn padded_row(&self) -> u32 {
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
}
fn copy_frame(
&self,
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> wgpu::Buffer {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon screenshot"),
size: u64::from(self.padded_row()) * u64::from(self.config.height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(self.padded_row()),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: self.config.width,
height: self.config.height,
depth_or_array_layers: 1,
},
);
buffer
}
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
return Err("this surface does not support frame readback".into());
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
@@ -5447,7 +5547,7 @@ impl Renderer {
let (width, height, row) = (
self.config.width,
self.config.height,
self.padded_row() as usize,
padded_row(self.config.width) as usize,
);
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
@@ -5467,8 +5567,18 @@ impl Renderer {
}
}
buffer.unmap();
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
@@ -5478,6 +5588,42 @@ impl Renderer {
Ok([width, height])
}
}
/// Centre-crop the frame to a square and average it down to `size`.
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
let (side, cx, cy) = if width > height {
(height, (width - height) / 2, 0)
} else {
(width, 0, (height - width) / 2)
};
let [tw, th] = size;
let mut out = vec![0u8; (tw * th * 4) as usize];
for ty in 0..th {
let y0 = cy + ty * side / th;
let y1 = cy + (ty + 1) * side / th;
for tx in 0..tw {
let x0 = cx + tx * side / tw;
let x1 = cx + (tx + 1) * side / tw;
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
for y in y0..y1 {
for x in x0..x1 {
let p = ((y * width + x) * 4) as usize;
r += u32::from(pixels[p]);
g += u32::from(pixels[p + 1]);
b += u32::from(pixels[p + 2]);
}
}
let count = (x1 - x0) * (y1 - y0);
let o = ((ty * tw + tx) * 4) as usize;
out[o..o + 4].copy_from_slice(&[
(r / count) as u8,
(g / count) as u8,
(b / count) as u8,
255,
]);
}
}
out
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -6008,3 +6154,101 @@ mod sprite_upload_tests {
}
}
}
/// What the system says about the window's display, kept up to date: on Wayland, the
/// compositor's preferred image description of the surface.
#[derive(Default)]
struct DisplayWatch {
#[cfg(target_os = "linux")]
wayland: Option<horizon_hdr::wayland::Watch>,
}
impl DisplayWatch {
/// Starts watching, with what the system reports now.
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
#[cfg(target_os = "linux")]
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
let watch = Self {
wayland: Some(wayland),
};
return (watch, now.map(reported));
}
let _ = window;
(Self::default(), None)
}
/// What the system reports, if that changed since the last call.
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(|now| now.map(reported));
}
None
}
}
/// The log of what the system reports about the display: one line per change. The
/// renderer is made anew a few times while the game starts and each asks again, and a
/// slider dragged in the desktop's settings changes the values many times a second, so
/// a change within [`DISPLAY_LOG_QUIET`] of the last line waits until the values have
/// held that long, and only the one they settled on is logged.
struct DisplayLog {
last: String,
at: Option<std::time::Instant>,
pending: Option<(String, std::time::Instant)>,
}
static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(DisplayLog {
last: String::new(),
at: None,
pending: None,
});
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
fn log_display(line: String) {
let now = std::time::Instant::now();
let mut log = DISPLAY_LOG
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if line == log.last {
log.pending = None;
} else if log.at.is_none_or(|at| now - at >= DISPLAY_LOG_QUIET) {
log::info!("{line}");
(log.last, log.at, log.pending) = (line, Some(now), None);
} else {
log.pending = Some((line, now));
}
}
/// Logs a change [`log_display`] held back, once the values have held for
/// [`DISPLAY_LOG_QUIET`].
fn flush_display_log() {
let now = std::time::Instant::now();
let mut log = DISPLAY_LOG
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, since)) = &log.pending
&& now - *since >= DISPLAY_LOG_QUIET
&& let Some((line, _)) = log.pending.take()
{
log::info!("{line}");
(log.last, log.at) = (line, Some(now));
}
}
/// What a compositor's luminances mean in HDR output's scale.
///
/// KWin maps content's reference white (203 nits in PQ, BT.2408) onto its own, so the
/// desktop's white is 203 there and the peak is the target's maximum over that ratio.
/// Checked on KWin. Hyprland (0.57) reports the standard 203, and shows HDR windows below
/// the SDR white of its desktop (which it doesn't report), so Auto can't match its desktop
/// there. Other compositors are unchecked. The peak is kept within the settings' lowest and
/// PQ's top, so the metadata never ends below the black level.
#[cfg(target_os = "linux")]
fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
let peak = (l.target_max * 203.0 / l.reference.max(1.0)).round() as u32;
log_display(format!(
"Horizon renderer: the compositor shows this window with reference white {} nits, \
{}..{} nits: peak {peak} nits in HDR output's scale",
l.reference, l.target_min, l.target_max,
));
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
}
}
@@ -0,0 +1,214 @@
//! Splitscreen: several players' renderers draw offscreen on one device, and the
//! compositor copies each frame into its place on a window's surface. A window holds views but
//! never owns them, so a view can later move to another window (another monitor, detached).
use super::Renderer;
use crate::Result;
use std::sync::Arc;
use winit::window::Window;
pub struct Compositor {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
present_modes: Vec<wgpu::PresentMode>,
suspended: bool,
}
impl Renderer {
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
// The surface this renderer set aside for its successor (`release_surface`), else a new
// one: a window can't keep two.
let spare = self
.spare_surface
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let surface = match spare {
Some(surface) => surface,
None => self.instance.create_surface(window.clone())?,
};
Compositor::new(
surface,
&self.adapter,
&self.device,
&self.queue,
window.inner_size().into(),
)
}
/// The last offscreen frame (`--offscreen`), for the compositor.
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
self.offscreen.as_ref()
}
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
/// the window again (it takes it, as after `release_surface`).
pub fn take_back(&self, compositor: Compositor) {
*self
.spare_surface
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
}
}
impl Compositor {
/// Present offscreen BGRA frames on a compatible surface using their existing device.
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
pub fn new(
surface: wgpu::Surface<'static>,
adapter: &wgpu::Adapter,
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
) -> Result<Self> {
let caps = surface.get_capabilities(adapter);
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
// take any surface format (Rgba8Unorm on some Linux drivers).
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
return Err("surface does not support copying offscreen frames".into());
}
let format = wgpu::TextureFormat::Bgra8Unorm;
if !caps.formats.contains(&format) {
return Err(format!(
"splitscreen needs a {format:?} surface; this one offers {:?}",
caps.formats
)
.into());
}
let max = device.limits().max_texture_dimension_2d;
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
format,
width: size[0].clamp(1, max),
height: size[1].clamp(1, max),
present_mode: wgpu::PresentMode::Fifo,
desired_maximum_frame_latency: 2,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![],
color_space: wgpu::SurfaceColorSpace::default(),
};
surface.configure(device, &config);
Ok(Compositor {
surface,
device: device.clone(),
queue: queue.clone(),
config,
present_modes: caps.present_modes,
suspended: !super::drawable_size(size[0], size[1], max),
})
}
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
pub fn set_vsync(&mut self, vsync: bool) {
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
.into_iter()
.find(|m| !vsync && self.present_modes.contains(m))
.unwrap_or(wgpu::PresentMode::Fifo);
if mode != self.config.present_mode {
self.config.present_mode = mode;
self.surface.configure(&self.device, &self.config);
}
}
pub fn resize(&mut self, width: u32, height: u32) {
self.suspended =
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
if !self.suspended {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
}
}
/// Copy each view's frame to its top-left corner on the window and present. Frames that
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
/// one still at its old size after a resize) are black. Nothing when the window has no frame
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
if self.suspended {
return false;
}
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(f)
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
self.surface.configure(&self.device, &self.config);
return false;
}
_ => return false,
};
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Splitscreen compositor"),
});
let target = frame.texture.create_view(&Default::default());
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Splitscreen compositor clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
for &(texture, [x, y]) in views {
let [width, height] = clipped_size(
[texture.width(), texture.height()],
[x, y],
[self.config.width, self.config.height],
);
if width == 0 || height == 0 {
continue;
}
encoder.copy_texture_to_texture(
texture.as_image_copy(),
wgpu::TexelCopyTextureInfo {
origin: wgpu::Origin3d { x, y, z: 0 },
..frame.texture.as_image_copy()
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
}
self.queue.submit([encoder.finish()]);
self.queue.present(frame);
true
}
}
/// Copies never reach past the surface, including while a resized view still has its old frame.
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
[
view[0].min(surface[0].saturating_sub(at[0])),
view[1].min(surface[1].saturating_sub(at[1])),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_are_clipped_to_the_window_during_resize() {
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
assert_eq!(
clipped_size([640, 480], [1000, 600], [1280, 720]),
[280, 120]
);
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
assert_eq!(
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
[0, 480]
);
}
}
+111 -3
View File
@@ -63,6 +63,12 @@ struct Targets {
pub struct Hdr {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
clear_alpha: wgpu::RenderPipeline,
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
/// output keeps the hand at the world's brightness.
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
pub output: Output,
/// The frame's format: what every other pipeline draws in.
@@ -73,6 +79,10 @@ pub struct Hdr {
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
pub black: f32,
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
}
impl Hdr {
@@ -138,6 +148,48 @@ impl Hdr {
multiview_mask: None,
cache: None,
});
let alpha_pass = |label, blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&device.create_pipeline_layout(&Default::default())),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_clear_alpha"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend,
write_mask: wgpu::ColorWrites::ALPHA,
})],
}),
multiview_mask: None,
cache: None,
})
};
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
// stays 0.
let scale_alpha = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::Constant,
operation: wgpu::BlendOperation::Add,
};
let hand_mark = alpha_pass(
"Horizon HDR hand mark",
Some(wgpu::BlendState {
color: scale_alpha,
alpha: scale_alpha,
}),
);
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 32,
@@ -147,6 +199,8 @@ impl Hdr {
Self {
layout,
pipeline,
clear_alpha,
hand_mark,
params,
output,
format,
@@ -154,6 +208,8 @@ impl Hdr {
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
paper_white: 200.0,
peak: 1000.0,
black: 0.0,
ui: 1.0,
}
}
@@ -210,7 +266,9 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// Keeps the world as drawn so far, before the hand and HUD cover it.
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
@@ -220,6 +278,56 @@ impl Hdr {
t.world.as_image_copy(),
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.clear_alpha);
pass.draw(0..3, 0..1);
}
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
};
let view = t.frame.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.hand_mark);
let a = 1.0 / 255.0;
pass.set_blend_constant(wgpu::Color {
r: a,
g: a,
b: a,
a,
});
pass.draw(0..3, 0..1);
}
/// Writes the finished frame to the swapchain image `target`.
@@ -240,8 +348,8 @@ impl Hdr {
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
0.0,
0.0,
self.black.clamp(0.0, paper),
self.ui.clamp(0.25, 1.0),
0.0,
];
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
@@ -15,10 +15,19 @@ pub struct Bolt {
#[derive(Default)]
pub struct Input<'a> {
pub bolts: &'a [Bolt],
/// Current atlas white texel; a changed rect invalidates retained vertices.
/// Current atlas white texel ([`white_uv`]); a changed rect invalidates
/// retained vertices.
pub uv: [f32; 2],
}
/// The atlas point an untextured quad (`RenderTypes.lightning` has no
/// texture) samples: the middle of the white sprite. Its corner (`u0`, `v0`)
/// is on the sprite's border, where the atlas's nearest sampling picks the
/// neighbouring, transparent texels for most fragments.
pub fn white_uv(white: &crate::hud::Rect) -> [f32; 2] {
[(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0]
}
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
pub(super) const QUADS: u32 = 4 * 14 * 4;
@@ -496,3 +496,253 @@ fn retained_gpu_draw_matches_transient_additive_draw() {
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
}
}
/// `horizon/white` (4x4 texels) at (40, 12) of a 64x32 atlas.
const ATLAS: (u32, u32) = (64, 32);
fn white_sprite() -> crate::hud::Rect {
let (w, h) = (ATLAS.0 as f32, ATLAS.1 as f32);
crate::hud::Rect {
u0: 40.0 / w,
v0: 12.0 / h,
u1: 44.0 / w,
v1: 16.0 / h,
w: 4,
h: 4,
}
}
/// The white sprite's corner is on its border with the next atlas texels,
/// which nearest sampling picks for most of a bolt's fragments (#3171): the
/// point sampled is at least a texel inside the sprite.
#[test]
fn white_uv_is_inside_the_white_sprite() {
let [u, v] = white_uv(&white_sprite());
let (x, y) = (u * ATLAS.0 as f32, v * ATLAS.1 as f32);
assert!((41.0..=43.0).contains(&x), "u at texel {x}");
assert!((13.0..=15.0).contains(&y), "v at texel {y}");
}
/// A bolt seen from 14 blocks through the atlas as the game binds it (nearest
/// sampling, only the white sprite opaque) draws the same pixels as through a
/// texture that is white everywhere (#3171: the sprite's corner left a few
/// scattered pixels). Run explicitly, like the test above.
#[test]
#[ignore = "requires GPU; run separately with --ignored --exact"]
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
let buffers = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &offsets,
}),
];
// The lightning pipeline (gfx.rs): additive, into RGBA8.
let additive = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_lightning"),
compilation_options: Default::default(),
buffers: &buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: Some(wgpu::BlendState {
color: additive,
alpha: additive,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
// The game's camera: 70 degrees, reversed depth, the bolt 14 blocks ahead.
let globals = crate::gfx::Globals {
view_proj: (glam::Mat4::perspective_rh(70f32.to_radians(), 1.0, 400.0, 0.05)
* glam::Mat4::look_at_rh(glam::Vec3::ZERO, glam::Vec3::Z, glam::Vec3::Y))
.to_cols_array_2d(),
..bytemuck::Zeroable::zeroed()
};
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
let texture = |(width, height): (u32, u32), data: &[u8]| {
device
.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
data,
)
.create_view(&Default::default())
};
let white = texture((1, 1), &[255; 4]);
// Transparent but for the white sprite.
let mut texels = vec![0u8; (ATLAS.0 * ATLAS.1 * 4) as usize];
for y in 12..16 {
for x in 40..44 {
let i = ((y * ATLAS.0 + x) * 4) as usize;
texels[i..i + 4].copy_from_slice(&[255; 4]);
}
}
let atlas = texture(ATLAS, &texels);
// The atlas sampler (gfx.rs): wgpu's default, nearest.
let sampler = device.create_sampler(&Default::default());
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS);
let size = 256;
let extent = wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
};
let draw = |view: &wgpu::TextureView, uv: [f32; 2]| -> Vec<u8> {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&white),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let bolts = [Bolt {
seed: 123,
at: [0.0, -1.62, 14.0],
}];
let input = Input { bolts: &bolts, uv };
let mut renderer = Renderer::new();
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
renderer.prepare(&device, &mut uploads, &mut encoder, &input, DVec3::ZERO);
let color = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
{
let target = color.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
renderer.draw(&mut pass, &input);
}
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(size * size * 4),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size * 4),
rows_per_image: None,
},
},
extent,
);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
read.slice(..).get_mapped_range().unwrap().to_vec()
};
let lit = |pixels: &[u8]| pixels.chunks(4).filter(|p| p[3] != 0).count();
let expected = draw(&white, [0.5; 2]);
let drawn = draw(&atlas, white_uv(&white_sprite()));
assert!(lit(&expected) > 1000, "{} pixels", lit(&expected));
assert_eq!(
lit(&drawn),
lit(&expected),
"pixels drawn through the atlas"
);
assert!(drawn == expected, "the bolt's colours through the atlas");
}
@@ -476,10 +476,12 @@ fn sampler(
})
}
/// Runs `create` and returns its validation error, if any.
/// Runs `create` and returns its validation error, if any. A panic while it runs is an error
/// too: naga's backends turn a module they cannot write into a panic as the driver gets the
/// pipeline (#3525), which a pack's program must not turn into a crash.
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let value = create();
let value = crate::crash::contain(create);
match pollster::block_on(scope.pop()) {
Some(e) => {
// The details are in the error's description or its sources.
@@ -494,7 +496,7 @@ fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, St
}
Err(message)
}
None => Ok(value),
None => value.map_err(|panic| format!("the shader compiler failed: {panic}")),
}
}
@@ -2629,6 +2631,38 @@ mod tests {
assert_eq!(used, [1, 2, 3]);
}
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
#[test]
fn a_shader_compiler_panic_skips_the_pass() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let failed = checked(&device, || -> wgpu::Buffer {
panic!("internal error: entered unreachable code: Expression [89] is not cached!")
});
let error = failed.expect_err("the panic comes back as an error");
assert!(error.contains("Expression [89] is not cached!"), "{error}");
let buffer = |usage| {
checked(&device, || {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 16,
usage,
mapped_at_creation: false,
})
})
};
// The device still makes things, and its errors still come back.
assert!(buffer(wgpu::BufferUsages::UNIFORM).is_ok());
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
}
#[test]
fn pack_formats_map_to_renderable_ones() {
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
@@ -0,0 +1,213 @@
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
//! then drawn onto the swapchain image as a fullscreen triangle.
/// The texture a frame to read back is drawn into, and the pass that puts it
/// on the swapchain afterwards.
#[derive(Default)]
pub struct Readback {
texture: Option<wgpu::Texture>,
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
/// A frame drew into `texture` and still has to reach the swapchain.
drawn: bool,
}
impl Readback {
/// The texture this frame draws into instead of the swapchain image
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
/// image cannot be copied from.
pub fn target(
&mut self,
device: &wgpu::Device,
surface: &wgpu::Texture,
screenshot: bool,
) -> Option<wgpu::Texture> {
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
return None;
}
let format = surface.format().remove_srgb_suffix();
if self
.texture
.as_ref()
.is_none_or(|t| t.size() != surface.size() || t.format() != format)
{
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon screenshot frame"),
size: surface.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
}));
}
self.drawn = true;
self.texture.clone()
}
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
/// when this frame drew straight into it.
pub fn present(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::Texture,
) {
if !std::mem::take(&mut self.drawn) {
return;
}
let Some(texture) = &self.texture else {
return;
};
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let target = surface.create_view(&wgpu::TextureViewDescriptor {
format: Some(format),
..Default::default()
});
let source = texture.create_view(&Default::default());
if let Some((_, blitter)) = &self.blitter {
blitter.copy(device, encoder, &source, &target);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn texture(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 70,
height: 3,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
usage,
view_formats: &[],
})
}
fn clear(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: wgpu::Color) {
let view = texture.create_view(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(color),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
}
fn read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
}
/// The last pixel of the last row of a 70x3 frame read back by `copy_frame`.
fn last_pixel(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
let pixels = read(device, buffer);
let row = 70usize * 4;
let last = row.next_multiple_of(256) * 2 + row - 4;
pixels[last..last + 4].to_vec()
}
/// The renderer's screenshot path on a surface like OpenGL's (#3510): the
/// frame is drawn into `target`, read back with `copy_frame` and drawn on
/// the swapchain, with no validation error (the copy used to need the
/// swapchain's missing `COPY_SRC`), and the swapchain shows the frame.
#[test]
fn screenshot_of_a_surface_without_copy_src() {
let Some((device, queue)) = device() else {
return;
};
// Sampled only so the test can look at what reached it.
let surface = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
let check = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
let mut readback = Readback::default();
let colors = [
(1.0, [0, 0, 255, 255]),
// A second screenshot reuses the texture.
(0.0, [255, 0, 0, 255]),
];
for (r, bgra) in colors {
let color = wgpu::Color {
r,
g: 0.0,
b: 1.0 - r,
a: 1.0,
};
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback
.target(&device, &surface, true)
.unwrap_or_else(|| surface.clone());
clear(&mut encoder, &frame, color);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
readback.present(&device, &mut encoder, &surface);
queue.submit([encoder.finish()]);
if let Some(error) = pollster::block_on(scope.pop()) {
panic!("{error}");
}
assert_eq!(last_pixel(&device, &buffer), bgra);
let mut encoder = device.create_command_encoder(&Default::default());
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
queue.submit([encoder.finish()]);
assert_eq!(last_pixel(&device, &buffer), bgra);
}
assert!(!readback.drawn);
}
/// A surface that can be copied from, or a frame nobody reads back,
/// draws straight into the swapchain image.
#[test]
fn frames_draw_straight_to_a_readable_surface() {
let Some((device, _)) = device() else {
return;
};
let mut readback = Readback::default();
let readable = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
assert!(readback.target(&device, &readable, true).is_none());
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
assert!(readback.target(&device, &surface, false).is_none());
assert!(readback.texture.is_none() && !readback.drawn);
}
}
@@ -0,0 +1,133 @@
//! The views a frame is drawn from. A flat screen has one; a headset has
//! one per eye. Every view shares the frame's camera position, so the
//! camera-relative world (section origins, entity offsets) is built once and
//! only each view's rotation and lens differ.
use glam::Mat4;
/// The most views one frame draws.
const MAX_VIEWS: usize = 2;
/// One eye's view of the camera-relative world.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
/// Camera-relative world to eye: a rotation, plus the eye's offset from
/// the camera on a headset.
pub view: Mat4,
/// Reversed-Z perspective: the near plane at depth 1, far at 0.
pub projection: Mat4,
/// Width over height of the image this view fills.
pub aspect: f32,
/// `tan(fov_y / 2)`, for shaders rebuilding view rays from NDC.
pub tan_half_fov: f32,
}
impl View {
/// The flat-screen view: vanilla's camera rotation and a symmetric
/// vertical field of view.
pub fn screen(yaw: f32, pitch: f32, fov_degrees: f32, aspect: f32, far: f32) -> Self {
Self {
view: crate::camera::view_matrix(yaw, pitch),
projection: super::world_projection(fov_degrees, aspect, far),
aspect,
tan_half_fov: (fov_degrees.to_radians() / 2.0).tan(),
}
}
/// Clip space from the camera-relative world, with `pose` (view bobbing,
/// nausea spin) between the lens and the rotation.
pub fn view_proj(&self, pose: Mat4) -> Mat4 {
self.projection * pose * self.view
}
}
/// Every view of a frame, and the one geometry is culled with.
#[derive(Clone, Copy, Debug)]
pub struct ViewSet {
views: [View; MAX_VIEWS],
len: usize,
cull: View,
}
impl ViewSet {
/// A frame drawn from `view` alone, culled with it.
pub fn single(view: View) -> Self {
Self {
views: [view; MAX_VIEWS],
len: 1,
cull: view,
}
}
/// The view the HUD, hand and screen-space effects follow.
pub fn primary(&self) -> &View {
&self.views[0]
}
pub fn views(&self) -> &[View] {
&self.views[..self.len]
}
/// The view whose frustum covers every other view's.
pub fn cull(&self) -> &View {
&self.cull
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The formulas `Renderer` used before views existed, kept to pin the
/// screen view to them bit for bit.
fn legacy_view_proj(fov: f32, yaw: f32, pitch: f32, pose: Mat4, aspect: f32, far: f32) -> Mat4 {
super::super::world_projection(fov, aspect, far)
* pose
* crate::camera::view_matrix(yaw, pitch)
}
fn poses() -> [Mat4; 3] {
[
Mat4::IDENTITY,
Mat4::from_translation(glam::Vec3::new(0.013, -0.027, 0.0))
* Mat4::from_rotation_z(0.021),
Mat4::from_rotation_y(0.4) * Mat4::from_scale(glam::Vec3::new(1.1, 0.9, 1.0)),
]
}
#[test]
fn the_screen_view_matches_the_legacy_projection_bit_for_bit() {
for (fov, yaw, pitch, aspect, far) in [
(70.0, 0.0, 0.0, 16.0 / 9.0, 512.0),
(110.0, -137.25, 61.5, 21.0 / 9.0, 8192.0),
(30.0, 359.9, -90.0, 4.0 / 3.0, 96.0),
(82.333, 12.5, 7.75, 1.0, 1024.0),
] {
let view = View::screen(yaw, pitch, fov, aspect, far);
for pose in poses() {
let old = legacy_view_proj(fov, yaw, pitch, pose, aspect, far);
let new = view.view_proj(pose);
assert_eq!(
old.to_cols_array().map(f32::to_bits),
new.to_cols_array().map(f32::to_bits),
"fov {fov} yaw {yaw} pitch {pitch}"
);
}
assert_eq!(view.view, crate::camera::view_matrix(yaw, pitch));
assert_eq!(
view.tan_half_fov.to_bits(),
(fov.to_radians() / 2.0).tan().to_bits()
);
assert_eq!(view.aspect.to_bits(), aspect.to_bits());
}
}
#[test]
fn a_single_view_set_draws_and_culls_with_its_view() {
let view = View::screen(45.0, 10.0, 70.0, 16.0 / 9.0, 512.0);
let set = ViewSet::single(view);
assert_eq!(set.views(), &[view]);
assert_eq!(*set.primary(), view);
assert_eq!(*set.cull(), view);
}
}
@@ -41,6 +41,15 @@ struct TaaUniform {
jitter: [f32; 4],
}
/// TAA's GPU resources, absent when the device cannot compile the shader
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
/// from depth textures).
struct TaaResources {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
@@ -57,9 +66,7 @@ pub struct Post {
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
taa_on: bool,
target: Option<WorldTarget>,
taa_layout: wgpu::BindGroupLayout,
taa_pipeline: wgpu::RenderPipeline,
taa_uniform: wgpu::Buffer,
taa_resources: Option<TaaResources>,
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
taa_frame: u32,
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
@@ -105,40 +112,11 @@ impl Post {
count: None,
};
let float = wgpu::TextureSampleType::Float { filterable: true };
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon TAA bindings"),
entries: &[
texture(0, float),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
texture(2, float),
texture(3, wgpu::TextureSampleType::Depth),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon post"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon TAA"),
bind_group_layouts: &[Some(&taa_layout)],
..Default::default()
});
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon post"),
@@ -166,6 +144,88 @@ impl Post {
cache: None,
})
};
// The OpenGL backend's GLSL translator does not support textureLoad
// from depth textures: on devices running through OpenGL (such as older
// Intel GPUs on Linux), TAA cannot compile its pipeline.
let taa_resources = {
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon TAA bindings"),
entries: &[
texture(0, float),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
texture(2, float),
texture(3, wgpu::TextureSampleType::Depth),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let taa_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon TAA"),
bind_group_layouts: &[Some(&taa_layout)],
..Default::default()
});
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let taa_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon TAA"),
layout: Some(&taa_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_post"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_taa"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: HISTORY_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => {
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon TAA"),
size: std::mem::size_of::<TaaUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Some(TaaResources {
layout: taa_layout,
pipeline: taa_pipeline,
uniform,
})
}
Some(e) => {
log::warn!("Horizon renderer: temporal anti-aliasing unavailable ({e})");
None
}
}
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Horizon post"),
mag_filter: wgpu::FilterMode::Linear,
@@ -175,15 +235,7 @@ impl Post {
Self {
copy: pipeline("fs_copy", &pipeline_layout, format),
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon TAA"),
size: std::mem::size_of::<TaaUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
layout,
taa_layout,
sampler,
format,
scale: 1.0,
@@ -192,12 +244,18 @@ impl Post {
upscale: 1.0,
taa_on: false,
target: None,
taa_resources,
taa_frame: 0,
taa_previous: None,
taa_this_frame: bytemuck::Zeroable::zeroed(),
}
}
/// Whether TAA is supported on this GPU/backend.
pub fn supports_taa(&self) -> bool {
self.taa_resources.is_some()
}
/// The world's rendered size and the size it is shown at (TAA's history).
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
@@ -212,7 +270,7 @@ impl Post {
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
self.taa_on = on && self.taa;
self.taa_on = on && self.taa && self.taa_resources.is_some();
if !self.taa_on {
self.taa_previous = None;
return Mat4::IDENTITY;
@@ -290,7 +348,8 @@ impl Post {
)
.create_view(&Default::default());
let group = self.bind(device, &color);
let taa = history_size.map(|output| {
let taa = history_size.and_then(|output| {
let res = self.taa_resources.as_ref()?;
let history = [0, 1].map(|_| {
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
.create_view(&Default::default())
@@ -298,7 +357,7 @@ impl Post {
let resolve = [0, 1].map(|i| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon TAA"),
layout: &self.taa_layout,
layout: &res.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
@@ -318,18 +377,18 @@ impl Post {
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.taa_uniform.as_entire_binding(),
resource: res.uniform.as_entire_binding(),
},
],
})
});
let show = [0, 1].map(|i| self.bind(device, &history[i]));
History {
Some(History {
size: output,
history,
resolve,
show,
}
})
});
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
@@ -381,12 +440,12 @@ impl Post {
self.finish_with(encoder, screen, &target.group);
return;
};
let Some(res) = &self.taa_resources else {
self.finish_with(encoder, screen, &target.group);
return;
};
let i = (self.taa_frame % 2) as usize;
queue.write_buffer(
&self.taa_uniform,
0,
bytemuck::bytes_of(&self.taa_this_frame),
);
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
@@ -401,7 +460,7 @@ impl Post {
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.taa_pipeline);
pass.set_pipeline(&res.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
@@ -485,4 +544,21 @@ mod tests {
[0.5, 0.25, 0.75]
);
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
#[test]
fn world_post_pipelines_compile_or_fall_back() {
let Some((device, _)) = test_device() else {
return;
};
let post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
// Copy and FXAA pipelines always compile; TAA compiles if supported by backend.
let _ = post.supports_taa();
}
}
+93 -8
View File
@@ -2,13 +2,39 @@
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
//! player makes here depart from it.
use std::{fs, path::Path};
use std::{
fs,
path::Path,
sync::atomic::{AtomicU32, Ordering},
};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// What the system reports for the display HDR output goes to, in nits in HDR output's
/// scale: where its SDR white sits (the desktop's white) and the peak.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
}
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Preset {
@@ -285,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
pub fn offer_hdr(on: bool) {
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -343,10 +379,18 @@ pub struct Graphics {
pub upscaling: Upscaling,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits.
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
pub hdr_paper_white: u32,
/// HDR peak brightness the world's highlights reach, in nits.
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -381,8 +425,10 @@ impl Default for Graphics {
taa: false,
upscaling: Upscaling::Native,
hdr: false,
hdr_paper_white: 200,
hdr_peak: 1000,
hdr_paper_white: 0,
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
@@ -445,8 +491,22 @@ impl Graphics {
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
n => n.clamp(80, 480),
}
}
"hdrPeak" => {
g.hdr_peak = match number(g.hdr_peak) {
0 => 0,
n => n.clamp(200, 2000),
}
}
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
@@ -464,7 +524,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -494,6 +554,8 @@ impl Graphics {
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
bool_text(self.battery),
self.api.name(),
self.shader_pack,
@@ -502,6 +564,22 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
pub fn paper_white_nits(&self) -> u32 {
match self.hdr_paper_white {
0 => reported().map_or(203, |r| r.paper_white),
n => n,
}
}
/// The peak the world's highlights reach, in nits: the player's, or on Auto
/// what the system reports, else 1000.
pub fn peak_nits(&self) -> u32 {
match self.hdr_peak {
0 => reported().map_or(1000, |r| r.peak),
n => n,
}
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
@@ -611,12 +689,19 @@ mod tests {
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -130,8 +130,9 @@ impl Style {
label: &Text,
value: f32,
hover: bool,
active: bool,
) {
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
+243 -43
View File
@@ -1,4 +1,4 @@
//! Horizon's graphics settings screens, Simple and Advanced: which settings
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
@@ -14,6 +14,8 @@ use crate::{
pub enum Level {
Simple,
Advanced,
/// HDR output's own settings, opened from Advanced while HDR is on.
Hdr,
}
/// Horizon's own settings (`graphics::Graphics`).
@@ -38,8 +40,12 @@ pub enum H {
Antialiasing,
Upscaling,
Hdr,
HdrPaperWhite,
/// Opens `Level::Hdr`.
HdrSettings,
HdrBlackLevel,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
BatteryMode,
GraphicsApi,
}
@@ -120,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
);
let mut rows = match level {
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
Level::Hdr => vec![
Row::Header("HDR"),
h(
H::Hdr,
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrBlackLevel,
None,
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
),
h(
H::HdrPeak,
None,
"How bright highlights get. Auto: the peak your system reports.",
),
h(
H::HdrPaperWhite,
None,
"How bright white paper and menus look. Auto: your desktop's white.",
),
h(
H::HdrUiBrightness,
None,
"How bright the HUD and menus are, against paper white.",
),
],
Level::Advanced => vec![
Row::Header("Rendering"),
render,
@@ -230,20 +264,10 @@ pub fn rows(level: Level) -> Vec<Row> {
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::Hdr,
H::HdrSettings,
None,
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
),
h(
H::HdrPaperWhite,
None,
"HDR: how bright white paper and menus look. Match your desktop.",
),
h(
H::HdrPeak,
None,
"HDR: how bright the brightest highlights get. Your display's peak.",
),
h(
H::PresentMode,
None,
@@ -351,8 +375,18 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
// Auto, then 80..480 by 10.
H::HdrPaperWhite => Some(match g.hdr_paper_white {
0 => 0.0,
n => (n as f32 - 70.0) / 410.0,
}),
// Auto, then 200..2000 by 50.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
n => (n as f32 - 150.0) / 1850.0,
}),
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
_ => None,
}
}
@@ -403,8 +437,23 @@ impl H {
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
H::HdrSettings => Text::literal("HDR Settings..."),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
format!("Auto ({} nits)", g.paper_white_nits()),
),
n => labelled("Paper White", format!("{n} nits")),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
n => labelled("Peak Brightness", format!("{n} nits")),
},
H::HdrBlackLevel => labelled(
"Black Level",
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
@@ -427,8 +476,20 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
H::HdrPaperWhite => {
g.hdr_paper_white = match (v * 41.0).round() as u32 {
0 => 0,
step => 70 + step * 10,
}
}
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
0 => 0,
step => 150 + step * 50,
}
}
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
_ => {}
}
}
@@ -504,6 +565,25 @@ impl Item {
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// Why a control can't be used now, when it can't: HDR's screen opens only where
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
}
match self {
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
_ => None,
}
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
@@ -605,6 +685,7 @@ pub enum Action {
SliderDrag,
OpenAdvanced,
OpenSimple,
OpenHdr,
Done,
}
@@ -624,15 +705,20 @@ struct Widget {
#[derive(Default)]
pub struct GraphicsUi {
scroll: f32,
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option<Item>,
}
impl GraphicsUi {
pub fn reset(&mut self) {
self.scroll = 0.0;
self.scroll = ui::Scroll::default();
self.dragging = None;
}
/// The scrollbar's column: right of the descriptions.
fn scrollbar_x(w: f32) -> f32 {
STYLE.row_x(w) + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0
}
fn bounds(h: f32) -> (f32, f32) {
(STYLE.band, h - STYLE.band)
}
@@ -657,7 +743,7 @@ impl GraphicsUi {
self.clamp_scroll(h, content);
let (top, _) = Self::bounds(h);
let x = STYLE.row_x(w);
let mut y = top + 4.0 - self.scroll;
let mut y = top + 4.0 - self.scroll.amount;
let mut widgets = Vec::new();
let mut headers = Vec::new();
for row in extra_rows(level, s) {
@@ -683,6 +769,17 @@ impl GraphicsUi {
let other = match level {
Level::Simple => Action::OpenAdvanced,
Level::Advanced => Action::OpenSimple,
// One Done, back to Advanced.
Level::Hdr => {
widgets.push(Widget {
x: cx - 100.0,
y: by,
w: 200.0,
target: Target::Button(Action::Done),
list: false,
});
return (widgets, headers, content);
}
};
widgets.push(Widget {
x: cx - 155.0,
@@ -702,7 +799,7 @@ impl GraphicsUi {
}
fn clamp_scroll(&mut self, h: f32, content: f32) {
let (top, bottom) = Self::bounds(h);
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
self.scroll.clamp(top, bottom, content);
}
pub fn draw(
&mut self,
@@ -744,12 +841,10 @@ impl GraphicsUi {
continue;
}
let label = item.label(s);
let live = item.available();
if hover && !live {
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
let blocked = item.blocked(s);
let live = blocked.is_none();
if hover && let Some(why) = blocked {
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
@@ -759,6 +854,7 @@ impl GraphicsUi {
&label,
value,
hover || self.dragging == Some(item),
live,
),
None => STYLE.button(
batch,
@@ -799,15 +895,16 @@ impl GraphicsUi {
ui::scrollbar(
batch,
atlas,
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
Self::scrollbar_x(w),
top,
bottom,
content,
self.scroll,
self.scroll.amount,
);
let title = match level {
Level::Simple => "Graphics",
Level::Advanced => "Graphics: Advanced",
Level::Hdr => "Graphics: HDR",
};
STYLE.title(batch, atlas, &Text::literal(title));
if let Some(lines) = tooltip {
@@ -825,9 +922,14 @@ impl GraphicsUi {
drag: bool,
right: bool,
) -> Vec<Action> {
let (widgets, _, _) = self.layout(level, s, w, h);
let (widgets, _, content) = self.layout(level, s, w, h);
let (top, bottom) = Self::bounds(h);
if drag {
// AbstractScrollArea.mouseDragged: the held scrollbar follows the pointer.
if self.scroll.held() {
self.scroll.drag(y, top, bottom, content);
return Vec::new();
}
if let Some(item) = self.dragging
&& let Some(wd) = widgets
.iter()
@@ -841,6 +943,13 @@ impl GraphicsUi {
if right {
return Vec::new();
}
// AbstractScrollArea.updateScrolling: a press on the scrollbar holds it, silently.
if self
.scroll
.press((x, y), Self::scrollbar_x(w), top, bottom, content)
{
return Vec::new();
}
for wd in widgets {
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
continue;
@@ -851,7 +960,10 @@ impl GraphicsUi {
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if !item.available() => Vec::new(),
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
@@ -866,14 +978,16 @@ impl GraphicsUi {
}
Vec::new()
}
/// Lets go of a slider (true: it plays its click) or the scrollbar.
pub fn release(&mut self) -> bool {
self.scroll.release();
self.dragging.take().is_some()
}
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
let content = Self::content_height(level, s);
self.clamp_scroll(h, content);
self.scroll -= amount * 18.0;
self.clamp_scroll(h, content);
let (top, bottom) = Self::bounds(h);
self.scroll.clamp(top, bottom, content);
self.scroll.wheel(amount, 18.0, top, bottom, content);
}
}
@@ -881,6 +995,53 @@ impl GraphicsUi {
mod tests {
use super::*;
#[test]
fn hdr_settings_open_where_hdr_is_offered() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 2000.0);
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
let w = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
(w.x + 1.0, w.y + 1.0)
};
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
ui.click(level, s, point, size, [480, 2000], false, false)
};
crate::graphics::offer_hdr(false);
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
// Not even with HDR saved on, from another computer.
s.horizon.hdr = true;
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
s.horizon.hdr = false;
crate::graphics::offer_hdr(true);
assert_eq!(
click(&mut ui, &mut s, Level::Advanced, button),
[Action::ButtonClick, Action::OpenHdr]
);
// On HDR's screen, the sliders wait for the switch above them.
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
s.horizon.hdr = true;
assert_eq!(
click(&mut ui, &mut s, Level::Hdr, peak),
[Action::SliderDrag]
);
let on = |level, item| {
extra_rows(level, &s)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
};
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
}
}
#[test]
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
let mut s = Settings::default();
@@ -960,29 +1121,68 @@ mod tests {
assert!(max > 4.5);
for _ in 0..3 {
ui.scroll(level, &s, h, 1000.0);
assert_eq!(ui.scroll, 0.0);
assert_eq!(ui.scroll.amount, 0.0);
}
for _ in 0..3 {
ui.scroll(level, &s, h, -1000.0);
assert_eq!(ui.scroll, max);
assert_eq!(ui.scroll.amount, max);
}
ui.scroll(level, &s, h, 0.25);
assert_eq!(ui.scroll, max - 4.5);
assert_eq!(ui.scroll.amount, max - 4.5);
ui.scroll(level, &s, h, 1000.0);
ui.scroll(level, &s, h, -0.25);
assert_eq!(ui.scroll, 4.5);
assert_eq!(ui.scroll.amount, 4.5);
}
}
#[test]
fn the_scrollbar_can_be_held_and_dragged() {
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
// column, mouseDragged moves the list with the pointer, onRelease lets go.
let mut s = Settings::default();
let size = (480.0, 2.0 * STYLE.band + 60.0);
let (top, bottom) = GraphicsUi::bounds(size.1);
// On the scrollbar column, as `draw` paints it.
let x =
STYLE.row_x(size.0) + STYLE.control_width + STYLE.gap + STYLE.text_width(size.0) + 8.0;
let first_row = |ui: &mut GraphicsUi| {
ui.layout(Level::Advanced, &Settings::default(), size.0, size.1)
.0[0]
.y
};
let mut ui = GraphicsUi::default();
let start = first_row(&mut ui);
let at = |y| (x, y);
let press = |ui: &mut GraphicsUi, s: &mut Settings, y, drag| {
ui.click(Level::Advanced, s, at(y), size, [854, 480], drag, false)
};
// The press itself makes no sound and changes nothing.
assert!(press(&mut ui, &mut s, top + 5.0, false).is_empty());
assert!(press(&mut ui, &mut s, top + 15.0, true).is_empty());
let moved = first_row(&mut ui);
assert!(moved < start, "dragging down scrolls the list down");
// Past the bottom of the list: the last row.
press(&mut ui, &mut s, bottom + 50.0, true);
let content = GraphicsUi::content_height(Level::Advanced, &s);
assert_eq!(first_row(&mut ui), start - (content - (bottom - top)));
// Above the list: the first row again.
press(&mut ui, &mut s, top - 50.0, true);
assert_eq!(first_row(&mut ui), start);
// Released: drags no longer scroll, and no slider click plays.
assert!(!ui.release());
press(&mut ui, &mut s, bottom + 50.0, true);
assert_eq!(first_row(&mut ui), start);
}
#[test]
fn resizing_clamps_before_positioning_graphics_rows() {
let s = Settings::default();
for level in [Level::Simple, Level::Advanced] {
let mut ui = GraphicsUi::default();
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
assert!(ui.scroll > 0.0);
assert!(ui.scroll.amount > 0.0);
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
assert_eq!(ui.scroll, 0.0);
assert_eq!(ui.scroll.amount, 0.0);
if let Some((y, _)) = headers.first() {
assert_eq!(*y, STYLE.band + 4.0);
}
@@ -992,7 +1192,7 @@ mod tests {
expected.iter().map(|w| w.y).collect::<Vec<_>>()
);
ui.scroll(level, &s, 4000.0, -1000.0);
assert_eq!(ui.scroll, 0.0);
assert_eq!(ui.scroll.amount, 0.0);
}
}
+45 -4
View File
@@ -16,6 +16,10 @@ struct Params {
debug: f32,
// 1 for an HDR10 swapchain, 0 for scRGB.
pq: f32,
// The display's black level in nits; 0 leaves the picture as it is.
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
@@ -56,20 +60,51 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
}
// ST 2084's inverse: the PQ signal to nits.
fn pq_nits(e: vec3<f32>) -> vec3<f32> {
let p = pow(clamp(e, vec3(0.0), vec3(1.0)), vec3(1.0 / 78.84375));
let y = max(p - 0.8359375, vec3(0.0)) / (18.8515625 - 18.6875 * p);
return 10000.0 * pow(y, vec3(1.0 / 0.1593017578125));
}
// ITU-R BT.2390's black level lift (its EETF without the knee) on the PQ signal
// `e`: black moves up to the display's black level, the peak stays, and the lift
// fades out by the fourth power on the way up, so only the shadows move. A display
// that can't go darker than its black level would crush everything below it.
fn lift_black(e: vec3<f32>) -> vec3<f32> {
let top = pq(vec3(params.peak * params.paper * 80.0)).x;
let n = min(e / top, vec3(1.0));
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
}
@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);
}
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
return vec4(0.0);
}
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
let c = textureLoad(frame, at, 0).rgb;
let f = textureLoad(frame, at, 0);
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine.
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
// (terrain.wgsl, the celestial pipeline): only those shine, and only where
// nothing was drawn over them since: grey text over the white sun still looks
// like the sun darkened, but it raised the frame's alpha.
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
&& shows_world(c, w.rgb) {
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
@@ -84,9 +119,15 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
}
}
}
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
if params.pq > 0.5 {
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
}
if params.black > 0.0 {
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
}
return vec4(scrgb, 1.0);
}
+1
View File
@@ -70,6 +70,7 @@ pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod spans;
pub mod split_layout;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
+136 -26
View File
@@ -435,6 +435,28 @@ impl Plan {
false
}
}
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
/// at level 0, also `filled`) rather than by a coarser ancestor standing
/// in for it, or is past the far terrain.
fn replaces(
&self,
(x, z): (i32, i32),
ready: &FxHashMap<TileKey, impl Sized>,
filled: impl Fn(TileKey) -> bool,
) -> bool {
let Some(key) = (0..=MAX_LEVEL)
.map(|level| TileKey {
x: (x * 16).div_euclid(16 << level),
z: (z * 16).div_euclid(16 << level),
level,
})
.find(|key| self.nodes.contains(key))
else {
return true;
};
// A split node's child there is out of range: nothing is drawn.
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
}
}
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
@@ -534,6 +556,8 @@ struct Shared {
kinds: std::sync::OnceLock<Vec<u8>>,
wake: Condvar,
stop: AtomicBool,
/// Reduced far detail: samplers approximate wide cells.
approximate: AtomicBool,
}
/// One background worker and at most two outstanding sample jobs. Only the
/// current view's quadtree and a bounded set of visited summaries are retained.
@@ -571,8 +595,17 @@ pub struct Lod {
active: FxHashSet<TileKey>,
dirty: FxHashSet<TileKey>,
removed: Vec<TileKey>,
/// The columns left out: `full` and `retiring`.
covered: FxHashSet<(i32, i32)>,
covered_bounds: Option<[i32; 4]>,
/// The full-detail columns `update` was last given.
full: FxHashSet<(i32, i32)>,
/// Columns that left the full-detail area, still left out (their
/// full-detail meshes still drawn) until the tile planned there is ready
/// (`Plan::replaces`). Tiles inside that area are never sampled: when the
/// render distance shrinks, their coarsest ancestors would otherwise
/// stand in for it for minutes.
retiring: FxHashSet<(i32, i32)>,
/// The covered columns the active meshes leave out, when a mesh job
/// has finished since the last `take_cutout`.
cutout: Option<FxHashSet<(i32, i32)>>,
@@ -589,6 +622,7 @@ impl Lod {
kinds: std::sync::OnceLock::new(),
wake: Condvar::new(),
stop: AtomicBool::new(false),
approximate: AtomicBool::new(false),
});
let worker = shared.clone();
let mesh_context = context.clone();
@@ -633,6 +667,9 @@ impl Lod {
}
};
let [x, _, z] = key.origin();
sample.set_approximate(
shared.approximate.load(Ordering::Relaxed),
);
let samples =
sample.grid(x, z, key.size() / CELLS as i32, CELLS);
if shared.stop.load(Ordering::Relaxed) {
@@ -695,6 +732,7 @@ impl Lod {
}
Job::Sample(key) => {
let [x, _, z] = key.origin();
sampler.set_approximate(worker.approximate.load(Ordering::Relaxed));
let samples = sampler.grid(x, z, key.size() / CELLS as i32, CELLS);
Done::Sample { key, samples }
}
@@ -770,6 +808,8 @@ impl Lod {
removed: Vec::new(),
covered: FxHashSet::default(),
covered_bounds: None,
full: FxHashSet::default(),
retiring: FxHashSet::default(),
cutout: None,
saved: FxHashMap::default(),
reduced: FxHashMap::default(),
@@ -991,9 +1031,11 @@ impl Lod {
self.near_loading = loading;
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
/// replans when it changes.
/// replans when it changes, and tiles sampled from then on approximate
/// wide cells (`GeneratorWorker::lod_grid`).
pub fn set_reduced_detail(&mut self, reduced: bool) {
self.reduced_detail = reduced;
self.shared.approximate.store(reduced, Ordering::Relaxed);
}
fn held(&self) -> bool {
self.small && self.near_loading
@@ -1029,7 +1071,8 @@ impl Lod {
(ox + (index as i32 % 16) * stride).div_euclid(16),
(oz + (index as i32 / 16) * stride).div_euclid(16),
);
if self.covered.contains(&chunk) {
// A retiring column's tile is drawn soon: it needs its detail.
if self.full.contains(&chunk) {
self.detail_masks.entry(key).or_default()[index / 64] |= 1 << (index % 64);
} else {
chunks.insert(chunk);
@@ -1151,31 +1194,13 @@ impl Lod {
}
/// Takes the new full-detail columns, when they changed (`None`: not).
fn cover(&mut self, covered: Option<FxHashSet<(i32, i32)>>) {
if let Some(covered) = covered
&& self.covered != covered
if let Some(full) = covered
&& self.full != full
{
let changes: Vec<_> = self
.covered
.symmetric_difference(&covered)
.copied()
.collect();
for (x, z) in changes {
self.invalidate_chunk(x, z);
}
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
Some(match bounds {
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
Some([x0, z0, x1, z1]) => [
x0.min(x * 16),
z0.min(z * 16),
x1.max((x + 1) * 16),
z1.max((z + 1) * 16),
],
})
});
self.covered = covered;
self.replanned = true;
self.reselect = true;
self.retiring.extend(self.full.difference(&full).copied());
self.retiring.retain(|column| !full.contains(column));
self.full = full;
self.leave_out();
}
// Complete resident columns need no duplicate noise work. Their real
// summaries are retained when the full-detail chunks leave the view.
@@ -1189,12 +1214,55 @@ impl Lod {
}
}
}
/// Leaves out the full-detail and retiring columns: the tiles touching
/// one that changed are meshed again.
fn leave_out(&mut self) {
let covered: FxHashSet<_> = self.full.union(&self.retiring).copied().collect();
if self.covered == covered {
return;
}
let changes: Vec<_> = self
.covered
.symmetric_difference(&covered)
.copied()
.collect();
for (x, z) in changes {
self.invalidate_chunk(x, z);
}
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
Some(match bounds {
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
Some([x0, z0, x1, z1]) => [
x0.min(x * 16),
z0.min(z * 16),
x1.max((x + 1) * 16),
z1.max((z + 1) * 16),
],
})
});
// Only new covered columns need stand-ins.
self.replanned |= !covered.is_subset(&self.covered);
self.covered = covered;
self.reselect = true;
}
/// Chooses the tiles to draw and meshes those that changed, when the
/// plan, the samples, the covered columns or the active tiles changed.
fn select(&mut self) {
if !std::mem::take(&mut self.reselect) && self.dirty.is_empty() {
return;
}
if !self.retiring.is_empty() {
let (plan, ready, saved, masks) =
(&self.plan, &self.ready, &self.saved, &self.detail_masks);
// A level-0 tile there is an empty stand-in (`cover`) until the
// chunk's summary or generated detail fills it (`apply_fine`).
self.retiring.retain(|&column| {
!plan.replaces(column, ready, |key| {
saved.contains_key(&column) || masks.get(&key) == Some(&[u64::MAX; 4])
})
});
self.leave_out();
}
{
let mut selected = Vec::new();
for &key in &self.plan.roots {
@@ -1611,6 +1679,48 @@ mod tests {
assert_eq!(selected, parent.children());
}
#[test]
fn a_shrunk_render_distance_keeps_columns_until_their_planned_tiles_are_ready() {
// Render distance 64 under LOD 64: every tile inside it is a level-0
// stand-in for a covered column, and only the roots are sampled.
let camera = [8, 8];
let before = Plan::new(camera, 64, 64);
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
let mut ready: FxHashMap<TileKey, ()> = before
.nodes
.iter()
.filter(|key| key.level == 0 || before.roots.contains(key))
.map(|&key| (key, ()))
.collect();
ready.retain(|key, _| after.nodes.contains(key));
let contains = |key: &TileKey, (x, z): (i32, i32)| {
let [ox, _, oz] = key.origin();
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
};
// 24 chunks out, now past the render distance: its planned tile was
// split before, so never sampled, and a root stands in for it.
let column = (24, 0);
let mut selected = Vec::new();
for &root in &after.roots {
after.select(root, &ready, &mut selected);
}
let drawn = selected.iter().filter(|key| contains(key, column));
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
assert!(!after.replaces(column, &ready, |_| true));
let planned = TileKey {
x: 12,
z: 0,
level: 1,
};
assert!(after.nodes.contains(&planned) && before.split.contains(&planned));
ready.insert(planned, ());
assert!(after.replaces(column, &ready, |_| true));
// 12 chunks out, its level-0 tile is the stand-in until filled.
assert!(!after.replaces((12, 0), &ready, |_| false));
assert!(after.replaces((12, 0), &ready, |_| true));
// Past the far terrain nothing waits.
assert!(after.replaces((100, 0), &ready, |_| false));
}
#[test]
fn detail_survives_movement_across_a_split_threshold() {
let before = Plan::new([0, 0], 2, 256);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
@@ -513,6 +513,56 @@ impl MeshTables {
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
}
/// `BlockColors` for each of the state's tint indices at `pos`, as the
/// chunk mesher tints them (grass blocks included): `None` for an
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
pub fn block_tints(
&self,
state: BlockStateId,
pos: [i32; 3],
region: &[[Option<&Chunk>; 3]; 3],
biomes: &Biomes,
zoom: BiomeZoom,
blend_radius: i32,
) -> impl Fn(i32) -> Option<u32> + use<> {
let sources = self.info.get(state.0 as usize).map(|info| {
let [x, y, z] = pos;
let origin = [x >> 4, z >> 4, y & !15];
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
let local = [x & 15, y & 15, z & 15];
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
(colors, i32::from(info.tint_count))
});
move |index| {
let (colors, count) = sources?;
if index < 0 || index >= count {
return None;
}
colors[(index as usize).min(1)]
}
}
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
/// packing, the quad's own emission included) for `quad` of `state`
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
/// it: `light` is the world's stored light (sky << 4 | block) at a
/// position. For block models drawn outside the chunk mesh.
pub fn flat_light(
&self,
state: BlockStateId,
pos: [i32; 3],
quad: &horizon_assets::bake::ModelQuad,
light: impl Fn([i32; 3]) -> u8,
) -> u32 {
let own = self.info.get(state.0 as usize);
let full = own.is_some_and(|i| i.full);
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
let packed = light(at);
let coords = match own {
Some(own) => lighting::packed_coords(own, packed),
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
};
lighting::with_emission(coords, quad.light_emission)
}
/// `BlockState.getOffset` for blocks with a random model offset.
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
offset(self.info.get(state.0 as usize)?, x, z)
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
/// (block level << 4 | sky level << 20).
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
}
/// `coords` of a stored light byte (sky level << 4 | block level).
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
if state.emissive {
return FULL_BRIGHT;
}
let packed = pad.light[pi(p[0], p[1], p[2])];
let block = u32::from((packed & 15).max(state.emission));
block << 4 | u32::from(packed >> 4) << 20
}
@@ -274,8 +277,28 @@ pub(super) fn flat(
positions: &[[f32; 3]; 4],
face: D,
) -> u32 {
let cubic = shape(positions, face, own.full).cubic;
coords(pad, own, if cubic { step(pos, face) } else { pos })
coords(
pad,
own,
flat_position(pos, positions, face, None, own.full),
)
}
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
/// when the quad is cubic, else the block itself). `full` is the block's
/// `isCollisionShapeFullBlock`.
pub(super) fn flat_position(
pos: [i32; 3],
positions: &[[f32; 3]; 4],
face: D,
cull: Option<D>,
full: bool,
) -> [i32; 3] {
match cull {
Some(d) => step(pos, d),
None if shape(positions, face, full).cubic => step(pos, face),
None => pos,
}
}
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
/// and smooth light coords, by vertex index.
@@ -398,6 +421,39 @@ mod tests {
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
}
#[test]
fn flat_light_reads_the_neighbour_a_face_looks_at() {
let pos = [4, 64, -2];
let full_top = [
[0.0, 1.0, 0.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 1.0, 0.0],
];
// A cull face samples its neighbour, whatever the quad's shape.
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
assert_eq!(
flat_position(pos, &inset, D::Up, Some(D::Up), false),
[4, 65, -2]
);
// A flush quad without a cull face samples its neighbour too: a block
// drawn at a cell that is now opaque (a moving block, #3002) still
// takes the light around it.
assert_eq!(
flat_position(pos, &full_top, D::Up, None, false),
[4, 65, -2]
);
let west = [
[0.0, 0.0, 0.0],
[0.0, 0.0, 1.0],
[0.0, 1.0, 1.0],
[0.0, 1.0, 0.0],
];
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
// An inset quad (a piston head's arm) takes the block's own light.
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
}
#[test]
fn quad_shape_classifies_partial_and_cubic_faces() {
let full_top = [
[0.0, 1.0, 0.0],
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
assert_eq!(tint("minecraft:pink_petals"), None);
assert_eq!(tint("minecraft:stone"), None);
}
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
/// as in the world, not left gray like its particles.
#[test]
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
let root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("horizon-resources.json").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
let biomes = Biomes::load(&root).unwrap();
let state = |name| assets.registry.default_state(name).unwrap();
let center = Chunk::empty(0, 0);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&center);
let tints = |name| {
tables.block_tints(
state(name),
[5, 70, 9],
&region,
&biomes,
BiomeZoom::new(0),
2,
)
};
let grass = tints("minecraft:grass_block");
assert!(grass(0).is_some());
assert_eq!(grass(-1), None);
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
assert_eq!(tints("minecraft:stone")(0), None);
}
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
];
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
/// view distance tops out at 127 anyway.
pub const MAX_RENDER_DISTANCE: i32 = 128;
const INACTIVITY: &[&str] = &["minimized", "afk"];
const CLOUDS: &[&str] = &["false", "fast", "true"];
const MAIN_HAND: &[&str] = &["left", "right"];
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
b("reducedDebugInfo", false),
def("renderClouds", O(Clouds), V::Int(2)),
i("cloudRange", 2, 128, 128),
i("renderDistance", 2, 64, 12),
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
i("simulationDistance", 5, 32, 12),
u("screenEffectScale", 1.0),
def("soundDevice", O(AnyStr), V::Str(String::new())),
+324 -103
View File
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
Opt::HighContrastBlockOutlines,
];
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
/// their vanilla order. The macOS-only Control-click switch is omitted on
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
/// does in Java.
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
let mut options = vec![
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::ToggleAttack,
Opt::ToggleUse,
Opt::AutoJump,
Opt::SprintWindow,
Opt::QuitShortcuts,
];
if cfg!(target_os = "macos") {
options.push(Opt::CtrlClickEmulatesRightClick);
}
options.push(Opt::OperatorItemsTab);
options
}
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
Opt::Sensitivity,
@@ -120,6 +141,11 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
}
fn file_key(self) -> Option<&'static str> {
@@ -144,6 +170,10 @@ impl Opt {
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
Opt::SoundDevice => "soundDevice",
Opt::OperatorItemsTab => "operatorItemsTab",
Opt::QuitShortcuts => "quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
Opt::AllowServerListing => "allowServerListing",
Opt::RealmsNotifications => "realmsNotifications",
@@ -167,6 +197,7 @@ impl Opt {
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
Opt::AllowCursorChanges => "options.allowCursorChanges",
Opt::Volume(_) => "soundCategory.master",
Opt::SoundDevice => "options.audioDevice",
Opt::GuiScale => "options.guiScale",
Opt::ViewBobbing => "options.viewBobbing",
Opt::Gamma => "options.gamma",
@@ -194,7 +225,13 @@ impl Opt {
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
Opt::ToggleCrouch => "key.sneak",
Opt::ToggleSprint => "key.sprint",
Opt::ToggleAttack => "key.attack",
Opt::ToggleUse => "key.use",
Opt::AutoJump => "options.autoJump",
Opt::SprintWindow => "options.sprintWindow",
Opt::QuitShortcuts => "options.quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
Opt::OperatorItemsTab => "options.operatorItemsTab",
Opt::ChatVisibility => "options.chat.visibility",
Opt::ChatColors => "options.chat.color",
Opt::ChatLinks => "options.chat.links",
@@ -217,7 +254,7 @@ impl Opt {
Opt::NotificationDisplayTime => "options.notifications.display_time",
Opt::Narrator => "options.narrator",
Opt::TextBackground => "options.accessibility.text_background",
Opt::ChatDelay => "Chat Delay",
Opt::ChatDelay => "horizon.options.chat_delay",
Opt::DistortionEffects => "options.screenEffectScale",
Opt::FovEffects => "options.fovEffectScale",
Opt::DarknessPulsing => "options.darknessEffectScale",
@@ -278,7 +315,11 @@ impl Opt {
| Opt::GlintStrength
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
Opt::Fov => unit(s.fov, 30.0, 110.0),
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
Opt::RenderDistance => unit(
s.render_distance as f32,
2.0,
crate::options_file::MAX_RENDER_DISTANCE as f32,
),
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
Opt::Sensitivity => Kind::Slider(s.sensitivity),
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
@@ -308,6 +349,7 @@ impl Opt {
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
_ => Kind::Cycle,
}
}
@@ -356,6 +398,14 @@ impl Opt {
}
}
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
Opt::SprintWindow => {
let value = if s.sprint_window == 0 {
tr("options.off")
} else {
Text::tr("options.value", &[&s.sprint_window])
};
generic(c, value)
}
Opt::Fov => match s.fov as i32 {
70 => generic(c, tr("options.fov.min")),
110 => generic(c, tr("options.fov.max")),
@@ -573,7 +623,20 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::ToggleAttack => tr(if s.toggle_attack {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::ToggleUse => tr(if s.toggle_use {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::AutoJump => on_off(s.auto_jump),
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
Opt::ChatVisibility => tr([
"options.chat.visibility.full",
"options.chat.visibility.system",
@@ -586,6 +649,13 @@ impl Opt {
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
Opt::HighContrast => on_off(s.high_contrast),
Opt::ShowSubtitles => on_off(s.show_subtitles),
Opt::SoundDevice => {
if s.sound_device.is_empty() {
tr("options.audioDevice.default")
} else {
Text::literal(&s.sound_device)
}
}
Opt::MusicFrequency => tr([
"options.music_frequency.default",
"options.music_frequency.frequent",
@@ -603,25 +673,25 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
gamepad::Lightbar::Health => "Health",
gamepad::Lightbar::Dimension => "Dimension",
gamepad::Lightbar::Off => "Off",
Opt::PadLightbar => tr(match s.controller.lightbar {
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
gamepad::Lightbar::Off => "options.off",
}),
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
GlyphStyle::Auto => "Auto",
GlyphStyle::Xbox => "Xbox",
GlyphStyle::PlayStation => "PlayStation",
GlyphStyle::Nintendo => "Nintendo",
Opt::PadGlyphs => tr(match s.controller.glyphs {
GlyphStyle::Auto => "horizon.options.glyphs.auto",
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "Focus",
gamepad::MenuNav::Pointer => "Pointer",
Opt::PadMenuNav => tr(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
}),
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "Auto",
gamepad::TextKeyboard::Horizon => "Horizon",
gamepad::TextKeyboard::Steam => "Steam",
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
_ => Text::empty(),
}
@@ -647,7 +717,10 @@ impl Opt {
crate::options_file::V::Double(f64::from(v)),
),
Opt::Fov => s.fov = step(30.0, 110.0),
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
Opt::RenderDistance => {
s.render_distance =
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
}
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
Opt::Sensitivity => s.sensitivity = v,
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
@@ -674,6 +747,7 @@ impl Opt {
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
_ => {}
}
if self.affects_preset() {
@@ -770,7 +844,14 @@ impl Opt {
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
Opt::AutoJump => s.auto_jump = !s.auto_jump,
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
Opt::CtrlClickEmulatesRightClick => {
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
}
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
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,
@@ -779,6 +860,23 @@ impl Opt {
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
Opt::HighContrast => s.high_contrast = !s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
Opt::SoundDevice => {
let devices = crate::audio::output_devices();
let mut all = vec![String::new()];
for d in devices {
if !all.contains(&d) {
all.push(d);
}
}
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
let count = all.len();
let next_idx = if back {
(current_idx + count - 1) % count
} else {
(current_idx + 1) % count
};
s.sound_device = all[next_idx].clone();
}
Opt::MusicFrequency => {
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
s.file
@@ -921,6 +1019,7 @@ pub enum Page {
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
GraphicsHdr,
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
@@ -1012,7 +1111,11 @@ pub enum Event {
Language(String),
/// The pack selection changed (reload resources).
Packs(Vec<String>),
/// Create World's Data Packs screen closed with a new selection.
DataPacks(Vec<String>),
OpenPackFolder,
/// Data Packs' Open Pack Folder: create the folder if needed and open it.
OpenDataPackFolder(std::path::PathBuf),
OpenModFolder,
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
ListShaderPacks,
@@ -1078,6 +1181,13 @@ pub struct OptionsUi {
pub pack_selection: Vec<String>,
/// The open pack screen, made at its first frame.
pub pack_screen: Option<crate::pack_screen::PackScreen>,
/// The Packs page is Create World's Data Packs screen on this folder.
data_pack_dir: Option<std::path::PathBuf>,
/// The game's resources, for the built-in data pack's icon.
data_pack_resources: std::path::PathBuf,
/// The folder's entries when its packs were listed, and when they were
/// last compared with it.
data_pack_stamp: (Vec<(String, Option<std::time::SystemTime>)>, Instant),
/// A reload failure, dismissed before returning to the current page.
pub resource_error: Option<String>,
/// When the pack screen's search box took the focus (its cursor blink).
@@ -1112,6 +1222,9 @@ impl Default for OptionsUi {
pack_infos: Vec::new(),
pack_selection: Vec::new(),
pack_screen: None,
data_pack_dir: None,
data_pack_resources: Default::default(),
data_pack_stamp: (Vec::new(), Instant::now()),
resource_error: None,
pack_focus: Instant::now(),
in_world: false,
@@ -1164,22 +1277,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
Page::Graphics => "Graphics, simply: each setting says what it costs.",
Page::GraphicsAdvanced => {
"Every graphics setting, Horizon's included, with what each costs."
}
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
Page::Extras => {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::ShaderPacks => {
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
}
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
_ => return None,
})
}
@@ -1219,9 +1324,48 @@ impl OptionsUi {
pub fn open(&mut self, in_world: bool) {
self.open_at(in_world, Page::Main);
}
/// Create World's Data Packs (`openDataPackSelectionScreen`): the Packs
/// page on the new world's pack folder `dir`, `selected` chosen;
/// `resources` holds the built-in pack's icon.
pub fn open_data_packs(
&mut self,
dir: std::path::PathBuf,
selected: Vec<String>,
resources: std::path::PathBuf,
) {
self.open_at(false, Page::Packs);
self.pack_infos = crate::datapack::screen_packs(&dir, &resources);
self.data_pack_resources = resources;
self.pack_selection = selected;
self.data_pack_stamp = (crate::datapack::folder_stamp(&dir), Instant::now());
self.data_pack_dir = Some(dir);
}
/// `PackSelectionScreen.tick`'s watcher: once a second, the Data Packs
/// folder's packs are listed again if it changed, the selection kept
/// (the search text and scroll start over).
fn watch_data_packs(&mut self, atlas: &HudAtlas, size: (f32, f32)) {
let Some(dir) = &self.data_pack_dir else {
return;
};
if self.data_pack_stamp.1.elapsed().as_secs() < 1 {
return;
}
let stamp = crate::datapack::folder_stamp(dir);
let changed = stamp != self.data_pack_stamp.0;
self.data_pack_stamp = (stamp, Instant::now());
if !changed {
return;
}
self.pack_infos = crate::datapack::screen_packs(dir, &self.data_pack_resources);
if let Some(p) = self.pack_screen.take() {
self.pack_selection = p.commit();
self.packs(atlas, size).changed = p.changed;
}
}
/// Open a page whose Done/Escape returns directly to the calling screen.
pub fn open_at(&mut self, in_world: bool, page: Page) {
self.stack.clear();
self.data_pack_dir = None;
self.push(page);
self.in_world = in_world;
self.selected_key = None;
@@ -1242,7 +1386,10 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
if matches!(
page,
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
) {
self.graphics.reset();
}
}
@@ -1251,6 +1398,7 @@ impl OptionsUi {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
_ => None,
}
}
@@ -1269,16 +1417,16 @@ impl OptionsUi {
Page::Language => "options.language.title",
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
Page::Extras => "Horizon Extras",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Extras => "horizon.pages.extras",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "Horizon Settings",
Page::Agent => "AI Agent",
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -1416,21 +1564,23 @@ impl OptionsUi {
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
let open = |label: &str, page: Page| {
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
};
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("Graphics...", Page::Graphics),
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
open("horizon.pages.open.graphics", Page::Graphics),
Some(open(
"horizon.pages.open.graphics_advanced",
Page::GraphicsAdvanced,
)),
),
Row::Buttons(
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
Some(open("horizon.pages.open.controller", Page::Controller)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
@@ -1438,7 +1588,7 @@ impl OptionsUi {
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("AI Agent...", Page::Agent)
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
@@ -1457,7 +1607,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Copy Connection Details"),
tr("horizon.agent.copy_details"),
Act::CopyAgentDetails,
)),
)];
@@ -1471,14 +1621,14 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Horizon Graphics..."),
tr("horizon.pages.open.horizon_graphics"),
Act::Push(Page::Graphics),
),
Some(button(
0.0,
0.0,
150.0,
Text::literal("Advanced Graphics..."),
tr("horizon.pages.open.graphics_advanced"),
Act::Push(Page::GraphicsAdvanced),
)),
)];
@@ -1527,6 +1677,7 @@ impl OptionsUi {
rows.extend(pairs(&others));
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
rows.push(Row::Big(Opt::SoundDevice));
rows
}
Page::Skin => {
@@ -1562,18 +1713,15 @@ impl OptionsUi {
Act::Push(Page::KeyBinds),
)),
)];
rows.extend(pairs(&[
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::AutoJump,
]));
let controls = vanilla_control_options();
rows.extend(pairs(&controls));
// Horizon's controller page, after vanilla's rows.
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
Text::literal("Controller Settings..."),
tr("horizon.pages.open.controller"),
Act::Push(Page::Controller),
),
None,
@@ -1586,7 +1734,7 @@ impl OptionsUi {
0.0,
0.0,
310.0,
Text::literal("Controller Binds..."),
tr("horizon.pages.open.controller_binds"),
Act::Push(Page::PadBinds),
),
None,
@@ -1644,9 +1792,7 @@ impl OptionsUi {
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
Page::Mods => {
if s.mods.installed.is_empty() {
return vec![Row::Header(
"No mods: put .hmod files in the horizon-mods folder",
)];
return vec![Row::Header("horizon.mods.empty")];
}
s.mods
.installed
@@ -1656,9 +1802,9 @@ impl OptionsUi {
let mut label = Text::literal(format!("{}: ", installed.title))
.append(on_off(s.mods.is_on(i)));
if installed.problem.is_some() {
label = label.append(" (Stopped)");
label = label.append(tr("horizon.mods.stopped"));
} else if installed.dev {
label = label.append(" (Dev)");
label = label.append(tr("horizon.mods.dev"));
}
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
@@ -1669,7 +1815,7 @@ impl OptionsUi {
0.0,
0.0,
150.0,
Text::literal("Settings"),
tr("horizon.mods.settings"),
Act::ModSettings(i),
);
Row::Buttons(
@@ -1686,17 +1832,17 @@ impl OptionsUi {
Page::NativeMod(i) => self.native_rows(s, i),
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
"No shader packs: put them in the shaderpacks folder",
)];
return vec![Row::Header("horizon.shader_packs.empty")];
}
s.shader_packs
.iter()
.enumerate()
.map(|(i, pack)| {
let kind = match pack.kind {
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
crate::shader_pack_list::Kind::Classic => "",
crate::shader_pack_list::Kind::Horizon => {
lang::get("horizon.shader_packs.kind_horizon")
}
crate::shader_pack_list::Kind::Classic => String::new(),
};
let label = Text::literal(format!("{}{kind}: ", pack.title))
.append(on_off(s.horizon.shader_pack == pack.file));
@@ -1756,8 +1902,8 @@ impl OptionsUi {
// Vanilla's Telemetry Data and Credits slots: Horizon's
// settings and its mods, as a mod adds its button here.
// Credits & Attribution (WinScreen's credits) follows them.
("Horizon Settings...", Some(Page::Horizon)),
("Mods...", Some(Page::Mods)),
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
("horizon.pages.open.mods", Some(Page::Mods)),
("options.credits_and_attribution", Some(Page::Credits)),
];
let rows = entries.len().div_ceil(2) as f32;
@@ -2190,18 +2336,12 @@ impl OptionsUi {
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal(label),
act,
));
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
out.push(done(h - 27.0));
@@ -2273,6 +2413,7 @@ impl OptionsUi {
return;
}
if self.page() == Page::Packs {
self.watch_data_packs(atlas, (w, h));
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
let blink = self.pack_focus.elapsed().as_millis() as u64;
let m = crate::screen_draw::Measure(&atlas.font);
@@ -2313,7 +2454,7 @@ impl OptionsUi {
} = &wd.kind
&& let Some(text) = horizon_page_tooltip(*page)
{
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
tooltip = Some(atlas.font.split(&tr(text), 170.0));
}
let key = match &wd.kind {
WKind::Button {
@@ -2340,10 +2481,7 @@ impl OptionsUi {
} = &wd.kind
&& !opt.available()
{
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
}
if let WKind::Button {
act: Act::ToggleMod(i),
@@ -2484,9 +2622,7 @@ impl OptionsUi {
if search.value.is_empty() && !search.focused {
batch.text_styled(
atlas,
&Text::literal("Search settings...")
.with_color(text::GRAY)
.italic(),
&tr("horizon.options.search").with_color(text::GRAY).italic(),
search.text_x(),
search.text_y(),
u32::MAX,
@@ -2533,6 +2669,7 @@ impl OptionsUi {
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -2568,7 +2705,10 @@ impl OptionsUi {
self.pack_focus = Instant::now();
}
match action {
crate::pack_screen::Action::OpenFolder => events.push(Event::OpenPackFolder),
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
None => Event::OpenPackFolder,
}),
crate::pack_screen::Action::Done => events.extend(self.back(s)),
crate::pack_screen::Action::None => {}
}
@@ -2820,8 +2960,14 @@ impl OptionsUi {
}
None => {
self.pack_focus = Instant::now();
let title = if self.data_pack_dir.is_some() {
"dataPack.title"
} else {
"resourcePack.title"
};
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
&m,
crate::text::Text::translate(title, &[]),
w,
h,
self.in_world,
@@ -2853,13 +2999,18 @@ impl OptionsUi {
}
Page::Packs => {
// PackSelectionScreen.onClose: PackSelectionModel.commit.
let data = self.data_pack_dir.take();
if let Some(p) = self.pack_screen.take()
&& p.changed
{
let ids = p.commit();
s.resource_packs = ids.clone();
self.pack_selection = ids.clone();
events.push(Event::Packs(ids));
if data.is_some() {
events.push(Event::DataPacks(ids));
} else {
s.resource_packs = ids.clone();
self.pack_selection = ids.clone();
events.push(Event::Packs(ids));
}
}
}
_ => {}
@@ -3155,10 +3306,48 @@ mod tests {
)));
}
#[test]
fn controls_match_java_26_3_option_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Controls);
let options =
ui.list_rows(&Settings::default())
.into_iter()
.fold(Vec::new(), |mut options, row| {
match row {
Row::Big(opt) => options.push(opt),
Row::Pair(a, b) => {
options.push(a);
options.extend(b);
}
_ => {}
}
options
});
assert_eq!(options, vanilla_control_options());
}
#[test]
fn sprint_window_and_new_control_switches_round_trip() {
let mut settings = Settings::default();
set_slider(Opt::SprintWindow, &mut settings, 0.5);
settings.operator_items_tab = true;
settings.quit_shortcuts = false;
settings.ctrl_click_emulates_right_click = true;
settings.toggle_attack = true;
settings.toggle_use = true;
let loaded = Settings::parse(&settings.serialize());
assert_eq!(loaded.sprint_window, 5);
assert!(loaded.operator_items_tab);
assert!(!loaded.quit_shortcuts);
assert!(loaded.ctrl_click_emulates_right_click);
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
set_slider(Opt::RenderDistance, &mut s, position);
assert_eq!(s.render_distance, distance);
let reloaded = Settings::parse(&s.serialize());
@@ -3169,10 +3358,10 @@ mod tests {
);
}
let mut file = crate::options_file::OptionsFile::default();
file.set_text("renderDistance", "64").unwrap();
assert!(file.set_text("renderDistance", "65").is_err());
file.set_text("renderDistance", "128").unwrap();
assert!(file.set_text("renderDistance", "129").is_err());
assert!(file.set_text("renderDistance", "1").is_err());
assert_eq!(file.int("renderDistance"), 64);
assert_eq!(file.int("renderDistance"), 128);
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
@@ -3246,8 +3435,15 @@ mod tests {
ui.show(Page::Mods);
let empty = ui.layout(&s, size.0, size.1).0;
assert!(empty.iter().any(|w| {
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
}));
// The empty page points at the bundled key; the English line behind it
// is what a player reads, so both halves are checked here.
let en = crate::lang::Language::load(&[], "en_us");
assert!(
en.get("horizon.mods.empty")
.is_some_and(|line| line.contains(".hmod"))
);
let open_folder = empty
.iter()
.find(|w| {
@@ -4422,4 +4618,29 @@ mod tests {
ui.stack = vec![Page::Main, Page::Controls];
assert!(pushes(&mut ui).contains(&Page::Controller));
}
#[test]
fn sound_device_button_in_sound_page_and_cycles() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let rows = ui.list_rows(&s);
assert!(
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
"Sound page should contain Row::Big(Opt::SoundDevice)"
);
assert_eq!(s.sound_device, "");
let devices = crate::audio::output_devices();
let count = 1 + devices.len();
// Cycling forward 'count' times wraps around back to the initial selection ("")
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
}
assert_eq!(s.sound_device, "");
// Cycling backward 'count' times also wraps around back to ""
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
}
assert_eq!(s.sound_device, "");
}
}
@@ -318,6 +318,8 @@ pub enum Action {
/// The pack screen and its state.
#[derive(Clone, Debug)]
pub struct PackScreen {
/// `resourcePack.title`, or `dataPack.title` from Create World.
pub title: Text,
pub width: i32,
pub height: i32,
pub in_world: bool,
@@ -342,6 +344,7 @@ impl PackScreen {
/// `PackSelectionScreen.init`: the lists filled, the search box focused.
pub fn new(
m: &Measure,
title: Text,
width: i32,
height: i32,
in_world: bool,
@@ -360,6 +363,7 @@ impl PackScreen {
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
.collect();
let mut s = Self {
title,
width,
height,
in_world,
@@ -382,7 +386,7 @@ impl PackScreen {
}
/// The header layout: title, drop info and search box, centred.
fn header(&self, m: &Measure) -> [(i32, i32); 3] {
let tw = m.width(&tr("resourcePack.title"));
let tw = m.width(&self.title);
let dw = m.width(&tr("pack.dropInfo"));
let col = tw.max(dw).max(200);
let frame = self.width.max(col);
@@ -837,7 +841,7 @@ impl PackScreen {
return out;
}
let [(tx, ty), (dx, dy), _] = self.header(m);
d::string_widget(&mut out, m, &tr("resourcePack.title"), tx, ty, 0);
d::string_widget(&mut out, m, &self.title, tx, ty, 0);
d::string_widget(
&mut out,
m,
@@ -1105,7 +1109,7 @@ mod icon_tests {
pack("high_contrast", Some(contrast.clone())),
pack("file/Iconless", None),
];
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
let mut draws = screen.draw(&m, (0, 0), 0);
let mut patches = Vec::new();
screen.bind_icons(&mut draws, &atlas, &mut patches);
@@ -1152,7 +1156,8 @@ mod click_sound_tests {
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let mut screen = PackScreen::new(&m, 480, 320, false, Vec::new(), &[]);
let title = tr("resourcePack.title");
let mut screen = PackScreen::new(&m, title, 480, 320, false, Vec::new(), &[]);
assert!(!screen.button_at(&m, 0.0, 0.0));
assert!(!screen.button_at(
&m,
+46 -7
View File
@@ -181,9 +181,15 @@ pub fn compatible(meta: &serde_json::Value) -> bool {
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
/// when the metadata names no format.
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
compatibility_for(meta, RESOURCE_FORMAT)
}
/// `compatibility` against `current`, the resource or the data pack format.
pub fn compatibility_for(
meta: &serde_json::Value,
current: (i64, i64),
) -> crate::pack_screen::Compat {
use crate::pack_screen::Compat;
let pack = &meta["pack"];
let current = RESOURCE_FORMAT;
let range = if let (Some(min), Some(max)) = (
format_of(&pack["min_format"]),
format_of(&pack["max_format"]),
@@ -275,6 +281,10 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
serde_json::from_slice(&bytes).ok()
}
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
pack_for(id, title, source, RESOURCE_FORMAT)
}
/// A pack whose compatibility is against `format`.
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
let meta = read_meta(&source)?;
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
Some(Pack {
@@ -284,7 +294,7 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
raw_description,
description_style,
compatible: compatible(&meta),
compatibility: compatibility(&meta),
compatibility: compatibility_for(&meta, format),
overlays: overlays_of(&meta),
filter: filter_of(&meta),
source,
@@ -305,7 +315,13 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
out.push(p);
}
}
let mut user: Vec<Pack> = fs::read_dir(packs_dir)
out.extend(folder(packs_dir, RESOURCE_FORMAT));
out
}
/// The packs (folders and zips) in `dir`, by name, as `FolderRepositorySource`
/// finds them; their compatibility is against `format`.
pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
let mut user: Vec<Pack> = fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
@@ -319,12 +335,11 @@ pub fn discover(resources: &Path, packs_dir: &Path) -> Vec<Pack> {
} else {
return None;
};
pack(format!("file/{name}"), Text::literal(name), source)
pack_for(format!("file/{name}"), Text::literal(name), source, format)
})
.collect();
user.sort_by(|a, b| a.id.cmp(&b.id));
out.extend(user);
out
user
}
/// A file a pack provides: its path (folder packs) and its name inside the pack.
type Provided = (Option<PathBuf>, String);
@@ -451,6 +466,22 @@ fn build_overlay_uncached(
files.insert(name, (i, provided));
}
}
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
// supplies the file or one above it; vanilla's own sits below every pack.
let hidden: BTreeSet<String> = files
.iter()
.filter(|(name, _)| !name.ends_with(".mcmeta"))
.filter_map(|(name, (owner, _))| {
let meta = format!("{name}.mcmeta");
match files.get(&meta) {
Some((meta_owner, _)) if meta_owner >= owner => None,
_ => Some(meta),
}
})
.collect();
for name in &hidden {
files.remove(name);
}
// Vanilla sits below every pack, so filters hide its files too.
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
let touched_dirs: BTreeSet<String> = files
@@ -502,6 +533,7 @@ fn build_overlay_uncached(
vanilla: &Path,
out: &Path,
touched: &BTreeSet<String>,
hidden: &BTreeSet<String>,
files: &BTreeMap<String, (usize, Provided)>,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
@@ -538,6 +570,9 @@ fn build_overlay_uncached(
}
for name in names {
let child = format!("{prefix}{name}");
if hidden.contains(&child) {
continue;
}
if let Some(bytes) = merged.get(&child) {
fs::write(out.join(&child), bytes)?;
} else if let Some((owner, (path, inner))) = files.get(&child) {
@@ -568,6 +603,7 @@ fn build_overlay_uncached(
vanilla,
out,
touched,
hidden,
files,
merged,
filters,
@@ -591,6 +627,7 @@ fn build_overlay_uncached(
vanilla,
out,
&touched_dirs,
&hidden,
&files,
&merged,
&filters,
@@ -781,7 +818,9 @@ impl SelectionModel {
/// `available` in any order; `selected` as options.txt lists them,
/// bottom first.
pub fn new(mut available: Vec<Selectable>, selected: &[String]) -> Self {
// PackRepository.available is a TreeMap by id (String.compareTo).
// PackRepository.discoverAvailable: every source's packs go into one
// TreeMap by id (String.compareTo), so `file/` packs sort among the
// built-in ones.
available.sort_by(|a, b| a.id.encode_utf16().cmp(b.id.encode_utf16()));
available.dedup_by(|a, b| a.id == b.id);
let mut m = Self {
@@ -3,7 +3,6 @@
use super::{Pack, Source, build_overlay_uncached};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(any(test, not(unix)))]
use std::io::Read;
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
@@ -60,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(bytes);
}
#[cfg(any(test, not(unix)))]
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
let mut file = fs::File::open(path)?;
hash.update(file.metadata()?.len().to_le_bytes());
@@ -238,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
}
Source::Zip(path) => {
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
stamp(&mut hash, path)?;
// By its bytes, not its stamp: a server pack found in the download cache is
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
// time rebuilt the whole overlay each time. One file reads in a moment.
content(&mut hash, path)?;
}
}
}
@@ -349,6 +350,27 @@ mod tests {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// Waits for the filesystem's clock to move on, as it does between a build and a
/// person's next edit. On Unix the cache compares stamps (length, mtime, ctime, inode),
/// and Linux takes them from a coarse clock (a few milliseconds a tick): an edit in the
/// tick of the build before kept them all, and the tests failed at random.
fn next_tick(&self) {
let probe = self.0.with_extension("tick");
// New bytes each time: NTFS keeps the time of a write that changes nothing.
let stamp = |n: u32| {
fs::write(&probe, n.to_le_bytes()).unwrap();
fs::metadata(&probe).unwrap().modified().unwrap()
};
let start = stamp(0);
// A second at most: no clock is that coarse, and a test never hangs on one.
for n in 1..1000 {
std::thread::sleep(std::time::Duration::from_millis(1));
if stamp(n) != start {
break;
}
}
let _ = fs::remove_file(&probe);
}
fn dir_pack(&self, name: &str) -> Pack {
self.write(
&format!("{name}/pack.mcmeta"),
@@ -371,6 +393,7 @@ mod tests {
output_signature(&cached).unwrap(),
output_signature(&self.0.join("reference")).unwrap()
);
self.next_tick();
}
}
impl Drop for Fixture {
@@ -647,6 +670,38 @@ mod tests {
);
}
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
/// pack found in the download cache is marked used on every join
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
#[test]
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
let f = Fixture::new();
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
let files = vec![
(
"pack.mcmeta",
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
),
("assets/minecraft/textures/a", b"aaa".to_vec()),
];
f.write("pack.zip", &archive(&files, false));
let zip = f.0.join("pack.zip");
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
// A rebuild empties the output first: this file survives a cache hit only.
fs::write(out.join("kept"), b"").unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
fs::OpenOptions::new()
.append(true)
.open(&zip)
.unwrap()
.set_modified(later)
.unwrap();
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
assert!(out.join("kept").exists(), "the overlay was rebuilt");
}
#[cfg(unix)]
#[test]
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
@@ -101,6 +101,8 @@ pub struct Settings {
pub master_volume: f32,
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
pub volumes: [f32; 11],
/// Empty for system default, or a specific audio device name.
pub sound_device: String,
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
pub gui_scale: u32,
pub view_bobbing: bool,
@@ -174,6 +176,12 @@ pub struct Settings {
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
pub sprint_window: i32,
/// Show the operator items tab in a creative inventory.
pub operator_items_tab: bool,
/// Allow platform quit shortcuts to close the game window.
pub quit_shortcuts: bool,
/// On macOS, turn Control-click into a right click.
pub ctrl_click_emulates_right_click: bool,
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
pub horizon: crate::graphics::Graphics,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
@@ -195,6 +203,7 @@ impl Default for Settings {
sensitivity: 0.5,
master_volume: 1.0,
volumes: [1.0; 11],
sound_device: String::new(),
gui_scale: 0,
view_bobbing: true,
gamma: 0.5,
@@ -256,6 +265,9 @@ impl Default for Settings {
toggle_attack: false,
toggle_use: false,
sprint_window: 7,
operator_items_tab: false,
quit_shortcuts: true,
ctrl_click_emulates_right_click: false,
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -383,6 +395,7 @@ impl Settings {
sensitivity: f.double("mouseSensitivity") as f32,
master_volume: volumes[0],
volumes,
sound_device: f.string("soundDevice"),
gui_scale: f.int("guiScale").max(0) as u32,
view_bobbing: f.bool("bobView"),
gamma: f.double("gamma") as f32,
@@ -418,6 +431,9 @@ impl Settings {
toggle_attack: f.bool("toggleAttack"),
toggle_use: f.bool("toggleUse"),
sprint_window: f.int("sprintWindow"),
operator_items_tab: f.bool("operatorItemsTab"),
quit_shortcuts: f.bool("quitShortcuts"),
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -475,6 +491,7 @@ impl Settings {
};
put_f(&mut f, &format!("soundCategory_{source}"), v);
}
put_s(&mut f, "soundDevice", &self.sound_device);
put_i(&mut f, "guiScale", self.gui_scale as i32);
put_b(&mut f, "bobView", self.view_bobbing);
put_f(&mut f, "gamma", self.gamma);
@@ -518,6 +535,13 @@ impl Settings {
put_b(&mut f, "toggleAttack", self.toggle_attack);
put_b(&mut f, "toggleUse", self.toggle_use);
put_i(&mut f, "sprintWindow", self.sprint_window);
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
put_b(
&mut f,
"ctrlClickEmulatesRightClick",
self.ctrl_click_emulates_right_click,
);
put_b(&mut f, "autoJump", self.auto_jump);
put_s(&mut f, "lang", &self.language);
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
@@ -637,6 +661,7 @@ pub enum Opt {
InvertY,
AllowCursorChanges,
Volume(usize),
SoundDevice,
GuiScale,
ViewBobbing,
Gamma,
@@ -664,7 +689,13 @@ pub enum Opt {
MenuBlur,
ToggleCrouch,
ToggleSprint,
ToggleAttack,
ToggleUse,
AutoJump,
SprintWindow,
QuitShortcuts,
CtrlClickEmulatesRightClick,
OperatorItemsTab,
ChatVisibility,
ChatColors,
ChatLinks,
@@ -820,4 +851,15 @@ mod tests {
1.0
);
}
#[test]
fn sound_device_option() {
let s = Settings::parse("soundDevice:\"Headphones\"\n");
assert_eq!(s.sound_device, "Headphones");
let serialized = s.serialize();
assert!(serialized.contains("soundDevice:\"Headphones\""));
let again = Settings::parse(&serialized);
assert_eq!(again.sound_device, "Headphones");
let empty = Settings::parse("");
assert_eq!(empty.sound_device, "");
}
}
@@ -58,6 +58,8 @@ pub enum Ticking {
Bee { bee: u32, aggressive: bool },
/// `GuardianAttackSoundInstance`.
GuardianAttack { guardian: u32 },
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
Elytra { time: i32 },
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -95,6 +97,12 @@ pub struct TickContext {
pub mobs: Vec<MobSound>,
/// `!tickRateManager().runsNormally()`.
pub frozen: bool,
/// The player's position.
pub player_position: [f64; 3],
/// `getDeltaMovement().lengthSqr()` of the player.
pub player_speed_sqr: f64,
/// `isFallFlying()` of the player.
pub player_fall_flying: bool,
}
/// `AbstractSoundInstance` and its subclasses' fields.
#[derive(Clone, Debug)]
@@ -390,6 +398,24 @@ impl SoundInstance {
};
s
}
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
/// its first tick.
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
let mut s = Self::new(
"minecraft:item.elytra.flying",
PLAYERS,
0.1,
1.0,
random,
true,
0,
Attenuation::Linear,
position,
false,
);
s.ticking = Ticking::Elytra { time: 0 };
s
}
pub fn tickable(&self) -> bool {
self.ticking != Ticking::No
}
@@ -494,6 +520,37 @@ impl SoundInstance {
self.volume = 0.0;
}
}
Ticking::Elytra { time } => {
*time += 1;
let time = *time;
if ctx.player_removed || (time > 20 && !ctx.player_fall_flying) {
self.stop();
return;
}
let [x, y, z] = ctx.player_position;
self.position = [
f64::from(x as f32),
f64::from(y as f32),
f64::from(z as f32),
];
let speed = ctx.player_speed_sqr as f32;
self.volume = if speed >= 1.0e-7 {
(speed / 4.0).clamp(0.0, 1.0)
} else {
0.0
};
// Silent for a second, then a second's fade in.
if time < 20 {
self.volume = 0.0;
} else if time < 40 {
self.volume *= (time - 20) as f32 / 20.0;
}
self.pitch = if self.volume > 0.8 {
1.0 + (self.volume - 0.8)
} else {
1.0
};
}
Ticking::GuardianAttack { guardian } => {
let id = *guardian;
// The client's guardian never has a target of its own, so
@@ -0,0 +1,272 @@
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
//! its window (`place`).
use winit::dpi::PhysicalSize;
/// Players at most: P1 and fifteen more.
pub const MAX_PLAYERS: usize = 16;
/// A player's view: its window (an index into the windows), its corner there and its size.
pub type View = (usize, [u32; 2], PhysicalSize<u32>);
/// Rows of five to sixteen players sharing a window, widest first (columns on a tall window): every
/// view stays near a screen's shape, where a full grid would leave a long thin one.
const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[3, 2],
&[3, 3],
&[3, 2, 2],
&[3, 3, 2],
&[3, 3, 3],
&[4, 3, 3],
&[4, 4, 3],
&[4, 4, 4],
&[4, 3, 3, 3],
&[4, 4, 3, 3],
&[4, 4, 4, 3],
&[4, 4, 4, 4],
];
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
let mut out: Vec<usize> = Vec::with_capacity(n);
for &i in order {
if i < n && !out.contains(&i) {
out.push(i);
}
}
out.extend((0..n).filter(|i| !order.contains(i)));
out
}
/// `place` with the players sharing a window in `order` (`ordered`): the first left or on top.
/// The views are still by player.
pub fn place_ordered(
windows: &[usize],
order: &[usize],
sizes: &[PhysicalSize<u32>],
two: u8,
three: u8,
) -> Vec<View> {
let order = ordered(order, windows.len());
let in_order: Vec<usize> = order.iter().map(|&i| windows[i]).collect();
let placed = place(&in_order, sizes, two, three);
let mut views = placed.clone();
for (k, &i) in order.iter().enumerate() {
views[i] = placed[k];
}
views
}
/// Each player's view, given the window each is in and the windows' sizes. Players in one window
/// share it in the order they come: two take halves (`two`: 0 side by side when wider than
/// tall, 1 side by side, 2 top and bottom); three (`three`: 0 the first wide on top and two below,
/// 1 the first tall on the left and two on the right, 2 three columns); four its quarters; five
/// to sixteen in balanced rows (`ROWS`).
pub fn place(windows: &[usize], sizes: &[PhysicalSize<u32>], two: u8, three: u8) -> Vec<View> {
windows
.iter()
.enumerate()
.map(|(i, &window)| {
let here = windows.iter().filter(|&&w| w == window).count();
let slot = windows[..i].iter().filter(|&&w| w == window).count() as u32;
let size = sizes
.get(window)
.copied()
.unwrap_or(PhysicalSize::new(1, 1));
let (w, h) = (size.width, size.height);
let side_by_side = match two {
1 => true,
2 => false,
_ => w >= h,
};
// The view's cell as fractions of the window: (x0, x1, y0, y1) in halves or thirds.
let cell = |x0: u32, x1: u32, y0: u32, y1: u32, of: u32| {
[w * x0 / of, w * x1 / of, h * y0 / of, h * y1 / of]
};
let [x0, x1, y0, y1] = match (here, slot) {
(1, _) => cell(0, 1, 0, 1, 1),
(2, s) if side_by_side => cell(s, s + 1, 0, 2, 2),
(2, s) => cell(0, 2, s, s + 1, 2),
(3, s) => match (three, s) {
(2, s) => cell(s, s + 1, 0, 3, 3),
(1, 0) => cell(0, 1, 0, 2, 2),
(1, s) => cell(1, 2, s - 1, s, 2),
(_, 0) => cell(0, 2, 0, 1, 2),
(_, s) => cell(s - 1, s, 1, 2, 2),
},
(4, s) => cell(s % 2, s % 2 + 1, s / 2, s / 2 + 1, 2),
(n, s) => rows(n, s, w, h),
};
(
window,
[x0, y0],
PhysicalSize::new((x1 - x0).max(1), (y1 - y0).max(1)),
)
})
.collect()
}
/// Five to sixteen players: `slot`'s cell `[x0, x1, y0, y1]` in balanced rows (`ROWS`), or
/// in columns when the window is taller than wide.
fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
let counts = ROWS[n.clamp(5, MAX_PLAYERS) - 5];
let bands = counts.len() as u32;
let (mut band, mut k) = (0, slot);
while band + 1 < bands && k >= counts[band as usize] {
k -= counts[band as usize];
band += 1;
}
let across = counts[band as usize];
// Along: the long side, divided between a band's views; down: the bands.
let (along, down) = if w >= h { (w, h) } else { (h, w) };
let (a0, a1) = (along * k / across, along * (k + 1) / across);
let (d0, d1) = (down * band / bands, down * (band + 1) / bands);
if w >= h {
[a0, a1, d0, d1]
} else {
[d0, d1, a0, a1]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
PhysicalSize::new(w, h)
}
#[test]
fn players_on_one_screen_share_it() {
let wide = [size(1920, 1080)];
assert_eq!(
place(&[0, 0], &wide, 0, 0)[1],
(0, [960, 0], size(960, 1080))
);
assert_eq!(
place(&[0, 0], &wide, 2, 0)[1],
(0, [0, 540], size(1920, 540))
);
// Auto on a tall screen: top and bottom.
assert_eq!(place(&[0, 0], &[size(1080, 1920)], 0, 0)[1].1, [0, 960]);
assert_eq!(
place(&[0; 4], &wide, 0, 0)[3],
(0, [960, 540], size(960, 540))
);
}
#[test]
fn desk_and_couch() {
// P1 alone at the desk; P2 and P3 share the TV.
let screens = [size(3024, 1964), size(3840, 2160)];
let views = place(&[0, 1, 1], &screens, 0, 0);
assert_eq!(views[0], (0, [0, 0], size(3024, 1964)));
assert_eq!(views[1], (1, [0, 0], size(1920, 2160)));
assert_eq!(views[2], (1, [1920, 0], size(1920, 2160)));
}
#[test]
fn three_on_a_screen() {
let tv = [size(1920, 1080)];
// One wide on top, two below.
let top = place(&[0; 3], &tv, 0, 0);
assert_eq!(top[0], (0, [0, 0], size(1920, 540)));
assert_eq!(top[2], (0, [960, 540], size(960, 540)));
// One tall on the left, two stacked on the right.
let left = place(&[0; 3], &tv, 0, 1);
assert_eq!(left[0], (0, [0, 0], size(960, 1080)));
assert_eq!(left[2], (0, [960, 540], size(960, 540)));
// Three columns.
assert_eq!(
place(&[0; 3], &tv, 0, 2)[2],
(0, [1280, 0], size(640, 1080))
);
}
#[test]
fn two_windows_two_players_each() {
// P1 and P2 at the desk, P3 and P4 on the TV: halves in each window.
let screens = [size(3024, 1964), size(3840, 2160)];
let views = place(&[0, 0, 1, 1], &screens, 0, 0);
assert_eq!(views[1], (0, [1512, 0], size(1512, 1964)));
assert_eq!(views[2], (1, [0, 0], size(1920, 2160)));
assert_eq!(views[3], (1, [1920, 0], size(1920, 2160)));
}
#[test]
fn five_to_eight_in_rows_of_three_and_two() {
let tv = [size(1920, 1080)];
let eight = place(&[0; 8], &tv, 0, 0);
assert_eq!(eight[0], (0, [0, 0], size(640, 360)));
assert_eq!(eight[5], (0, [1280, 360], size(640, 360)));
assert_eq!(eight[7], (0, [960, 720], size(960, 360)));
let seven = place(&[0; 7], &tv, 0, 0);
assert_eq!(seven[3], (0, [0, 360], size(960, 360)));
// Every count tiles the window exactly: no gap, no overlap.
for n in 5..=MAX_PLAYERS {
for screen in [size(1920, 1080), size(1080, 1920), size(1711, 997)] {
let views = place(&vec![0; n], &[screen], 0, 0);
for (i, (_, [x, y], size)) in views.iter().enumerate() {
assert!(x + size.width <= screen.width && y + size.height <= screen.height);
for (_, [ox, oy], other) in &views[..i] {
assert!(
x + size.width <= *ox
|| ox + other.width <= *x
|| y + size.height <= *oy
|| oy + other.height <= *y,
"overlapping views for {n} in {screen:?}"
);
}
}
let area: u64 = views
.iter()
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
.sum();
assert_eq!(
area,
u64::from(screen.width) * u64::from(screen.height),
"{n} in {screen:?}"
);
}
}
// A tall window: columns.
let tall = place(&[0; 5], &[size(1080, 1920)], 0, 0);
assert_eq!(tall[0], (0, [0, 0], size(540, 640)));
assert_eq!(tall[4], (0, [540, 960], size(540, 960)));
}
#[test]
fn sixteen_players_form_a_four_by_four_grid() {
let views = place(&[0; 16], &[size(3840, 2160)], 0, 0);
for (i, view) in views.iter().enumerate() {
assert_eq!(
*view,
(
0,
[(i as u32 % 4) * 960, (i as u32 / 4) * 540],
size(960, 540)
)
);
}
}
#[test]
fn an_order_names_each_player_once_and_the_others_come_last() {
assert_eq!(ordered(&[], 3), [0, 1, 2]);
assert_eq!(ordered(&[2, 0], 3), [2, 0, 1]);
// Gone players and repeats are dropped.
assert_eq!(ordered(&[5, 1, 1], 3), [1, 0, 2]);
}
#[test]
fn players_sharing_a_window_fill_it_in_their_order() {
let wide = [size(2000, 1000)];
// P2 first: P2 on the left, P1 on the right.
let views = place_ordered(&[0, 0], &[1, 0], &wide, 0, 0);
assert_eq!(views[1].1, [0, 0]);
assert_eq!(views[0].1, [1000, 0]);
// P3 first of three: the wide view on top is P3's.
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
assert_eq!(views[2].2, size(2000, 500));
}
}
+206 -34
View File
@@ -13,11 +13,10 @@ use horizon_core::biome::BiomeZoom;
use horizon_world::block_render::FaceVisibility;
use horizon_world::worldgen::decoration_order::DecorationOrder;
use std::{
collections::{BinaryHeap, HashSet},
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, RwLock,
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
};
@@ -99,6 +98,9 @@ pub trait LodSampler: Send {
fn drain_details(&mut self) -> Vec<LodDetail> {
Vec::new()
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
/// approximate wide cells.
fn set_approximate(&mut self, _approximate: bool) {}
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
crate::lod::Column::from_surface(self.sample(x, z))
@@ -168,6 +170,9 @@ enum Job {
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
/// Meshes the renderer is done with (`Streaming::free`): their memory
/// is given back here, not in the frame.
Free(Vec<((i32, i32, i32), SectionMesh)>),
}
pub enum Done {
Generated(Chunk),
@@ -214,11 +219,52 @@ struct Shared {
/// What meshing reads (block models and sprites), swapped when resource
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
done: Mailbox,
}
/// Finished jobs, from the workers to the game thread, in the order they
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
/// half way through a send, and a worker descheduled there on a busy machine
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
/// flight's 1% lows). The frame takes what is there without waiting; while a
/// worker holds the lock, it takes it the next frame.
#[derive(Default)]
struct Mailbox {
done: Mutex<VecDeque<Done>>,
arrived: Condvar,
}
impl Mailbox {
fn send(&self, done: Done) {
self.done.lock().unwrap().push_back(done);
self.arrived.notify_one();
}
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
match self.done.try_lock() {
Ok(done) => Some(done),
Err(TryLockError::WouldBlock) => None,
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
}
}
/// Every finished job, or none while a worker is sending.
fn take(&self) -> VecDeque<Done> {
self.try_lock()
.map(|mut done| std::mem::take(&mut *done))
.unwrap_or_default()
}
fn try_recv(&self) -> Option<Done> {
self.try_lock()?.pop_front()
}
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
let done = self.done.lock().unwrap();
let (mut done, _) = self
.arrived
.wait_timeout_while(done, timeout, |done| done.is_empty())
.unwrap();
done.pop_front()
}
}
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,
/// Ticks since the columns and protos that fell out of range were swept.
@@ -398,8 +444,14 @@ impl GenerationScan {
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
}
}
// Forced chunks and their generation neighbourhood.
let reach = margin - 1;
// Forced chunks and their generation neighbourhood: the ring that
// is decorated (or finished, without decoration) and the one its
// 3x3 needs.
let reach = if input.decorates {
FORCED_TERRAIN_RING
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for dz in -reach..=reach {
for dx in -reach..=reach {
@@ -455,14 +507,13 @@ impl Streaming {
ready: Condvar::new(),
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
});
let (send, results) = mpsc::channel();
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
.map(|i| {
let shared = shared.clone();
let send = send.clone();
let generator = generator.clone();
let light = context.light.clone();
thread::Builder::new()
@@ -510,6 +561,10 @@ impl Streaming {
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::Free(meshes) => {
drop(meshes);
continue;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), &light, sky), seq)
}
@@ -541,9 +596,7 @@ impl Streaming {
Done::Meshed(x, sy, z, mesh, seq)
}
};
if send.send(done).is_err() {
return;
}
shared.done.send(done);
}
})
.expect("spawn worker")
@@ -556,7 +609,6 @@ impl Streaming {
Self {
shared,
light_view,
results,
threads,
order: 0,
scan_age: 0,
@@ -660,6 +712,17 @@ impl Streaming {
}
}
}
/// Give back meshes the renderer has copied: a worker frees them, ahead of
/// other jobs, as vanilla's compile task closes its mesh data on its own
/// thread once copied (`SectionRenderDispatcher.RenderSection.CompileTask.doTask`).
/// Freed in the frame, the hundreds of meshes a chunk border brings waited
/// on the allocator behind every busy worker: up to 20 ms of a frame each
/// time while flying at render distance 32.
pub fn free(&mut self, meshes: Vec<((i32, i32, i32), SectionMesh)>) {
if !meshes.is_empty() {
self.submit(None, 0, Job::Free(meshes));
}
}
/// A column the server unloaded (ClientboundForgetLevelChunkPacket).
pub fn forget(&mut self, world: &mut World, x: i32, z: i32) -> Option<Arc<Chunk>> {
self.lit.remove(&(x, z));
@@ -676,7 +739,7 @@ impl Streaming {
}
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
/// times four plus `bias`, which orders a column's jobs. A job without a
/// column (a save) goes ahead of every other.
/// column (a save, meshes to free) goes ahead of every other.
fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) {
self.order += 1;
let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias);
@@ -909,8 +972,10 @@ impl Streaming {
self.requeue();
self.relight_edits(world);
let deferred = std::mem::take(&mut self.deferred);
let received = std::iter::from_fn(|| self.results.try_recv().ok());
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
let received = self.shared.done.take();
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
let mut stale = Vec::new();
for done in deferred.into_iter().chain(received) {
match done {
Done::Proto(x, z, proto, decorated) => {
self.generation_scan.exhausted = false;
@@ -966,12 +1031,14 @@ impl Streaming {
}
Done::Meshed(x, sy, z, mesh, seq) => {
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
stale.push(((x, sy, z), mesh));
continue;
}
// Its column unloaded while it was meshed: nothing would
// ever remove the uploaded section.
if !world.chunks.contains_key(&(x, z)) {
self.meshing.remove(&(x, sy, z));
stale.push(((x, sy, z), mesh));
continue;
}
self.meshing.remove(&(x, sy, z));
@@ -980,6 +1047,7 @@ impl Streaming {
}
}
}
self.free(stale);
let mut removed = Vec::new();
if sweep {
// Unload far columns.
@@ -1021,10 +1089,10 @@ impl Streaming {
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges
.forced
.iter()
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
&& !ranges.forced.iter().any(|f| {
(x - f.0).abs() <= FORCED_TERRAIN_RING
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
})
})
.map(|(k, _)| *k)
.collect();
@@ -1418,12 +1486,12 @@ impl Streaming {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Ok(Done::Saved(protos)) => self.receive_saved(protos),
Ok(other) => self.deferred.push(other),
Err(_) => {
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(other) => self.deferred.push(other),
None => {
log::warn!("Horizon: chunk generation did not finish before saving");
break;
}
@@ -1445,7 +1513,7 @@ impl Streaming {
/// in `save(true)`, so older generation cannot overwrite the final chunks.
pub fn poll_save(&mut self, world: &mut World) -> bool {
let started = std::time::Instant::now();
while let Ok(done) = self.results.try_recv() {
while let Some(done) = self.shared.done.try_recv() {
match done {
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
@@ -1530,7 +1598,7 @@ impl Streaming {
/// Wait up to `timeout` for a worker to finish a job; the next `update`
/// takes it. A server pumps generation with this between its ticks.
pub fn wait(&mut self, timeout: std::time::Duration) {
if let Ok(done) = self.results.recv_timeout(timeout) {
if let Some(done) = self.shared.done.recv_timeout(timeout) {
self.deferred.push(done);
}
}
@@ -1544,6 +1612,14 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
}
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
/// out, features (34) at three, terrain (35) at four.
const FORCED_FULL_RING: i32 = 2;
const FORCED_FEATURES_RING: i32 = 3;
const FORCED_TERRAIN_RING: i32 = 4;
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
struct Ranges<'a> {
center: (i32, i32),
@@ -1799,7 +1875,16 @@ impl Ranges<'_> {
if dx * dx + dz * dz <= r * r {
return true;
}
let reach = (margin - 1).max(0);
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced
.iter()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
@@ -1811,10 +1896,10 @@ impl Drop for Streaming {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Saved(_)) => self.saving -= 1,
Ok(_) => {}
Err(_) => break,
match self.shared.done.recv_timeout(left) {
Some(Done::Saved(_)) => self.saving -= 1,
Some(_) => {}
None => break,
}
}
self.shared.stop.store(true, Ordering::Relaxed);
@@ -1852,6 +1937,37 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
mod tests {
use super::*;
/// The frame never waits for a worker: while one holds the mailbox, the
/// frame takes nothing and finds the results, in order, the next time.
#[test]
fn the_frame_takes_finished_jobs_without_waiting() {
let mailbox = Mailbox::default();
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
mailbox.send(lit(1));
mailbox.send(lit(2));
let sending = mailbox.done.lock().unwrap();
assert!(mailbox.take().is_empty());
assert!(mailbox.try_recv().is_none());
drop(sending);
let seqs: Vec<u64> = mailbox
.take()
.into_iter()
.map(|done| match done {
Done::Lit(_, _, _, seq) => seq,
_ => unreachable!(),
})
.collect();
assert_eq!(seqs, [1, 2]);
assert!(mailbox.take().is_empty());
let waiting = std::time::Instant::now();
assert!(
mailbox
.recv_timeout(std::time::Duration::from_millis(10))
.is_none()
);
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
}
#[test]
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
let forced = HashSet::new();
@@ -2037,9 +2153,14 @@ mod tests {
}
}
}
let forced_reach = if input.decorates {
FORCED_TERRAIN_RING
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for dz in -(margin - 1)..=(margin - 1) {
for dx in -(margin - 1)..=(margin - 1) {
for dz in -forced_reach..=forced_reach {
for dx in -forced_reach..=forced_reach {
let (x, z) = (fx + dx, fz + dz);
if missing(x, z) {
wanted.push((input.ranges.distance(x, z), x, z));
@@ -2441,6 +2562,57 @@ mod tests {
}
}
}
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
/// rings around it (33), gives the third its features (34) and the
/// fourth its terrain (35), and the rings past that are left alone.
#[test]
fn forced_chunks_reach_vanillas_rings() {
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
let r = Ranges {
// The player's own area, far away.
center: (-400, 300),
radius: 2,
extra: &[],
forced: &forced,
cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
for x in 40 - 6..=40 + 6 {
for z in -40 - 6..=-40 + 6 {
// The full ring: the column becomes a level chunk.
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
// The features ring, and the terrain ring.
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
}
}
// Generation reaches the terrain ring: the features ring is
// decorated from it.
let mut fixture = GenerationFixture::new(2, true);
fixture.center = (-400, 300);
fixture.forced = forced;
let candidates = GenerationScan::default()
.candidates(fixture.input(), usize::MAX)
.expect("a scan");
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
assert!(
candidates
.iter()
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
"the terrain ring at ({}, {}) is not generated",
40 + dx,
-40 + dz
);
}
}
}
#[cfg(test)]
+91 -25
View File
@@ -1,12 +1,10 @@
//! Resident client world: streamed chunk columns plus the bookkeeping the game
//! loop needs to relight and remesh after edits.
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::BlockStateId;
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use std::sync::Arc;
/// Sections (x, section index, z) waiting to be remeshed, kept by column
/// (a bit per section index), so the mesh scheduler looks at each column once
@@ -65,14 +63,14 @@ pub struct World {
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
/// Chunks changed since the last save.
pub modified: HashSet<(i32, i32)>,
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
pub block_entities: crate::block_entities::BlockEntities,
/// Each chest's `ContainerOpenersCounter`: opener count and largest
/// interaction range.
pub chest_openers: HashMap<(i32, i32, i32), (i32, f64)>,
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
pub block_names: HashMap<(i32, i32, i32), String>,
pub block_names: FxHashMap<(i32, i32, i32), String>,
/// States (by id) whose writes are recorded in `watched_writes` (sculk
/// blocks, which need a block entity).
pub watched: Vec<bool>,
@@ -80,7 +78,7 @@ pub struct World {
pub watched_writes: Vec<((i32, i32, i32), u32, u32)>,
/// `ChunkAccess.inhabitedTime` of resident chunks: ticks a player spent
/// within spawning range (local difficulty).
pub inhabited: HashMap<(i32, i32), i64>,
pub inhabited: FxHashMap<(i32, i32), i64>,
pub game_time: i64,
pub day_time: i64,
pub weather: Weather,
@@ -177,13 +175,13 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
modified: HashSet::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: HashMap::new(),
block_names: HashMap::new(),
chest_openers: FxHashMap::default(),
block_names: FxHashMap::default(),
watched: Vec::new(),
watched_writes: Vec::new(),
inhabited: HashMap::new(),
inhabited: FxHashMap::default(),
game_time: 0,
day_time: 1000,
weather: Weather::default(),
@@ -274,20 +272,40 @@ impl World {
log.push((x, y, z));
}
self.modified.insert((x >> 4, z >> 4));
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
// the sections it can reach: this section, the one above/below
// only on a section face, and a neighbour column only on a chunk
// border (the diagonal only where both borders meet).
let sy = (y - MIN_Y) >> 4;
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
continue;
}
self.dirty_sections.insert((bx >> 4, s, bz >> 4));
}
let local_y = y & 15;
let mark = |dirty: &mut DirtySections, bx: i32, bz: i32| {
dirty.insert((bx >> 4, sy, bz >> 4));
if local_y == 0 && sy > 0 {
dirty.insert((bx >> 4, sy - 1, bz >> 4));
}
if local_y == 15 && sy + 1 < SECTIONS as i32 {
dirty.insert((bx >> 4, sy + 1, bz >> 4));
}
};
let (bx, bz) = (x & 15, z & 15);
mark(&mut self.dirty_sections, x, z);
if bx == 0 {
mark(&mut self.dirty_sections, x - 1, z);
} else if bx == 15 {
mark(&mut self.dirty_sections, x + 1, z);
}
if bz == 0 {
mark(&mut self.dirty_sections, x, z - 1);
} else if bz == 15 {
mark(&mut self.dirty_sections, x, z + 1);
}
if (bx == 0 || bx == 15) && (bz == 0 || bz == 15) {
mark(
&mut self.dirty_sections,
if bx == 0 { x - 1 } else { x + 1 },
if bz == 0 { z - 1 } else { z + 1 },
);
}
let _ = sy;
if self.chunks.get(&(x >> 4, z >> 4)).is_some_and(|c| c.lit) {
self.light_edits.push(((x, y, z), previous, state.0));
} else {
@@ -407,11 +425,59 @@ impl Default for HealthRules {
#[cfg(test)]
mod tests {
use super::World;
use crate::chunk::Chunk;
use super::{DirtySections, World};
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// Every block write marks the same section set the 27-cell loop did, but
/// only the distinct keys: one in the interior, a neighbour section on a
/// section face, a neighbour column on a chunk border and the diagonal at
/// a corner (eight keys at a corner on a section face).
#[test]
fn writes_mark_exactly_the_reachable_sections() {
let mut world = World::new(BlockStateId(0), 0);
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
for (x, y, z) in [
(3, 70, 3),
(0, 70, 3),
(15, 70, 3),
(3, 70, 0),
(0, 70, 0),
(15, 70, 15),
(0, MIN_Y, 0),
(0, 80, 0),
(15, MIN_Y + SECTIONS as i32 * 16 - 1, 15),
] {
world.dirty_sections.clear();
world.set(x, y, z, BlockStateId(1)).unwrap();
let mut expected = DirtySections::default();
for dz in -1..=1 {
for dy in -1..=1 {
for dx in -1..=1 {
let (bx, by, bz) = (x + dx, y + dy, z + dz);
let s = (by - MIN_Y) >> 4;
if (0..SECTIONS as i32).contains(&s) {
expected.insert((bx >> 4, s, bz >> 4));
}
}
}
}
let mut got: Vec<_> = world.dirty_sections.columns().collect();
let mut want: Vec<_> = expected.columns().collect();
got.sort_unstable();
want.sort_unstable();
assert_eq!(got, want, "write at {x} {y} {z}");
if (x & 15, z & 15) == (3, 3) {
assert_eq!(world.dirty_sections.len(), 1);
}
if (x & 15, z & 15, y & 15) == (0, 0, 0) && y > MIN_Y {
// Four columns, each with the section below and this one.
assert_eq!(world.dirty_sections.len(), 8);
}
}
}
/// A box misses a state only where no section it reaches lists it, and
/// unloaded columns or heights past the world count as their air.
#[test]
+61 -15
View File
@@ -14,11 +14,13 @@ use zip::{CompressionMethod, ZipArchive};
/// bomb stops here.
const MAX_ENTRY: usize = 1 << 30;
/// One archive with its entries indexed by name (`\` read as `/`, folders
/// left out).
/// One archive with its entries indexed by name (`\` read as `/`), files
/// and folders apart.
pub struct Zip {
archive: ZipArchive<Cursor<Arc<[u8]>>>,
entries: BTreeMap<String, usize>,
/// Folder entries (`name/`) by their name without the slash.
folders: BTreeMap<String, usize>,
/// The archive's length: no size an entry claims can be bigger.
len: usize,
}
@@ -33,26 +35,44 @@ impl Zip {
normalize_end(&mut data);
let len = data.len();
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
let entries = (0..archive.len())
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
.filter(|(name, _)| !name.ends_with('/'))
.collect();
let (mut entries, mut folders) = (BTreeMap::new(), BTreeMap::new());
for i in 0..archive.len() {
let Some(name) = archive.name_for_index(i) else {
continue;
};
let name = name.replace('\\', "/");
match name.strip_suffix('/') {
Some(folder) => folders.insert(folder.to_owned(), i),
None => entries.insert(name, i),
};
}
Ok(Self {
archive,
entries,
folders,
len,
})
}
/// The files, as `FilePackResources.listResources` lists them (folder
/// entries skipped).
pub fn names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
/// `FilePackResources.getResource` through `ZipFile.getEntry`: the file
/// `name`, else the folder entry `name/`, whose bytes Java reads like a
/// file's (Loka's pack stores its `pack.mcmeta` and `pack.png` that way).
fn index(&self, name: &str) -> Option<usize> {
self.entries
.get(name)
.or_else(|| self.folders.get(name))
.copied()
}
pub fn contains(&self, name: &str) -> bool {
self.entries.contains_key(name)
self.index(name).is_some()
}
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
let &index = self
.entries
.get(name)
let index = self
.index(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
// A clone shares the bytes and the central directory: reading needs `&mut`.
let mut archive = self.archive.clone();
@@ -133,13 +153,16 @@ mod tests {
use super::*;
/// A hand-built archive with one stored and one deflated entry.
fn archive() -> Vec<u8> {
let files: [(&str, &[u8], bool); 2] = [
archive_of(&[
("pack.mcmeta", b"{\"pack\":{}}", false),
("a/b.txt", b"hello hello hello hello", true),
];
])
}
/// A hand-built archive of `(name, body, deflated)` entries.
fn archive_of(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, body, deflate) in files {
for &(name, body, deflate) in files {
let data = if deflate {
miniz_oxide::deflate::compress_to_vec(body, 6)
} else {
@@ -174,8 +197,8 @@ mod tests {
out.extend(&central);
out.extend(0x0605_4b50u32.to_le_bytes());
out.extend([0; 4]);
out.extend(2u16.to_le_bytes());
out.extend(2u16.to_le_bytes());
out.extend((files.len() as u16).to_le_bytes());
out.extend((files.len() as u16).to_le_bytes());
out.extend((central.len() as u32).to_le_bytes());
out.extend(start.to_le_bytes());
out.extend([0; 2]);
@@ -191,6 +214,29 @@ mod tests {
assert!(zip.read("missing").is_err());
}
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
/// vanilla reads its bytes; a file of the exact name still comes first.
#[test]
fn a_name_falls_back_to_its_folder_entry() {
let zip = Zip::from_bytes(archive_of(&[
("pack.mcmeta/", b"{\"pack\":{\"pack_format\":75}}", true),
("pack.png/", b"png", false),
("a/", b"folder", false),
("a", b"file", false),
]))
.unwrap();
assert_eq!(zip.names().collect::<Vec<_>>(), ["a"]);
assert!(zip.contains("pack.mcmeta"));
assert_eq!(
zip.read("pack.mcmeta").unwrap(),
b"{\"pack\":{\"pack_format\":75}}"
);
assert_eq!(zip.read("pack.png").unwrap(), b"png");
assert_eq!(zip.read("a").unwrap(), b"file");
assert!(!zip.contains("pack"));
}
/// A pack as zip tools write it: folder entries, a nested pack root,
/// stored and deflated files, and a Windows-style name.
fn written() -> Vec<u8> {
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
}
@@ -11,5 +11,57 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
}
@@ -0,0 +1,15 @@
{
"horizon.options.controller": "Геймпад",
"horizon.options.vibration": "Вібрація",
"horizon.options.adaptive_triggers": "Адаптивні тригери",
"horizon.options.lightbar": "Світлова панель",
"horizon.options.look_sensitivity": "Чутливість огляду",
"horizon.options.invert_x": "Інвертувати огляд по X",
"horizon.options.invert_y": "Інвертувати огляд по Y",
"horizon.options.sneak_button": "Кнопка присідання",
"horizon.options.move_deadzone": "Мертва зона руху",
"horizon.options.look_deadzone": "Мертва зона огляду",
"horizon.options.button_glyphs": "Значки кнопок",
"horizon.options.menu_navigation": "Навігація в меню",
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
}
@@ -43,6 +43,7 @@ pub fn registries(r: &Value, permission: u8) -> Registries {
reg.timelines = strings(&r["timelines"]);
reg.dimensions = strings(&r["dimensions"]);
reg.players = strings(&r["players"]);
reg.selected_entities = strings(&r["selected_entities"]);
reg.teams = strings(&r["teams"]);
reg.objectives = strings(&r["objectives"]);
reg.tracked = strings(&r["tracked"]);
@@ -178,6 +178,7 @@ pub fn main() -> horizon_client::Result<()> {
reg.timelines = strings(&r["timelines"]);
reg.dimensions = strings(&r["dimensions"]);
reg.players = strings(&r["players"]);
reg.selected_entities = strings(&r["selected_entities"]);
reg.teams = strings(&r["teams"]);
reg.objectives = strings(&r["objectives"]);
reg.tracked = strings(&r["tracked"]);
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
mob.extra.crouching = flag("isCrouching");
mob.extra.passenger = flag("isPassenger");
if flag("isHoldingBow") {
mob.extra.main_hand = Some("minecraft:bow".into());
mob.extra.set_main_hand(Some("minecraft:bow".into()));
}
}
Species::IronGolem => {
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
}
}
Species::Witch if flag("isHoldingItem") => {
mob.extra.main_hand = Some("minecraft:potion".into());
mob.extra.set_main_hand(Some("minecraft:potion".into()));
}
_ => {}
}
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
.cloned()
.unwrap_or_else(|| kind.model.clone());
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
mob.extra.main_hand = None;
mob.extra.set_main_hand(None);
let case = &v["case"];
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
if baby {
@@ -134,7 +134,7 @@ pub fn main() -> horizon_client::Result<()> {
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
let index = match *slot {
"mainhand" => {
m.extra.main_hand = item;
m.extra.set_main_hand(item);
Some(gear::MAIN)
}
"offhand" => {
@@ -88,6 +88,7 @@ mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod storage_menu_fixture;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -179,6 +180,7 @@ probes! {
sound_event_fixture,
spawn_rules_fixture,
sprint_probe,
storage_menu_fixture,
subtitle_fixture,
tab_list_fixture,
team_rules_fixture,
@@ -733,6 +733,15 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3);
}
if m.species() == Species::Wolf {
// NeutralMob anger left (the angry texture), -1 when calm.
let anger = if m.extra.anger > 0 {
i64::from(m.extra.anger)
} else {
-1
};
let _ = write!(out, "\twolf {anger}");
}
if let Some(b) = horizon_client::mobs::bee::get(m) {
let anger = if b.anger_end > 0 {
b.anger_end - now
@@ -126,6 +126,7 @@ pub fn main() -> horizon_client::Result<()> {
.collect();
let mut screen = PackScreen::new(
&m,
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
int(&c["width"]),
int(&c["height"]),
c["inWorld"] == true,
@@ -0,0 +1,37 @@
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
//! block id per line and prints the menu type the server opens it with
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
//! sends it) and whether that menu's slots take a shulker box: the server
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
//! `lan::containers`).
//!
//! usage: storage_menu_fixture INPUT OUTPUT
use horizon_client::containers::block_menu_type;
use std::fs;
fn case(block: &str) -> Option<String> {
let menu = block_menu_type(block.trim())?;
let accepts = if menu == "minecraft:shulker_box" {
"refuses"
} else {
"accepts"
};
Some(format!("{menu} {accepts} shulker_box"))
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
+16
View File
@@ -877,6 +877,22 @@ pub fn item_entity(
&& item(&v["item"], stack, w)
}
/// `RecipeCraftedTrigger.TriggerInstance.matches`: each ingredient predicate
/// takes a different one of the stacks used, in order.
pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
let Some(preds) = v.as_array() else {
return true;
};
let mut remaining: Vec<&ItemView> = used.iter().collect();
preds.iter().all(|p| {
let Some(i) = remaining.iter().position(|it| item(p, it, w)) else {
return false;
};
remaining.remove(i);
true
})
}
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
match v {
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
"shader-packs",
"graphics",
"graphics-advanced",
"graphics-hdr",
"extras",
"horizon",
"agent",
@@ -425,7 +426,7 @@ pub const COMMANDS: &[Cmd] = &[
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
+1 -9
View File
@@ -27,7 +27,7 @@ impl Ingredient {
pub fn of(items: Vec<String>) -> Self {
Self(items.iter().cloned().collect(), items)
}
fn parse(v: &Value, items: &Items) -> Option<Self> {
pub(crate) fn parse(v: &Value, items: &Items) -> Option<Self> {
let mut list: Vec<String> = Vec::new();
let mut add = |s: &str| {
let more: Vec<String> = match s.strip_prefix('#') {
@@ -43,14 +43,6 @@ impl Ingredient {
match v {
Value::String(s) => add(s),
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
Value::Object(o) => {
if let Some(s) = o.get("item").and_then(Value::as_str) {
add(s);
}
if let Some(t) = o.get("tag").and_then(Value::as_str) {
add(&format!("#{t}"));
}
}
_ => return None,
}
(!list.is_empty()).then(|| Self::of(list))
+42
View File
@@ -584,6 +584,14 @@ impl Creative {
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
(x, y)
}
/// `CreativeModeInventoryScreen.extractTabButton`'s cursor: an unselected
/// tab asks for the pointing hand when the whole-pixel mouse, relative to
/// the screen origin, is strictly inside its 26x32 button.
pub fn tab_wants_hand(tab: &Tab, selected: bool, mouse: (f32, f32)) -> bool {
let (x, y) = Self::tab_position(tab);
let (mx, my) = (mouse.0.floor(), mouse.1.floor());
!selected && mx > x && my > y && mx < x + 26.0 && my < y + 32.0
}
}
/// Whether a suffix array of `strings` has a suffix starting with `text`:
/// some string contains it (none for no strings, even when `text` is empty).
@@ -1171,4 +1179,38 @@ mod tests {
c.search.key(KeyCode::Backspace, false, false);
assert_eq!(c.search.value, "world");
}
#[test]
fn unselected_tabs_want_the_hand_strictly_inside() {
let tab = |top, column| Tab {
id: String::new(),
title: String::new(),
icon: String::new(),
items: Vec::new(),
top,
column,
search: false,
inventory: false,
};
// Top row, second column: the button spans x 27..53, y -28..4.
let t = tab(true, 1);
assert!(Creative::tab_wants_hand(&t, false, (40.0, -10.0)));
assert!(!Creative::tab_wants_hand(&t, true, (40.0, -10.0)));
// Its edges ask for nothing; the mouse counts in whole pixels.
assert!(!Creative::tab_wants_hand(&t, false, (27.0, -10.0)));
assert!(!Creative::tab_wants_hand(&t, false, (27.9, -10.0)));
assert!(Creative::tab_wants_hand(&t, false, (28.0, -10.0)));
assert!(Creative::tab_wants_hand(&t, false, (52.9, -10.0)));
assert!(!Creative::tab_wants_hand(&t, false, (53.0, -10.0)));
assert!(!Creative::tab_wants_hand(&t, false, (40.0, -28.0)));
assert!(Creative::tab_wants_hand(&t, false, (40.0, -27.0)));
assert!(Creative::tab_wants_hand(&t, false, (40.0, 3.9)));
assert!(!Creative::tab_wants_hand(&t, false, (40.0, 4.0)));
// Bottom row, right-aligned inventory column: x 169..195, y 132..164.
let b = tab(false, 6);
assert!(Creative::tab_wants_hand(&b, false, (180.0, 140.0)));
assert!(!Creative::tab_wants_hand(&b, false, (169.0, 140.0)));
assert!(Creative::tab_wants_hand(&b, false, (170.0, 140.0)));
assert!(!Creative::tab_wants_hand(&b, false, (180.0, 132.0)));
}
}
+126 -25
View File
@@ -37,9 +37,11 @@ pub enum Kind {
columns: i32,
button_width: i32,
},
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs).
/// `DialogListDialog`: its dialogs as sent (ids, a tag or inline dialogs),
/// and one button per dialog they resolve to (`set_list`).
DialogList {
dialogs: Tag,
entries: Vec<Button>,
exit: Option<Button>,
columns: i32,
button_width: i32,
@@ -325,29 +327,36 @@ impl Control {
fn parse(t: &Tag) -> Option<Self> {
let label = || t.get("label").map(text);
Some(match path(&string(t.get("type"))?) {
"text" => Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial: string(t.get("initial")).unwrap_or_default(),
max_length: int(t, "max_length", 32),
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
},
"text" => {
let initial = string(t.get("initial")).unwrap_or_default();
let max_length = int(t, "max_length", 32);
// TextInput: the default text may not be longer than the length.
if initial.encode_utf16().count() > max_length.max(0) as usize {
return None;
}
Self::Text {
width: int(t, "width", 200),
label: label()?,
label_visible: boolean(t, "label_visible", true),
initial,
max_length,
multiline: t.get("multiline").map(|m| {
let get = |key| number(m.get(key)).map(|v| v as i32);
(get("max_lines"), get("height"))
}),
}
}
"boolean" => Self::Boolean {
label: label()?,
initial: boolean(t, "initial", false),
on_true: string(t.get("on_true")).unwrap_or_else(|| "true".into()),
on_false: string(t.get("on_false")).unwrap_or_else(|| "false".into()),
},
"single_option" => Self::SingleOption {
width: int(t, "width", 200),
"single_option" => {
// SingleOptionInput.Entry.CODEC: a full entry or just its id.
options: list(t.get("options"))
let options: Vec<_> = list(t.get("options"))
.iter()
.filter_map(|o| match o {
.map(|o| match o {
Tag::String(id) => Some((id.to_string_lossy(), None, false)),
o => Some((
string(o.get("id"))?,
@@ -355,10 +364,18 @@ impl Control {
boolean(o, "initial", false),
)),
})
.collect(),
label: label()?,
label_visible: boolean(t, "label_visible", true),
},
.collect::<Option<_>>()?;
// A non-empty list with at most one initial entry.
if options.is_empty() || options.iter().filter(|o| o.2).count() > 1 {
return None;
}
Self::SingleOption {
width: int(t, "width", 200),
options,
label: label()?,
label_visible: boolean(t, "label_visible", true),
}
}
"number_range" => {
let float = |key| number(t.get(key)).map(|v| v as f32);
Self::NumberRange {
@@ -395,6 +412,44 @@ impl Control {
)),
}
}
/// The text on a cycle button or slider holding `value`
/// (`CycleButton.createFullName`, `NumberRangeInput.computeLabel`).
pub fn message(&self, value: &InputValue) -> Option<Text> {
match (self, value) {
(
Self::SingleOption {
options,
label,
label_visible,
..
},
InputValue::Option(i),
) => {
let (id, display, _) = options.get(*i)?;
let shown = display.clone().unwrap_or_else(|| Text::literal(id));
Some(if *label_visible {
Text::translate("options.generic_value", &[label.clone(), shown])
} else {
shown
})
}
(
Self::NumberRange {
label,
label_format,
..
},
InputValue::Slider(_),
) => {
let shown = number_string(self.scaled(value)?);
Some(Text::translate(
label_format,
&[label.clone(), Text::literal(shown)],
))
}
_ => None,
}
}
/// `ValueGetter.asTemplateSubstitution`.
pub fn substitution(&self, value: &InputValue) -> String {
match (self, value) {
@@ -536,6 +591,7 @@ impl Dialog {
},
"dialog_list" => Kind::DialogList {
dialogs: t.get("dialogs")?.clone(),
entries: Vec::new(),
exit: button("exit_action"),
columns: int(t, "columns", 2),
button_width: int(t, "button_width", 150),
@@ -543,20 +599,21 @@ impl Dialog {
_ => return None,
};
// DialogBody.COMPACT_LIST_CODEC: one body, or a list of them.
// A body or input it cannot read fails the whole dialog.
let body = match t.get("body") {
Some(Tag::List(items)) => items.iter().filter_map(Body::parse).collect(),
Some(one) => Body::parse(one).into_iter().collect(),
Some(Tag::List(items)) => items.iter().map(Body::parse).collect::<Option<_>>()?,
Some(one) => vec![Body::parse(one)?],
None => Vec::new(),
};
let inputs = list(t.get("inputs"))
.iter()
.filter_map(|i| {
.map(|i| {
Some(Input {
key: string(i.get("key"))?,
control: Control::parse(i)?,
})
})
.collect();
.collect::<Option<_>>()?;
Some(Self {
kind,
title: text(t.get("title")?),
@@ -588,7 +645,51 @@ impl Dialog {
Kind::Notice(b) => vec![b],
Kind::Confirmation { yes, no } => vec![yes, no],
Kind::MultiAction { actions, exit, .. } => actions.iter().chain(exit).collect(),
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => exit.iter().collect(),
Kind::ServerLinks { exit, .. } => exit.iter().collect(),
Kind::DialogList { entries, exit, .. } => entries.iter().chain(exit).collect(),
}
}
/// `DialogListDialogScreen.createListActions`: a button for each of `dialogs`
/// (what the list names), labelled by its external title, opening it.
pub fn set_list(&mut self, dialogs: &[Tag]) {
let Kind::DialogList {
entries,
button_width,
..
} = &mut self.kind
else {
return;
};
*entries = dialogs
.iter()
.filter_map(|tag| {
let sub = Dialog::parse(tag)?;
Some(Button {
label: sub.external_title.unwrap_or(sub.title),
tooltip: None,
width: *button_width,
action: Some(Action::Static(Click::ShowDialog(tag.clone()))),
})
})
.collect();
}
/// The dialogs a `dialog_list`'s `dialogs` names: the entries of a `#tag`
/// (by `tag`), registry ids (by `registry`) and dialogs written inline.
pub fn list_entries(
dialogs: &Tag,
tag: &dyn Fn(&str) -> Vec<Tag>,
registry: &dyn Fn(&str) -> Option<Tag>,
) -> Vec<Tag> {
let one = |t: &Tag| match t {
Tag::String(id) => registry(&id.to_string_lossy()),
inline => Some(inline.clone()),
};
match dialogs {
Tag::String(id) if id.to_string_lossy().starts_with('#') => {
tag(&id.to_string_lossy()[1..])
}
Tag::List(items) => items.iter().filter_map(one).collect(),
single => one(single).into_iter().collect(),
}
}
/// What `status` reports of it.
+125 -55
View File
@@ -31,6 +31,44 @@ pub enum Part {
Check { input: usize, rect: [f32; 4] },
/// A button of `Dialog::buttons`.
Button { index: usize, rect: [f32; 4] },
/// A single option input's cycle button.
Cycle { input: usize, rect: [f32; 4] },
/// A number range input's slider.
Slider { input: usize, rect: [f32; 4] },
/// An item body's widget; `body` indexes `Dialog::body`.
Item { body: usize, rect: [f32; 4] },
}
impl Part {
/// The part moved by `(dx, dy)`.
fn moved(self, dx: f32, dy: f32) -> Self {
match self {
Self::Line(t, x, y) => Self::Line(t, x + dx, y + dy),
Self::Field { input, rect } => Self::Field {
input,
rect: shift(rect, dx, dy),
},
Self::Check { input, rect } => Self::Check {
input,
rect: shift(rect, dx, dy),
},
Self::Button { index, rect } => Self::Button {
index,
rect: shift(rect, dx, dy),
},
Self::Cycle { input, rect } => Self::Cycle {
input,
rect: shift(rect, dx, dy),
},
Self::Slider { input, rect } => Self::Slider {
input,
rect: shift(rect, dx, dy),
},
Self::Item { body, rect } => Self::Item {
body,
rect: shift(rect, dx, dy),
},
}
}
}
#[derive(Clone, Debug)]
@@ -55,21 +93,7 @@ struct Element {
impl Element {
/// Its parts moved to `(x, y)`.
fn at(self, x: f32, y: f32) -> impl Iterator<Item = Part> {
self.parts.into_iter().map(move |p| match p {
Part::Line(t, px, py) => Part::Line(t, px + x, py + y),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, x, y),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, x, y),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, x, y),
},
})
self.parts.into_iter().map(move |p| p.moved(x, y))
}
}
fn shift([x, y, w, h]: [f32; 4], dx: f32, dy: f32) -> [f32; 4] {
@@ -100,9 +124,43 @@ fn message(font: &Font, contents: &Text, width: f32) -> Element {
}
}
/// `InputControlHandlers`: the control for input `i`, if Horizon has it.
fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
Some(match control {
/// `DialogBodyHandlers.ItemHandler`: the item widget, with its description 2
/// to the right of it, both centred on each other vertically.
fn item(
font: &Font,
body: usize,
description: Option<&(Text, i32)>,
(w, h): (f32, f32),
) -> Element {
let widget = |rect_h: f32, y: f32| Part::Item {
body,
rect: [0.0, y, w, rect_h],
};
let Some((contents, width)) = description else {
return Element {
w,
h,
parts: vec![widget(h, 0.0)],
};
};
let text = message(font, contents, *width as f32);
let height = h.max(text.h);
let mut parts = vec![widget(h, centre(height, h))];
parts.extend(
text.parts
.into_iter()
.map(|p| p.moved(w + 2.0, centre(height, text.h))),
);
Element {
w: w + 2.0 + text.w,
h: height,
parts,
}
}
/// `InputControlHandlers`: the control for input `i`.
fn input(font: &Font, i: usize, control: &Control) -> Element {
match control {
Control::Text {
width,
label,
@@ -111,14 +169,14 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
} => {
let w = *width as f32;
if !label_visible {
return Some(Element {
return Element {
w,
h: 20.0,
parts: vec![Part::Field {
input: i,
rect: [0.0, 0.0, w, 20.0],
}],
});
};
}
// CommonLayouts.labeledElement: the label, 4 below it the box,
// both on the left.
@@ -145,8 +203,25 @@ fn input(font: &Font, i: usize, control: &Control) -> Option<Element> {
}],
}
}
Control::SingleOption { .. } | Control::NumberRange { .. } => return None,
})
// CycleButton and AbstractSliderButton: one 20 high button whose
// message holds the label.
Control::SingleOption { width, .. } => Element {
w: *width as f32,
h: 20.0,
parts: vec![Part::Cycle {
input: i,
rect: [0.0, 0.0, *width as f32, 20.0],
}],
},
Control::NumberRange { width, .. } => Element {
w: *width as f32,
h: 20.0,
parts: vec![Part::Slider {
input: i,
rect: [0.0, 0.0, *width as f32, 20.0],
}],
},
}
}
/// `DialogScreen.packControlsIntoColumns`: full rows in a grid 2 apart, what
@@ -210,9 +285,20 @@ impl Layout {
let mut elements: Vec<Element> = dialog
.body
.iter()
.filter_map(|b| match b {
Body::Message { contents, width } => Some(message(font, contents, *width as f32)),
Body::Item { .. } => None,
.enumerate()
.map(|(i, b)| match b {
Body::Message { contents, width } => message(font, contents, *width as f32),
Body::Item {
description,
width,
height,
..
} => item(
font,
i,
description.as_ref(),
(*width as f32, *height as f32),
),
})
.collect();
elements.extend(
@@ -220,26 +306,24 @@ impl Layout {
.inputs
.iter()
.enumerate()
.filter_map(|(i, input_)| input(font, i, &input_.control)),
.map(|(i, input_)| input(font, i, &input_.control)),
);
let buttons = dialog.buttons();
let (listed, footer_buttons, footer_h) = match &dialog.kind {
Kind::MultiAction {
actions,
exit,
columns: n,
exit, columns: n, ..
}
| Kind::DialogList {
exit, columns: n, ..
} => {
let widths: Vec<f32> = actions.iter().map(|b| b.width as f32).collect();
let listed = buttons.len() - usize::from(exit.is_some());
let widths: Vec<f32> = buttons[..listed].iter().map(|b| b.width as f32).collect();
elements.push(columns(&widths, *n as usize, 0));
// ButtonListDialogScreen: the exit button in the footer, else a 5 high one.
let footer = exit.as_ref().map(|_| actions.len()).into_iter().collect();
(
actions.len(),
footer,
if exit.is_some() { BAR } else { 5.0 },
)
let footer = exit.as_ref().map(|_| listed).into_iter().collect();
(listed, footer, if exit.is_some() { BAR } else { 5.0 })
}
Kind::ServerLinks { exit, .. } | Kind::DialogList { exit, .. } => {
Kind::ServerLinks { exit, .. } => {
let footer = exit.as_ref().map(|_| 0).into_iter().collect();
(0, footer, if exit.is_some() { BAR } else { 5.0 })
}
@@ -303,24 +387,10 @@ impl Layout {
/// whether it is in the body (clipped to its area).
pub fn placed(&self, scroll: f32) -> impl Iterator<Item = (Part, bool)> + '_ {
let dy = self.area[1] - scroll;
let body = self.body.iter().map(move |p| {
let p = match p.clone() {
Part::Line(t, x, y) => Part::Line(t, x, y + dy),
Part::Field { input, rect } => Part::Field {
input,
rect: shift(rect, 0.0, dy),
},
Part::Check { input, rect } => Part::Check {
input,
rect: shift(rect, 0.0, dy),
},
Part::Button { index, rect } => Part::Button {
index,
rect: shift(rect, 0.0, dy),
},
};
(p, true)
});
let body = self
.body
.iter()
.map(move |p| (p.clone().moved(0.0, dy), true));
body.chain(self.footer.iter().map(|p| (p.clone(), false)))
}
}
+159
View File
@@ -0,0 +1,159 @@
//! End gateway travel for the entities that step into one:
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
#[derive(Clone, Copy, Debug, Default)]
pub struct Traveller {
/// The entity's portal process and cooldown (`Entity.portalProcess`).
state: PortalState,
}
impl Traveller {
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
/// through it on the next tick.
pub fn enter(&mut self, gateway: BlockPos) {
self.state.set_inside(gateway, ENTITY_DELAY);
}
/// `Entity.handlePortal`: the entity goes through the gateway it entered
/// (the End gateway has no transition time), or stays where it is.
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
self.state.handle(allowed, 0, ENTITY_DELAY)
}
/// Whether the entity's travel is over: it has no portal process left and
/// its portal cooldown has run out.
pub fn is_done(&self) -> bool {
self.state.cooldown == 0 && self.state.process.is_none()
}
/// The entity's portal cooldown, the ticks it stays out of a portal.
pub fn cooldown(&self) -> i32 {
self.state.cooldown
}
}
/// The End gateway block an entity's box swept through
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
pub fn swept_gateway(
from: Vec3,
to: Vec3,
width: f32,
height: f32,
mut is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
let half = f64::from(width) / 2.0;
let bounds = horizon_core::geometry::Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
)
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
let mut found = None;
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
if is_gateway(p) {
found = Some(p);
return false;
}
true
});
found
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The gateway of the issue: a block at [702, 100, 0] in the End.
const GATEWAY: [i32; 3] = [702, 100, 0];
fn at(x: f64, y: f64, z: f64) -> Vec3 {
Vec3::new(x, y, z)
}
fn is_gateway(p: BlockPos) -> bool {
[p.x, p.y, p.z] == GATEWAY
}
#[test]
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
// A dropped item, 0.25 wide, standing in the gateway block.
let item = at(702.5, 100.5, 0.5);
assert_eq!(
swept_gateway(item, item, 0.25, 0.25, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
// A cow, 0.9 wide and 1.4 tall, in the same block.
let cow = at(702.5, 100.0, 0.5);
assert_eq!(
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_out_of_the_gateway_is_not_in_it() {
// Two blocks above the gateway: nothing to travel through.
let above = at(702.5, 102.5, 0.5);
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
// Beside the gateway, outside its block.
let beside = at(704.5, 100.5, 0.5);
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
}
#[test]
fn an_entity_falling_through_the_gateway_goes_through_it() {
// A cow falling past the gateway block passes through it
// (Entity.checkInsideBlocks visits the blocks along the way).
let from = at(702.5, 103.0, 0.5);
let to = at(702.5, 99.0, 0.5);
assert_eq!(
swept_gateway(from, to, 0.9, 1.4, is_gateway),
Some(BlockPos::new(702, 100, 0))
);
}
#[test]
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
// Nothing happens while the entity is outside the gateway.
assert_eq!(traveller.travel(true), None);
// It enters the gateway, and goes through on the next tick.
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// Its portal cooldown keeps it from going back at once.
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
assert!(!traveller.is_done());
}
#[test]
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
let gateway = BlockPos::new(702, 100, 0);
let mut traveller = Traveller::default();
traveller.enter(gateway);
// A mob that cannot use a portal does not go through.
assert_eq!(traveller.travel(false), None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
// centre, the same spot the player arrives at.
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
}
}
+67 -9
View File
@@ -759,6 +759,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.into_iter()
.enumerate()
{
if slot == "mainhand"
&& let Some(stack) = m.extra.main_hand_stack()
{
let mut stack = stack.clone();
if let Some(g) = crate::mobs::gear::gear(m) {
stack.data_mut().enchantments = g.slots[index].clone();
stack.data_mut().trim = g.trims[index].clone();
}
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
@@ -782,10 +793,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
match item {
Some(id) => {
let keep = matches!(
equipment.get(&slot.into()).and_then(|e| e.get("id")),
Some(Tag::String(s)) if s.to_string_lossy() == **id
);
let keep = slot != "mainhand"
&& matches!(
equipment.get(&slot.into()).and_then(|e| e.get("id")),
Some(Tag::String(s)) if s.to_string_lossy() == **id
);
if slot == "head"
&& &**id == crate::mobs::raider::BANNER
&& crate::mobs::raider::is_raider(m.species())
@@ -989,10 +1001,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
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") {
m.extra.main_hand = eq
.get("mainhand")
.and_then(|i| text(i.get("id")))
.map(Into::into);
m.extra
.set_main_hand_stack(eq.get("mainhand").and_then(crate::save::item_from_nbt));
m.extra.off_hand = eq
.get("offhand")
.and_then(|i| text(i.get("id")))
@@ -1019,7 +1029,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
} else {
// Registration hands out default weapons (finalizeSpawn does in
// vanilla); a loaded mob holds only what its file lists.
m.extra.main_hand = None;
m.extra.set_main_hand(None);
}
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
// Mob.readAdditionalSaveData overrides what the constructor set (copper
@@ -3133,3 +3143,51 @@ mod neutral_anger_tests {
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
}
}
#[cfg(test)]
mod held_item_name_tests {
use super::*;
#[test]
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
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<_, _> = data
.types
.into_iter()
.map(|(id, kind)| (id, Arc::new(kind)))
.collect();
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
sword.data_mut().custom_name = Some("Old Sword".into());
mob.extra.set_main_hand_stack(Some(sword));
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
assert_eq!(
loaded.extra.main_hand_stack().unwrap().custom_name(),
Some("Old Sword")
);
loaded
.extra
.set_main_hand(Some("minecraft:diamond_sword".into()));
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
assert_eq!(
restored.extra.main_hand.as_deref(),
Some("minecraft:diamond_sword")
);
assert!(
restored
.extra
.main_hand_stack()
.unwrap()
.custom_name()
.is_none()
);
}
}
File diff suppressed because it is too large. Load diff
+50 -1
View File
@@ -1373,6 +1373,55 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w)
});
}
/// `RecipeCraftedTrigger`: the recipe and the stacks that went into it
/// (`ServerPlayer.triggerRecipeCrafted`); an empty slot is air.
pub(super) fn trigger_recipe_crafted(
&mut self,
recipe: &str,
ingredients: &[Option<ItemStack>],
) {
let me = self.player_entity();
let used: Vec<ItemView> = ingredients
.iter()
.map(|s| {
s.as_ref().map_or_else(
|| ItemView {
id: "minecraft:air".to_owned(),
..Default::default()
},
item_view,
)
})
.collect();
let recipe = recipe.to_owned();
self.trigger("minecraft:recipe_crafted", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::id_matches(&c["recipes"], "recipe", &recipe, w)
&& adv::recipe_ingredients(&c["ingredients"], &used, w)
});
}
/// `LootTableTrigger`: a container's loot table unpacked for this player
/// (`RandomizableContainer.unpackLootTable`).
pub(super) fn trigger_loot_table(&mut self, table: &str) {
let me = self.player_entity();
let table = table.to_owned();
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::id_matches(&c["loot_tables"], "loot_table", &table, w)
});
}
/// `BredAnimalsTrigger`.
pub(super) fn trigger_bred(
&mut self,
@@ -1500,7 +1549,7 @@ impl Game<'_> {
.iter()
.filter_map(|t| t.slot)
.chain(packs)
.fold(0, |used, slot| used | 1 << slot)
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
}
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
+14 -3
View File
@@ -566,7 +566,7 @@ impl Game<'_> {
"hardcore": options.hardcore,
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
"allow_commands": options.commands(*creative),
"superflat": options.flat,
"world_type": options.world_type.id(),
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
@@ -579,7 +579,7 @@ impl Game<'_> {
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
@@ -611,6 +611,9 @@ impl Game<'_> {
json!({ "mode": "multiplayer", "disconnected": reason })
}
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
Mode::MultiplayerWarning { check } => {
json!({ "mode": "multiplayer-warning", "check": check })
}
}
}
@@ -1454,7 +1457,15 @@ impl Game<'_> {
self.playing()?;
let all = a.pos.first() == Some(&"all");
let before = self.held().map(|s| self.stack_value(s));
self.drop_held(all);
if self.remote.is_some() {
// As Q does on a server (and on the single-player server
// thread): the server throws the stack, the client predicts it.
if !self.spectator() {
self.remote_drop(all);
}
} else {
self.drop_held(all);
}
ok(
json!({ "ok": true, "dropped_from": before, "held": self.held().map(|s| self.stack_value(s)) }),
)
+1 -1
View File
@@ -219,7 +219,7 @@ impl Game<'_> {
let holds = m.extra.main_hand.is_some();
if !holds && !item.is_empty() {
let m = &mut self.mobs[index];
m.extra.main_hand = Some(item.into());
m.extra.set_main_hand(Some(item.into()));
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
@@ -153,5 +153,6 @@ impl Game<'_> {
};
}
self.world.block_entities.insert(key, entity);
self.update_spawner_display(key);
}
}
+4 -2
View File
@@ -142,9 +142,11 @@ impl Game<'_> {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
return;
};
if tag.get("LootTable").is_none() {
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
return;
}
};
let table = table.to_string_lossy();
self.trigger_loot_table(&table);
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
+31 -1
View File
@@ -16,6 +16,15 @@ pub(super) struct BundleMouse {
hovered: Option<usize>,
}
/// Whether `screen` is an `AbstractContainerScreen`, whose `mouseScrolled` first offers
/// the wheel to the hovered slot's `ItemSlotMouseAction`s (the bundle's), and whose
/// `onStopHovering` clears the selection: the inventory and container menus, and
/// `CreativeModeInventoryScreen`, which scrolls its items only when no bundle took
/// the wheel.
pub(super) fn takes_bundle_wheel(screen: Screen) -> bool {
matches!(screen, Screen::Inventory | Screen::Creative)
}
impl Game<'_> {
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
/// container's own slots. A server only knows these in the inventory and chest-like menus.
@@ -89,7 +98,7 @@ impl Game<'_> {
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
/// screen, clears the bundle's selection.
pub(super) fn tick_bundle_hover(&mut self) {
let now = if self.screen == Screen::Inventory {
let now = if takes_bundle_wheel(self.screen) {
self.inventory_target().0
} else {
None
@@ -101,3 +110,24 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `CreativeModeInventoryScreen.mouseScrolled` calls `AbstractContainerScreen`'s
/// first: a bundle under the cursor takes the wheel there too (#3211).
#[test]
fn container_screens_offer_the_wheel_to_bundles() {
assert!(takes_bundle_wheel(Screen::Inventory));
assert!(takes_bundle_wheel(Screen::Creative));
for screen in [
Screen::Playing,
Screen::Chat,
Screen::Paused,
Screen::Options,
] {
assert!(!takes_bundle_wheel(screen));
}
}
}
+35 -23
View File
@@ -20,7 +20,7 @@ use std::{
/// The tree the chat parses and completes with: a server's, once it has
/// sent one (ClientPacketListener.commands), else the game's own.
fn chat_tree<'a>(
pub(super) fn chat_tree<'a>(
remote: &'a Option<super::remote::Remote>,
mods: &'a super::mods::ModsState,
) -> &'a horizon_client::command::Dispatcher {
@@ -148,7 +148,7 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
templates: ids_under(&root.join("worldgen/structure/minecraft"), "nbt"),
templates: templates_under(&root.join("worldgen/structure")),
});
cache.push((root.to_owned(), ids.clone()));
ids
@@ -156,6 +156,26 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
ids_in(root, extension, "minecraft")
}
/// The templates of every namespace folder under `root` (vanilla's and the
/// content mods'), as `namespace:path`, sorted.
fn templates_under(root: &Path) -> Vec<String> {
let Ok(namespaces) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut out: Vec<String> = namespaces
.flatten()
.filter(|e| e.path().is_dir())
.flat_map(|e| ids_in(&e.path(), "nbt", &e.file_name().to_string_lossy()))
.collect();
out.sort();
out
}
/// The `namespace:` ids of the `extension` files under `root`, sorted.
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![root.to_owned()];
while let Some(dir) = stack.pop() {
@@ -175,7 +195,7 @@ fn ids_under(root: &Path, extension: &str) -> Vec<String> {
&& let Ok(rel) = path.strip_prefix(root)
{
out.push(format!(
"minecraft:{}",
"{namespace}:{}",
rel.with_extension("").to_string_lossy().replace('\\', "/")
));
}
@@ -427,7 +447,8 @@ impl Game<'_> {
.map(|(k, _)| k.clone())
.collect();
}
/// The block the player is looking at, for coordinate suggestions.
/// The block the player is looking at, for coordinate suggestions, and
/// the entity, for entity arguments (ClientSuggestionProvider).
/// `ClientSuggestionProvider.getRelevantCoordinates` and
/// `getAbsoluteCoordinates` read `Minecraft.hitResult` only when it is a
/// block: an entity in front of the block leaves the `~` defaults.
@@ -440,6 +461,7 @@ impl Game<'_> {
self.ui.registries.target_block = hit.as_ref().map(|h| h.pos);
self.ui.registries.target_point =
hit.map(|h| h.point.map(horizon_client::java_text::format_fixed2));
self.ui.registries.selected_entities = self.crosshair_entity_uuid().into_iter().collect();
// ClientPacketListener.getOnlinePlayerNames on a server.
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
@@ -649,7 +671,7 @@ impl Game<'_> {
ChatAction::None => {
// charTyped: the text the key typed.
if let Some(text) = typed_text
&& !ctrl
&& self.types_text()
{
screen.type_text(&m, &c, text);
}
@@ -1181,7 +1203,10 @@ impl Game<'_> {
return;
};
let path = dir.join(name);
renderer.screenshot = Some(path.clone());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
});
self.ui.screenshot_message = Some(path);
}
/// After a frame: report a finished F2 screenshot in chat.
@@ -1191,6 +1216,7 @@ impl Game<'_> {
};
let message = match result {
Ok(_) => {
log::info!("Saved screenshot to {}", path.display());
let name = path.file_name().unwrap_or_default().to_string_lossy();
let link = Text::styled(
name,
@@ -1215,24 +1241,10 @@ impl Game<'_> {
);
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
/// approximated as UTC).
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, in local time).
pub fn screenshot_name(dir: &std::path::Path) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let days = (secs / 86400) as i64;
let (h, m, s) = ((secs % 86400) / 3600, (secs % 3600) / 60, secs % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(mo <= 2);
let base = format!("{y}-{mo:02}-{d:02}_{h:02}.{m:02}.{s:02}");
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
let mut n = 1;
loop {
let name = if n == 1 {
@@ -27,6 +27,22 @@ impl Game<'_> {
m.alt_key() || held([KeyCode::AltLeft, KeyCode::AltRight]),
)
}
/// Whether the held keys let a key's text through to the focused field
/// (`clipboard::types_text`): not with the edit modifier, unless it is
/// part of AltGr.
pub(super) fn types_text(&self) -> bool {
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.physical_keys.contains(k));
let edit = if cfg!(target_os = "macos") {
held([KeyCode::SuperLeft, KeyCode::SuperRight])
} else {
held([KeyCode::ControlLeft, KeyCode::ControlRight])
};
clipboard::types_text(
edit,
held([KeyCode::AltLeft, KeyCode::AltRight]),
clipboard::ALTGR_IS_CTRL_ALT,
)
}
/// A shortcut in an append-only field limited to `max_chars` characters
/// (an `EditBox` with `setMaxLength`). True when the value changed.
pub(super) fn append_shortcut(
+20 -2
View File
@@ -304,6 +304,16 @@ impl Game<'_> {
None
}
}
/// The display component of a custom-named held stack.
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
let mut stack = m.extra.main_hand_stack()?.clone();
stack.custom_name()?;
if let Some(g) = horizon_client::mobs::gear::gear(m) {
stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone();
}
Some(self.stack_display_name(&stack))
}
/// `CombatTracker.getDeathMessage`.
pub(super) fn combat_death_message(&mut self) -> Text {
self.combat_death_message_for(Text::literal(PLAYER_NAME))
@@ -357,8 +367,16 @@ impl Game<'_> {
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
if let Some((id, name)) = killer {
// DamageSource.getLocalizedDeathMessage: the cause's item with a
// custom name gets the `<id>.item` message.
let (key, args) = horizon_client::scoreboard::death_message_args(
&key,
&me,
&name,
self.mob_main_hand_name(id).as_ref(),
);
return Text::translate(&key, &args);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
+180 -29
View File
@@ -4,18 +4,21 @@
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use horizon_client::{
chat::{ChatScreen, Commands},
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
hud::{HudBatch, rgba},
screen_draw::Measure,
text::{self, Text},
ui::{self, EditBox},
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
use winit::{
@@ -28,7 +31,10 @@ type Pos = (i32, i32, i32);
/// The open `CommandBlockEditScreen`.
pub(super) struct CommandBlockScreen {
pos: Pos,
command: EditBox,
/// The command box with its `CommandSuggestions`.
command: ChatScreen,
/// When the command box was focused, for its cursor's blink.
opened: std::time::Instant,
previous: EditBox,
track_output: bool,
mode: Mode,
@@ -93,6 +99,9 @@ impl CommandBlockScreen {
Button::Cancel => tr("gui.cancel"),
}
}
pub(super) fn pos(&self) -> Pos {
self.pos
}
/// `updatePreviousOutput`.
fn show_previous(&mut self, block: &CommandBlock) {
let shown = if self.track_output {
@@ -446,10 +455,14 @@ impl Game<'_> {
let Some(state) = self.block_state_at(pos) else {
return;
};
let mut command = EditBox::new(0.0, 50.0, 300.0, 20.0);
command.max_length = 32500;
command.set_value(&block.command);
command.focused = true;
let (w, h) = self.gui_size();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
command.auto_suggestions = self.settings.auto_suggestions;
let mut previous = EditBox::new(0.0, 135.0, 276.0, 20.0);
previous.max_length = 32500;
previous.text_color = UNEDITABLE;
@@ -457,6 +470,7 @@ impl Game<'_> {
let mut screen = CommandBlockScreen {
pos,
command,
opened: std::time::Instant::now(),
previous,
track_output: block.track_output,
mode: Mode::of_block(&state.name).unwrap_or(Mode::Redstone),
@@ -485,7 +499,14 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
let mid = (w / 2.0).floor();
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
// Screen.extractBackground of an isInGameUi screen: the dimmed world,
// with no blur and no menu background.
batch.fill_gradient(
atlas,
(0.0, 0.0, w, h),
rgba(0x10, 0x10, 0x10, 0xC0),
rgba(0x10, 0x10, 0x10, 0xD0),
);
let title = Text::translate("advMode.setCommand", &[]);
let tw = atlas.font.width_text(&title);
batch.text_styled(
@@ -499,8 +520,6 @@ impl Game<'_> {
let grey = rgba(160, 160, 160, 255);
let label = Text::translate("advMode.command", &[]);
batch.text_styled(atlas, &label, mid - 149.0, 40.0, grey, true);
s.command.x = mid - 150.0;
s.command.draw(batch, atlas, None);
if !s.previous.value.is_empty() {
let label = Text::translate("advMode.previousOutput", &[]);
batch.text_styled(atlas, &label, mid - 149.0, 125.0, grey, true);
@@ -511,12 +530,65 @@ impl Game<'_> {
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
}
// The command box, then its suggestions over the widgets.
let m = Measure(&atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
s.command.resize(&m, &c, w as i32, h as i32);
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
let blink = s.opened.elapsed().as_millis() as u64;
let draws = s.command.draw(&m, mouse, blink, 0);
horizon_client::screen_draw::paint(batch, atlas, &draws);
self.command_block_screen = Some(s);
}
/// The wheel over the suggestion list (`CommandSuggestions.mouseScrolled`).
pub(super) fn command_block_scroll(&mut self, amount: f32) {
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
if let Some(s) = self.command_block_screen.as_mut() {
s.command.scroll(mouse, f64::from(amount));
}
}
/// The command box's completion request for an `ask_server` node.
pub(super) fn command_block_suggestion_request(&mut self) -> Option<(i32, String)> {
self.command_block_screen.as_mut()?.command.take_request()
}
/// The server's completions for the command box's `ask_server` request.
pub(super) fn command_block_server_suggestions(
&mut self,
id: i32,
range: (usize, usize),
list: Vec<(String, Option<String>)>,
) {
let Some(mut s) = self.command_block_screen.take() else {
return;
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
s.command.server_suggestions(&m, &c, id, range, list);
self.command_block_screen = Some(s);
}
pub(super) fn command_block_click(&mut self) {
let Some(mut s) = self.command_block_screen.take() else {
return;
};
// CommandSuggestions.mouseClicked first, then the command box.
{
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
let (x, y) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
if s.command.click(&m, &c, x, y) {
self.command_block_screen = Some(s);
return;
}
}
let size = self
.renderer
.as_ref()
@@ -552,8 +624,8 @@ impl Game<'_> {
}
self.command_block_screen = Some(s);
}
/// `AbstractCommandBlockEditScreen.keyPressed`: Enter is Done, Escape
/// cancels, the rest edits the command.
/// `AbstractCommandBlockEditScreen.keyPressed`: the suggestions first,
/// then Enter is Done, Escape cancels, the rest edits the command.
pub(super) fn command_block_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
@@ -561,27 +633,58 @@ impl Game<'_> {
let PhysicalKey::Code(key) = event.physical_key else {
return;
};
let shortcut = self.edit_shortcut(event);
self.command_block_key_code(key, shortcut, event.text.as_deref());
}
/// A key pressed on the command block screen, with the edit shortcut it
/// makes and the text it types.
pub(super) fn command_block_key_code(
&mut self,
key: KeyCode,
shortcut: Option<horizon_client::clipboard::EditShortcut>,
text: Option<&str>,
) {
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
// Windows' AltGr is Ctrl+Alt, and its characters still type.
let types_text = self.types_text();
let Some(mut s) = self.command_block_screen.take() else {
return;
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if s.command.suggestions_key(&m, &c, key, shift) {
self.command_block_screen = Some(s);
return;
}
let typed = match key {
KeyCode::Escape | KeyCode::Enter | KeyCode::NumpadEnter => false,
_ => {
if let Some(shortcut) = shortcut {
s.command.shortcut(&m, &c, shortcut, &mut self.clipboard);
false
} else {
!s.command.field_key(&m, &c, key, shift, ctrl) && types_text
}
}
};
self.command_block_screen = Some(s);
if typed {
self.command_block_type(text.unwrap_or(""));
}
match key {
KeyCode::Escape => return self.close_command_block(),
KeyCode::Escape => self.close_command_block(),
KeyCode::Enter | KeyCode::NumpadEnter => {
if let Some(s) = self.command_block_screen.take() {
self.set_command_block(&s);
}
return self.close_command_block();
self.close_command_block();
}
_ => {}
}
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let shortcut = self.edit_shortcut(event);
let Some(s) = self.command_block_screen.as_mut() else {
return;
};
if let Some(shortcut) = shortcut {
s.command.shortcut(shortcut, &mut self.clipboard);
} else if !s.command.key(key, ctrl, shift) && !ctrl {
self.command_block_type(event.text.as_deref().unwrap_or(""));
}
}
/// Characters typed into the command box (`StringUtil.isAllowedChatCharacter`).
pub(super) fn command_block_type(&mut self, typed: &str) {
@@ -589,11 +692,19 @@ impl Game<'_> {
.chars()
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
.collect();
if let Some(s) = self.command_block_screen.as_mut()
&& !allowed.is_empty()
{
s.command.insert_text(&allowed);
if allowed.is_empty() {
return;
}
let Some(mut s) = self.command_block_screen.take() else {
return;
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
s.command.type_text(&m, &c, &allowed);
self.command_block_screen = Some(s);
}
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
@@ -604,7 +715,7 @@ impl Game<'_> {
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
command: s.command.value().to_owned(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
@@ -653,6 +764,46 @@ impl Game<'_> {
}
});
}
/// `ServerPlayer.openCommandBlock`: the command block's data
/// (`saveCustomOnly`) goes to the guest whose use opened it, as the
/// chunk's update tag leaves it out and the edit screen shows it.
pub(super) fn lan_open_command_block(&mut self, host: &mut LanHost, conn: u32, pos: Pos) {
if !host
.hub
.guest(conn)
.is_some_and(|g| g.creative() && g.permission >= 2)
{
return;
}
let Some(block) = self.command_block(pos) else {
return;
};
let tag = Tag::compound(block.save());
let state = self.world.block(pos.0, pos.1, pos.2).0;
let Some(kind) = host.hub.encoder.entity_type(state) else {
return;
};
host.hub.send_to(
conn,
&C::BlockEntityData(play::BlockEntityData {
pos: [pos.0, pos.1, pos.2],
kind,
tag,
}),
);
}
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
let open = self.screen == Screen::CommandBlock
&& self
.command_block_screen
.as_ref()
.is_some_and(|s| s.pos == pos);
if open {
self.open_command_block(pos);
}
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
@@ -2069,9 +2069,53 @@ impl Game<'_> {
return false;
}
self.world.block_entities.insert(key, new);
self.update_spawner_display(key);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
true
}
/// `RandomizableContainer.unpackLootTable` for a player who opens a
/// container: a roll the container still holds fires
/// `player_generates_container_loot` for its table, and from then on the
/// container saves its items, not the table.
pub(super) fn unpack_opened_loot(&mut self, key: (i32, i32, i32), name: &str) {
let Some(rolled) = self
.world
.chunks
.get(&(key.0 >> 4, key.2 >> 4))
.and_then(|c| c.saved.as_ref())
.and_then(|s| s.rolled_loot.iter().find(|r| r.pos == key))
.cloned()
else {
return;
};
// The roll is still there only while the container holds exactly its items.
let still_rolled = self
.world
.block_entities
.get_mut(&key)
.is_some_and(|entity| {
let mut tag = horizon_client::save::encode_block_entity(key, entity, name);
match &mut tag {
horizon_world::nbt::Tag::Compound(map) => rolled.restore(map),
_ => false,
}
});
if still_rolled {
self.trigger_loot_table(&rolled.table);
}
let Some(chunk) = self.world.chunks.get_mut(&(key.0 >> 4, key.2 >> 4)) else {
return;
};
let chunk = std::sync::Arc::make_mut(chunk);
let mut extras = chunk
.saved
.take()
.map(std::sync::Arc::unwrap_or_clone)
.unwrap_or_default();
extras.rolled_loot.retain(|r| r.pos != key);
chunk.saved = Some(std::sync::Arc::new(extras));
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
/// container (`setblock`, `data`): an empty container with a `LootTable`
/// is rolled now, as a loaded chunk's are (`save::roll_loaded_loot`), and
@@ -317,6 +317,7 @@ impl Game<'_> {
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
self.update_spawner_display(key);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
let _ = self.world.set(p[0], p[1], p[2], state);
@@ -235,9 +235,9 @@ impl Game<'_> {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) else {
return false;
};
let item = stack.map(|s| s.item);
let item = stack.as_ref().map(|s| s.item.clone());
match *key {
"mainhand" => m.extra.main_hand = item,
"mainhand" => m.extra.set_main_hand_stack(stack),
"offhand" => m.extra.off_hand = item,
"head" | "body" => m.extra.head = item,
"chest" => m.extra.chest = item,
@@ -246,9 +246,9 @@ impl Game<'_> {
c.insert("z".into(), Tag::Int(at[2]));
}
if let Some((_, entity)) = save::decode_block_entity(&tag) {
self.world
.block_entities
.insert((at[0], at[1], at[2]), entity);
let key = (at[0], at[1], at[2]);
self.world.block_entities.insert(key, entity);
self.update_spawner_display(key);
}
}
}
@@ -73,7 +73,7 @@ impl Game<'_> {
/// `EntitySelector.findEntities` with a UUID: the player with it, else
/// the entity.
pub(super) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
pub(in crate::game) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
let own = self.lan_acting_uuid.or_else(|| {
self.level.player_uuid.map(|[a, b, c, d]| {
(u128::from(a as u32) << 96)
+20 -7
View File
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
"tp X Y Z teleport the player",
"hotbar 1-9 select a hotbar slot",
"give ITEM [COUNT] add items to the inventory",
"render-distance N chunks, 2..64",
"render-distance N chunks, 2..128",
"lod-distance N far surface chunks, 0 disables, up to1024",
"time TICKS set the time of day",
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
@@ -34,7 +34,7 @@ const HELP: &[&str] = &[
"chat TEXT send a chat line or /command as if typed",
"type TEXT type into the open chat box (key enter tap sends)",
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
"stats [reset] frame-time summary since the last reset",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
@@ -349,7 +349,10 @@ impl Game<'_> {
self.native_key_event(physical, false, false);
}
// Text screens consume raw presses; releases follow key binds.
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
let typing = matches!(
self.screen,
Screen::Chat | Screen::Options | Screen::CommandBlock
);
let key = if typing && arg(2)? != "up" {
key
} else {
@@ -361,7 +364,8 @@ impl Game<'_> {
if action != "up" {
self.play_mouse(button, true)?;
}
if action != "down" {
// A repeat keeps the key held, as the window's.
if action == "up" || action == "tap" {
self.play_mouse(button, false)?;
}
}
@@ -516,8 +520,9 @@ impl Game<'_> {
}
"render-distance" => {
let n: i32 = arg(1)?.parse()?;
if !(2..=64).contains(&n) {
return Err("render distance must be 2..64".into());
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&n) {
return Err(format!("render distance must be 2..{max}").into());
}
self.settings.render_distance = n;
self.apply_settings();
@@ -587,7 +592,10 @@ impl Game<'_> {
arg(1)?;
// The rest of the line: a path may hold spaces.
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
renderer.screenshot = Some(path.trim().into());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
}
@@ -677,6 +685,7 @@ impl Game<'_> {
"shader-packs" => Page::ShaderPacks,
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"graphics-hdr" => Page::GraphicsHdr,
"extras" => Page::Extras,
"credits" => Page::Credits,
"horizon" => Page::Horizon,
@@ -854,6 +863,10 @@ impl Game<'_> {
if self.screen == Screen::Options {
return self.options_key(key, None);
}
if self.screen == Screen::CommandBlock {
self.command_block_key_code(key, None, None);
return Ok(());
}
if self.keys.insert(key) {
self.key_pressed(key)?;
}
+21 -2
View File
@@ -18,7 +18,10 @@ const FIREWORK_ROCKET: &str = "minecraft:firework_rocket";
/// `ProjectileWeaponItem.ARROW_ONLY`, for the arrows Horizon shoots.
fn is_arrow(stack: &ItemStack) -> bool {
&*stack.item == "minecraft:arrow"
matches!(
&*stack.item,
"minecraft:arrow" | "minecraft:spectral_arrow" | "minecraft:tipped_arrow"
)
}
/// `Player.getProjectile`: what `weapon` supports in the off hand, then in
@@ -206,10 +209,14 @@ impl Game<'_> {
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
}
if !rocket {
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
(&*ammo.item == "minecraft:spectral_arrow")
.then_some(("minecraft:glowing", 200))
});
let kind = ProjectileKind::Arrow {
base_damage: 2.0,
critical: true,
effect: None,
effect,
};
let mut arrow = Projectile::new(
kind,
@@ -277,5 +284,17 @@ mod tests {
// The main hand's rocket, with the crossbow in the off hand.
let hands = [(OFFHAND_SLOT, crossbow.as_ref()), (3, rocket.as_ref())];
assert_eq!(projectile_slot(CROSSBOW, hands, &inventory), Some(3));
// Spectral and tipped arrows in off hand or inventory are recognized.
let spectral = stack("minecraft:spectral_arrow");
let tipped = stack("minecraft:tipped_arrow");
inventory[7] = spectral.clone();
assert_eq!(slot("minecraft:bow", &None, &inventory), Some(7));
assert_eq!(
slot("minecraft:bow", &tipped, &inventory),
Some(OFFHAND_SLOT)
);
inventory[7] = None;
assert_eq!(slot("minecraft:bow", &None, &inventory), None);
}
}
+129 -27
View File
@@ -26,12 +26,105 @@ use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
sync::{
Arc, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
mpsc,
},
time::{Duration, Instant},
};
mod status;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
running: AtomicBool,
}
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
running: AtomicBool::new(true),
});
let shared = watchdog.clone();
std::thread::Builder::new()
.name("Server Watchdog".into())
.spawn(move || shared.run(max))
.ok()?;
Some(watchdog)
}
/// The tick loop is about to run the tick due at `next`.
fn tick_due(&self, next: Instant, tick: Duration) {
let nanos = next.saturating_duration_since(self.base).as_nanos();
self.next_nanos
.store(u64::try_from(nanos).unwrap_or(u64::MAX), Ordering::Relaxed);
let tick = u64::try_from(tick.as_nanos()).unwrap_or(u64::MAX);
self.tick_nanos.store(tick, Ordering::Relaxed);
}
/// `ServerWatchdog.run`.
fn run(&self, max: Duration) {
let max = u64::try_from(max.as_nanos()).unwrap_or(u64::MAX);
while self.running.load(Ordering::Relaxed) {
let next = self.next_nanos.load(Ordering::Relaxed);
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
self.tick_nanos.load(Ordering::Relaxed) as f32 / 1e9
);
log_from(
"Server Watchdog",
"ERROR",
&format!("A single server tick took {took} seconds (should be max {should})"),
);
log_from(
"Server Watchdog",
"ERROR",
"Considering it to be crashed, server will forcibly shutdown.",
);
let message = format!(
"ServerWatchdog detected that a single server tick took {took} seconds (should be max {should})"
);
match horizon_client::crash::report_watchdog(
std::path::Path::new("."),
"server",
message,
) {
Some(path) => log_from(
"Server Watchdog",
"ERROR",
&format!(
"This crash report has been saved to: {}",
path.canonicalize().unwrap_or(path).display()
),
),
None => log_from(
"Server Watchdog",
"ERROR",
"We were unable to save this crash report to disk.",
),
}
std::process::exit(1);
}
// Thread.sleep(nextTickTime + maxTickTime - now).
let wait = (next + max).saturating_sub(now).max(1_000_000);
std::thread::sleep(Duration::from_nanos(wait));
}
}
}
/// Double.toString for the magnitudes positions have (no exponent form
/// between 1e-3 and 1e7).
pub(super) fn java_double(v: f64) -> String {
@@ -298,15 +391,14 @@ impl Lists {
}
}
/// The world presets of 26.3's `worldgen/world_preset`.
const WORLD_PRESETS: [&str; 7] = [
"minecraft:normal",
"minecraft:flat",
"minecraft:large_biomes",
"minecraft:amplified",
"minecraft:single_biome_surface",
"minecraft:debug_all_block_states",
"minecraft:flat_all_dimensions",
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
/// names, before the presets had their own ids.
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
("default", horizon_client::save::WorldPreset::Normal),
(
"largebiomes",
horizon_client::save::WorldPreset::LargeBiomes,
),
];
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
@@ -323,11 +415,18 @@ impl Game<'_> {
/// DedicatedServerProperties.createDimensions for a world that doesn't
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
/// and `generate-structures`, written before the world opens so that
/// streaming reads them. Horizon generates the normal and flat presets.
/// streaming reads them.
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
let level_type = &props.level_type;
let preset = parse_identifier(level_type)
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
.as_deref()
.and_then(horizon_client::save::WorldPreset::of_id)
.or_else(|| {
LEGACY_PRESET_NAMES
.iter()
.find(|(name, _)| *name == level_type)
.map(|(_, preset)| *preset)
})
.unwrap_or_else(|| {
log_from(
"Worker-Main-1",
@@ -336,18 +435,9 @@ impl Game<'_> {
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
),
);
"minecraft:normal".to_owned()
horizon_client::save::WorldPreset::Normal
});
let flat = preset == "minecraft:flat";
if !flat && preset != "minecraft:normal" {
log_from(
"Worker-Main-1",
"WARN",
&format!(
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
),
);
}
let flat = preset == horizon_client::save::WorldPreset::Flat;
let settings = if flat {
let biomes = self.res.biomes();
let (settings, errors) = horizon_client::flatgen::settings_from_json(
@@ -363,7 +453,7 @@ impl Game<'_> {
None
};
let options = horizon_client::save::WorldGen {
flat,
preset,
structures: props.generate_structures,
bonus_chest: false,
};
@@ -608,6 +698,7 @@ impl Game<'_> {
motd: props.motd.clone(),
max_players: props.max_players.max(0) as usize,
compression_threshold: props.network_compression_threshold,
rate_limit: props.rate_limit,
enable_status: props.enable_status,
hide_online_players: props.hide_online_players,
favicon,
@@ -704,8 +795,13 @@ impl Game<'_> {
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
let mut next = Instant::now();
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
let watchdog = Watchdog::start(props.max_tick_time);
while !state.stop {
let tick_start = Instant::now();
if let Some(watchdog) = &watchdog {
watchdog.tick_due(next, self.clock.tick_duration());
}
while let Ok(line) = state.console.try_recv() {
self.console_line(&mut state, &line);
}
@@ -746,6 +842,9 @@ impl Game<'_> {
state.ticked(Instant::now());
self.wait_for_next_server_tick(&mut next, paused);
}
if let Some(watchdog) = &watchdog {
watchdog.running.store(false, Ordering::Relaxed);
}
self.stop_dedicated()
}
@@ -1355,10 +1454,13 @@ impl Game<'_> {
fn stop_dedicated(&mut self) -> Result<()> {
log("INFO", "Stopping server");
log("INFO", "Saving players");
if let Some(h) = self.lan.as_mut() {
for g in &h.hub.guests {
horizon_client::lan::playerdata::save(g, &self.world_root);
if let Some(host) = self.lan.take() {
for g in &host.hub.guests {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(g.vehicle);
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
}
self.lan = Some(host);
}
if let Some(h) = self.lan.as_ref() {
for g in &h.hub.guests {
+189 -5
View File
@@ -8,7 +8,7 @@ use horizon_client::{
Result,
clipboard::EditShortcut,
confirm_screen::Confirm,
dialog::{AfterAction, Click, Control, Dialog, InputValue},
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
hud::{HudBatch, rgba},
text::Text,
@@ -26,6 +26,8 @@ struct Open {
fields: Vec<Option<ui::EditBox>>,
focus: Option<usize>,
scroll: ui::Scroll,
/// The number range input whose slider is held.
dragging: Option<usize>,
}
/// The dialog screens a server can open.
@@ -38,6 +40,17 @@ pub(super) struct DialogState {
warning: bool,
}
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
/// it, wrapping.
fn cycle(open: &mut Open, input: usize, delta: i32) {
let Control::SingleOption { options, .. } = &open.dialog.inputs[input].control else {
return;
};
if let Some(InputValue::Option(i)) = open.values.get_mut(input) {
*i = (*i as i32 + delta).rem_euclid(options.len().max(1) as i32) as usize;
}
}
fn warning_texts() -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
(
@@ -57,10 +70,35 @@ impl Game<'_> {
/// `showDialog`: the dialog replaces an open one (and a waiting screen);
/// under the warning screen, it is what Back returns to.
pub(super) fn show_dialog(&mut self, tag: &Tag) {
let Some(dialog) = Dialog::parse(tag) else {
let Some(mut dialog) = Dialog::parse(tag) else {
log::warn!("Horizon: failed to show dialog for data {tag:?}");
return;
};
if let Kind::DialogList { dialogs, .. } = &dialog.kind {
let entries = self.remote.as_ref().map(|r| {
let list = r.registries.registries.get("minecraft:dialog");
let by_id = |id: &str| {
let name = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
list?.iter().find(|(n, _)| *n == name)?.1.clone()
};
let by_tag = |name: &str| {
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let ids = r.registries.dialog_tags.get(&name).into_iter().flatten();
ids.filter_map(|&i| list?.get(usize::try_from(i).ok()?)?.1.clone())
.collect()
};
Dialog::list_entries(dialogs, &by_tag, &by_id)
});
dialog.set_list(&entries.unwrap_or_default());
}
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
let fields = dialog
.inputs
@@ -87,6 +125,7 @@ impl Game<'_> {
fields,
focus: None,
scroll: ui::Scroll::default(),
dragging: None,
});
self.dialogs.waiting = None;
self.capture(false);
@@ -123,6 +162,17 @@ impl Game<'_> {
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
// defaultHandleClickEvent: an open_url never sets the screen itself, so
// the dialog stays unless the confirmation takes over (Web Links off,
// or Prompt on Links off, opens nothing or the browser behind it).
if let Some(Click::OpenUrl(url)) = &click
&& !(self.settings.chat_links && self.settings.file.bool("chatLinksPrompt"))
{
if self.settings.chat_links {
super::packs::open_uri(url);
}
return Ok(());
}
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
@@ -153,7 +203,11 @@ impl Game<'_> {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
}
Some(other) => log::info!("Horizon: dialog action {other:?} is not supported yet"),
// ConfirmLinkScreen over the screen the dialog leaves.
Some(Click::OpenUrl(url)) => self.click_url(url),
// Screen.insertText: the screen after the dialog takes no text.
Some(Click::SuggestCommand(_)) => {}
Some(other) => log::error!("Don't know how to handle {other:?}"),
None => {}
}
if !self.dialog_open() {
@@ -262,7 +316,8 @@ impl Game<'_> {
let Some(layout) = self.dialog_layout() else {
return;
};
let Some(open) = self.dialogs.open.as_mut() else {
// Out of `self` while it draws: an item body draws through `self`.
let Some(mut open) = self.dialogs.open.take() else {
return;
};
let atlas = &self.gui.atlas;
@@ -291,6 +346,7 @@ impl Game<'_> {
let buttons = open.dialog.buttons();
let in_area = |y: f32| y >= ay && y < ay + ah;
let mut tooltip = None;
let mut item_tip = None;
let mut start = None;
for (part, clipped) in layout.placed(open.scroll.amount) {
if clipped && start.is_none() {
@@ -333,6 +389,64 @@ impl Game<'_> {
true,
);
}
Part::Cycle { input, rect } => {
let [x, y, cw, ch] = rect;
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, cw, ch);
let Some(label) = open
.values
.get(input)
.and_then(|v| open.dialog.inputs[input].control.message(v))
else {
continue;
};
ui::button(batch, atlas, x, y, cw, ch, &label, true, hover);
}
Part::Slider { input, rect } => {
let [x, y, sw, sh] = rect;
let hover = in_area(cursor.1) && ui::hovered(cursor, x, y, sw, sh);
let Some((InputValue::Slider(position), Some(label))) = open
.values
.get(input)
.map(|v| (v.clone(), open.dialog.inputs[input].control.message(v)))
else {
continue;
};
let held = open.dragging == Some(input);
ui::slider(
batch,
atlas,
x,
y,
sw,
&label,
position,
hover || held,
true,
);
}
Part::Item { body, rect } => {
let [x, y, iw, ih] = rect;
let Some(Body::Item {
item,
show_decorations,
show_tooltip,
..
}) = open.dialog.body.get(body)
else {
continue;
};
let Some(stack) = horizon_items::item_nbt::item_from_nbt(item) else {
continue;
};
// ItemDisplayWidget: the item at its corner, then its decorations.
self.draw_item_model(batch, &stack, x, y);
if *show_decorations {
self.draw_item_decorations(batch, &stack, x, y, None);
}
if *show_tooltip && in_area(cursor.1) && ui::hovered(cursor, x, y, iw, ih) {
item_tip = Some(stack);
}
}
Part::Button { index, rect } => {
let [x, y, bw, bh] = rect;
let hover =
@@ -371,6 +485,10 @@ impl Game<'_> {
.collect();
ui::tooltip(batch, atlas, &lines, cursor);
}
self.dialogs.open = Some(open);
if let Some(stack) = item_tip {
self.draw_stack_tooltip(batch, &stack, cursor.0, cursor.1);
}
}
/// A left click on a dialog screen.
@@ -420,6 +538,7 @@ impl Game<'_> {
self.dialogs.warning = true;
return Ok(());
}
let shift = self.modifiers.shift_key();
let Some(open) = self.dialogs.open.as_mut() else {
return Ok(());
};
@@ -450,6 +569,23 @@ impl Game<'_> {
}
action = Some(None);
}
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
// CycleButton.onPress: Shift goes back.
cycle(open, input, if shift { -1 } else { 1 });
action = Some(None);
}
Part::Slider {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
open.dragging = Some(input);
if let Some(v) = open.values.get_mut(input) {
*v = InputValue::Slider(ui::slider_value(x, w, cursor.0));
}
}
Part::Button {
index,
rect: [x, y, w, h],
@@ -479,17 +615,65 @@ impl Game<'_> {
self.run_dialog_action(click, after)
}
/// The mouse wheel over a dialog: its body scrolls 10 a step.
/// The mouse wheel over a dialog: a cycle button under it takes the
/// step, else the body scrolls 10 a step.
pub(super) fn dialog_scroll(&mut self, delta: f32) {
let Some(layout) = self.dialog_layout() else {
return;
};
let cursor = self.cursor;
if let Some(open) = self.dialogs.open.as_mut() {
let [_, ay, _, ah] = layout.area;
if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah {
let hovered = layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Cycle {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => Some(input),
_ => None,
});
if let Some(input) = hovered {
// CycleButton.mouseScrolled: up goes back.
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
return;
}
}
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
}
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
pub(super) fn dialog_drag(&mut self) {
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
return;
};
let Some(layout) = self.dialog_layout() else {
return;
};
let x = self.cursor.0;
let Some(open) = self.dialogs.open.as_mut() else {
return;
};
let slider = layout.body.iter().find_map(|p| match p {
Part::Slider { input: i, rect } if *i == input => Some(*rect),
_ => None,
});
if let (Some([sx, _, sw, _]), Some(value)) = (slider, open.values.get_mut(input)) {
*value = InputValue::Slider(ui::slider_value(sx, sw, x));
}
}
/// The left button released: a held slider lets go and clicks.
pub(super) fn dialog_release(&mut self) {
if let Some(open) = self.dialogs.open.as_mut()
&& open.dragging.take().is_some()
{
self.audio.play_button_click();
}
}
/// A key pressed on a dialog screen: Escape closes what allows it, Tab
/// moves between the text inputs, the rest goes to the focused one
/// (`shortcut`: the press as an edit shortcut; `text`: what it types).
+7 -2
View File
@@ -296,7 +296,7 @@ pub(super) fn mob_set_slot(m: &mut Mob, slot: Slot, stack: ItemStack) {
let item: Arc<str> = stack.item.clone();
let x = &mut m.extra;
match slot {
Slot::MainHand => x.main_hand = Some(item),
Slot::MainHand => x.set_main_hand(Some(item)),
Slot::OffHand => x.off_hand = Some(item),
Slot::Feet => x.feet = Some(item),
Slot::Legs => x.legs = Some(item),
@@ -2140,6 +2140,11 @@ pub(super) fn tipped_effect(stack: &ItemStack) -> Option<(&'static str, i32)> {
def.effects
.iter()
.find(|(id, _, _)| !horizon_client::brewing::effect(id).is_some_and(|e| e.instant))
.map(|(id, duration, _)| (*id, (duration / 8).max(1)))
.map(|(id, duration, _)| {
(
horizon_client::brewing::namespaced(id),
(duration / 8).max(1),
)
})
})
}
+125 -52
View File
@@ -801,10 +801,7 @@ impl Game<'_> {
}
if !self.fight.killed {
if self.dragon.is_none() {
// createNewDragon: over the podium, holding pattern, a random yaw.
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
self.create_new_dragon();
}
self.fight.crystal_scan += 1;
if self.fight.crystal_scan >= 100 {
@@ -2593,54 +2590,9 @@ impl Game<'_> {
self.crystal_beam(pos, to - pos, self.ticks as f32 + a, camera, out);
}
}
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
/// `EnderDragonRenderer.submitRays`: untextured, additive death rays.
fn dragon_rays(&self, base: glam::Mat4, t: f32, out: &mut Vec<Vertex>) {
let white = self.gui.atlas.font.white();
let uv = [white.u0, white.v0];
let over = if t > 0.8 {
((t - 0.8) / 0.2).min(1.0)
} else {
0.0
};
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let outer = 0x00FF_00FF;
let mut random = LegacyRandom::new(432);
let mut m = base;
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
let half_sqrt3 = 3f32.sqrt() / 2.0;
let tau = std::f32::consts::TAU;
for _ in 0..count {
let mut f = || random.next_float() * tau;
let q1 = glam::Quat::from_rotation_x(f())
* glam::Quat::from_rotation_y(f())
* glam::Quat::from_rotation_z(f());
let (a, b, c) = (f(), f(), f());
let q2 = glam::Quat::from_rotation_x(a)
* glam::Quat::from_rotation_y(b)
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
m *= glam::Mat4::from_quat(q1 * q2);
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
let o = glam::Vec3::ZERO;
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
let bottom = glam::Vec3::new(0.0, length, width);
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
// A triangle as a quad with its last corner repeated.
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: 2,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
}
dragon_rays(base, t, self.gui.atlas.font.white(), out);
}
/// `submitCrystalBeams`: an eight-sided tube from `from` (+2 up) along
/// `delta`, the beam texture scrolling along it.
@@ -3022,10 +2974,17 @@ impl Game<'_> {
}
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
/// does not hurt it (the dragon flies its death flight when its health
/// runs out).
/// runs out). The fight's dragon ends the fight there and then
/// (`dragonFight.setDragonKilled`): the exit portal, a gateway and the
/// first dragon's egg, and no new dragon. No experience: `tickDeath`
/// drops it, and a killed dragon never gets there.
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
self.dragon = None;
// ServerLevel.getDragonFight: only the End has a fight.
if self.dim == Dim::End {
self.dragon_killed();
}
}
}
/// Any entity touching `room` (`Level.getEntities(null, box)`).
@@ -3317,6 +3276,12 @@ impl Game<'_> {
}
}
}
/// `EnderDragonFight.createNewDragon`: over the podium, holding pattern, a random yaw.
fn create_new_dragon(&mut self) {
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
}
/// The END stage: the portal crystals blow up (no block damage) and a
/// new dragon takes over.
fn finish_respawn(&mut self) {
@@ -3343,6 +3308,31 @@ impl Game<'_> {
}
}
self.fight.killed = false;
// setRespawnStage(END): the new dragon is summoned for every player
// of the fight (the boss bar's players: alive within 192 blocks).
self.create_new_dragon();
let view = self
.dragon
.as_ref()
.map(|d| horizon_client::advancements::EntityView {
kind: "minecraft:ender_dragon".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
..Default::default()
});
if let Some(view) = view {
self.for_each_player(|g| {
let near = g.player.as_ref().is_some_and(|p| {
!p.health.dead_or_dying()
&& p.position.x.powi(2)
+ (p.position.y - 128.0).powi(2)
+ p.position.z.powi(2)
<= 192.0 * 192.0
});
if near {
g.trigger_entity("minecraft:summoned_entity", "entity", &view);
}
});
}
self.fight.dirty = true;
self.count_crystals();
}
@@ -3562,10 +3552,93 @@ impl Game<'_> {
}
}
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out: &mut Vec<Vertex>) {
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
// its border can round into transparent neighbours during interpolation.
let uv = [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0];
let over = if t > 0.8 {
((t - 0.8) / 0.2).min(1.0)
} else {
0.0
};
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let outer = 0x00FF_00FF;
let mut random = LegacyRandom::new(432);
let mut m = base;
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
let half_sqrt3 = 3f32.sqrt() / 2.0;
let tau = std::f32::consts::TAU;
for _ in 0..count {
let mut f = || random.next_float() * tau;
let q1 = glam::Quat::from_rotation_x(f())
* glam::Quat::from_rotation_y(f())
* glam::Quat::from_rotation_z(f());
let (a, b, c) = (f(), f(), f());
let q2 = glam::Quat::from_rotation_x(a)
* glam::Quat::from_rotation_y(b)
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
m *= glam::Mat4::from_quat(q1 * q2);
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
let o = glam::Vec3::ZERO;
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
let bottom = glam::Vec3::new(0.0, length, width);
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
// A triangle as a quad with its last corner repeated.
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: 2,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn death_rays_sample_inside_the_white_sprite() {
// A four-texel white sprite surrounded by transparent atlas pixels.
// A border UV can round outside it during perspective interpolation.
let size = 1024.0;
let white = horizon_client::hud::Rect {
u0: 20.0 / size,
v0: 60.0 / size,
u1: 24.0 / size,
v1: 64.0 / size,
w: 4,
h: 4,
};
for t in [0.25, 0.8, 0.95, 1.0] {
let mut vertices = Vec::new();
dragon_rays(glam::Mat4::IDENTITY, t, white, &mut vertices);
assert!(!vertices.is_empty());
for v in &vertices {
assert!(
v.uv[0] > white.u0 + 0.5 / size && v.uv[0] < white.u1 - 0.5 / size,
"death-ray U touches the atlas border: {}",
v.uv[0]
);
assert!(
v.uv[1] > white.v0 + 0.5 / size && v.uv[1] < white.v1 - 0.5 / size,
"death-ray V touches the atlas border: {}",
v.uv[1]
);
}
}
}
fn dragon(phase: Phase) -> Dragon {
let mut d = Dragon::new(Vec3::new(0.0, 70.0, 0.0), 0.0, 1);
d.phase = phase;
+234 -7
View File
@@ -3,6 +3,7 @@
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
//! inactive until the dragon dies.
use super::{Dim, Game};
use horizon_client::nether_portal::ENTITY_DELAY;
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
@@ -16,10 +17,18 @@ use horizon_client::{
};
use horizon_core::{
geometry::Vec3,
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, sync::Arc};
/// A list of doubles, as an NBT list of them.
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -94,6 +115,11 @@ pub(super) struct EndState {
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
/// The entities that went through a gateway, waiting for its exit.
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -947,6 +973,17 @@ impl Game<'_> {
self.save_gateway_exits()
}
/// The exit a gateway already leads to: its block entity, or the side file
/// that kept it for a gateway whose chunk is not loaded.
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
let key = (gateway[0], gateway[1], gateway[2]);
let saved = match self.world.block_entities.get(&key) {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
@@ -1043,13 +1080,7 @@ impl Game<'_> {
/// The teleport position of a gateway job, creating the exit gateway (and
/// an island when the chunk has no landing spot) for new destinations.
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
let bottom_center = |p: [i32; 3]| {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
)
};
let bottom_center = horizon_client::end_gateway::bottom_center;
let find_exit = |game: &Self, exit: [i32; 3]| {
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
[p[0], p[1] + 1, p[2]]
@@ -1059,6 +1090,14 @@ impl Game<'_> {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
// The player and the entities may resolve the same gateway: the
// first one to arrive finds the exit, the others use it.
let known = self.known_gateway_exit(gateway);
if let Some((exit, exact)) = known {
return Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
})));
}
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -1420,6 +1459,194 @@ impl Game<'_> {
}
}
}
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
.map(|e| (e.id, e.position, 0.25, 0.25))
.collect();
let mobs: Vec<(u32, Vec3, f32, f32)> = self
.mobs
.iter()
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
continue;
}
// An entity that just came through any portal stays out of this one.
if self.restart_portal_cooldown(id) {
continue;
}
self.end.gateways.entry(id).or_default().enter(pos);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let ids: Vec<(u32, bool)> = self
.dropped
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
let Some(state) = self.end.gateways.get_mut(&id) else {
continue;
};
// The End gateway has no transition time (Portal.getPortalTransitionTime).
if let Some(entry) = state.travel(allowed) {
leaving.push((id, entry));
}
if state.is_done() {
self.end.gateways.remove(&id);
}
}
for (id, entry) in leaving {
self.send_through_gateway(id, entry);
}
}
/// `Entity.teleportToPortalDestination` for an entity that goes through an
/// End gateway: it is saved and waits for the exit's chunks.
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
&self.dropped[i],
uuid,
self.persist_raw(id),
);
self.dropped.remove(i);
self.forget_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let mut tag = self.mob_tree_nbt(i);
// Entity.removeAfterChangingDimensions drops the leash.
if let Tag::Compound(c) = &mut tag {
c.remove(&"leash".into());
}
// The mobs riding it travel with it (their tags are in its compound).
let mut gone = vec![id];
let mut k = 0;
while k < gone.len() {
let v = gone[k];
gone.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(v))
.map(|m| m.id),
);
k += 1;
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.forget_entity(g);
}
tag
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
}
/// The entities that reached a gateway's exit: they land where the
/// gateway leads, once its chunks have loaded.
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Ok(())
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
let areas = &self.end.areas;
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
}
}
self.end.areas = areas;
}
}
#[cfg(test)]
+7 -1
View File
@@ -132,6 +132,12 @@ impl Game<'_> {
"minecraft:glow_item_frame" => true,
_ => return false,
};
let invisible = stack.extra.as_ref().is_some_and(|extra| {
extra.other.iter().any(|(k, v)| {
(k == "minecraft:entity_data" || k == "entity_data")
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
})
});
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
let frame = ItemFrame {
id: self.next_entity + 1,
@@ -142,7 +148,7 @@ impl Game<'_> {
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
invisible,
check_interval: 0,
};
if !self.frame_survives(&frame) {
@@ -36,6 +36,8 @@ pub(super) struct FnState {
load_pending: bool,
pub schedule: Vec<Scheduled>,
next_seq: u64,
/// A dedicated server's `function-permission-level`.
pub permission: Option<u8>,
}
/// What a function reported through `return`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -45,6 +47,16 @@ pub(super) enum Outcome {
Fail,
}
impl FnState {
/// The level function files compile at (`ServerFunctionLibrary`'s
/// `functionCompilationPermissions`): `function-permission-level` on a
/// dedicated server, game master (2) otherwise. Whoever runs a function,
/// its commands need no more than this.
fn command_level(&self) -> u8 {
self.permission.unwrap_or(2)
}
}
impl Game<'_> {
// ---- loading -----------------------------------------------------------
@@ -278,6 +290,7 @@ impl Game<'_> {
}
self.exec.function_depth += 1;
let outer = self.exec.returned.take();
let level = std::mem::replace(&mut self.ui.registries.permission, self.fns.command_level());
let inner = Source {
silent: true,
store: None,
@@ -292,6 +305,7 @@ impl Game<'_> {
break;
}
}
self.ui.registries.permission = level;
let returned = std::mem::replace(&mut self.exec.returned, outer);
self.exec.function_depth -= 1;
Ok(match returned {
+29
View File
@@ -85,8 +85,36 @@ impl Game<'_> {
{
m.extra.player_created = true;
}
self.trigger_golem_summoned(id);
self.update_pattern_blocks(&blocks)
}
/// `CarvedPumpkinBlock.spawnGolemInWorld`: SUMMONED_ENTITY for each player
/// whose box is within 5 blocks of the golem's box (the local player here).
fn trigger_golem_summoned(&mut self, id: Option<u32>) {
let Some(golem) = id
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.cloned()
else {
return;
};
let me = self.player_entity();
let half = f64::from(golem.width()) / 2.0;
let height = f64::from(golem.height());
let reach = 5.0;
let [x, y, z] = me.position;
let p = golem.position;
// The player's box is 0.6 wide and 1.8 tall.
let near = x + 0.3 >= p.x - half - reach
&& x - 0.3 <= p.x + half + reach
&& y + 1.8 >= p.y - reach
&& y <= p.y + height + reach
&& z + 0.3 >= p.z - half - reach
&& z - 0.3 <= p.z + half + reach;
if near {
let view = self.mob_entity(&golem);
self.trigger_entity("minecraft:summoned_entity", "entity", &view);
}
}
/// Whether placing a golem head at `head` completes a golem.
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
self.find_golem(head).is_some() || self.copper_golem_base(head)
@@ -192,6 +220,7 @@ impl Game<'_> {
f64::from(head[2]) + 0.5,
];
let id = self.create_triggered("minecraft:copper_golem", at, 0.0);
self.trigger_golem_summoned(id);
self.update_pattern_blocks(&cells)?;
let direction = match facing.as_str() {
"south" => horizon_core::geometry::Direction::South,
+13
View File
@@ -61,6 +61,11 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
impl Game<'_> {
/// Opening a screen stops breaking before the next game tick (which may pause).
pub(super) fn stop_mining(&mut self) {
if self.clicks.destroying.active {
let pos = self.clicks.destroying.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
self.tutorial_destroy(state, -1.0);
}
let mut destroying = std::mem::take(&mut self.clicks.destroying);
destroying.stop(&mut GameHands {
game: self,
@@ -141,6 +146,12 @@ impl Game<'_> {
progress: d.progress,
ticks: d.ticks as u32,
});
if let Some(mining) = &self.mining {
let pos = mining.pos;
let progress = mining.progress;
let state = self.world.block(pos[0], pos[1], pos[2]);
self.tutorial_destroy(state, progress);
}
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
@@ -578,6 +589,8 @@ impl HandWorld for GameHands<'_, '_> {
{
return false;
}
let state = self.game.world.block(pos[0], pos[1], pos[2]);
self.game.tutorial_destroy(state, 1.0);
let r = self.game.break_block(pos);
self.keep(r);
true
+20 -5
View File
@@ -7,11 +7,11 @@
//! into its `IntegratedServer`.
//!
//! On by default. `--in-frame` (or `--no-server-thread`, or
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
//! escape hatch. LAN guests and dedicated servers use the same code either
//! way.
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. That path
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
//! LAN guests and dedicated servers use the same code either way.
use super::{
Game, Options, PackResources, Resources, UiResources,
Game, Options, PackResources, Resources, UiResources, WorldIcon,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
@@ -177,6 +177,8 @@ pub(super) struct Starting {
handle: Integrated,
ready: Receiver<Ready>,
player: String,
/// The save's folder in `saves/`, for the world icon once joined.
folder: String,
}
/// The world to open and the player who owns it.
@@ -287,6 +289,7 @@ impl Game<'_> {
handle,
ready,
player,
folder: folder.to_owned(),
});
// Harnesses, control scripts and runs without a window go on with the world open.
if self.window.is_none() || super::synchronous_startup(&self.options) {
@@ -318,13 +321,25 @@ impl Game<'_> {
}
};
let Some(Starting {
mut handle, player, ..
mut handle,
player,
folder,
..
}) = self.integrated_starting.take()
else {
return Ok(());
};
handle.world_name = world_name;
self.connect_local(pipe, &player)?;
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
// the Select World list shows, and needs the save folder for it.
self.world_icon = Some(WorldIcon {
path: std::env::current_dir()?
.join("saves")
.join(&folder)
.join("icon.png"),
last_attempt: None,
});
self.integrated = Some(handle);
Ok(())
}
+14 -1
View File
@@ -344,11 +344,24 @@ impl Game<'_> {
pose: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
) {
let lights = diffuse_lights(self.dim == Dim::Nether);
self.submit_item_lit(state, pose, light, lights, out);
}
/// [`Self::submit_item_world`] shaded by other diffuse lights (a
/// `Lighting.Entry` other than the level's), in the pose's output space.
pub(super) fn submit_item_lit(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
lights: (glam::Vec3, glam::Vec3),
out: &mut Vec<Vertex>,
) {
let mut sink = WorldSink {
out,
light,
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
lights: Some(lights),
glint: self.glint(),
};
state.submit(
+72 -3
View File
@@ -148,6 +148,20 @@ impl Game<'_> {
self.lan_songs.push((pos, Some(song.to_owned())));
}
}
/// `LevelEventHandler` 1010 on a client of a server: `playJukeboxSong`
/// for the song the event's data names in the server's `jukebox_song`
/// registry (`Registry.get(int).ifPresent`: an unknown id does nothing).
/// The server's jukebox plays it; this client only hears it.
pub(super) fn jukebox_song_event(&mut self, pos: [i32; 3], data: i32) {
if let Some(song) = jukebox_song_name(self.remote.as_ref().map(|r| &*r.registries), data) {
self.start_song(pos, &song);
}
}
/// `LevelEventHandler` 1011: `stopJukeboxSongAndNotifyNearby`.
pub(super) fn jukebox_stop_event(&mut self, pos: [i32; 3]) {
self.stop_sound(pos);
self.notify_record_playing(pos, false);
}
fn stop_sound(&mut self, pos: [i32; 3]) {
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
self.audio.stop_and_release(id);
@@ -256,15 +270,37 @@ impl Game<'_> {
.map(|(k, id)| (*k, *id))
.collect();
for (k, id) in sounds {
if !self.audio.engine.is_active(id)
|| !matches!(self.world.block_entities.get(&k), Some(BlockEntity::Jukebox(j)) if j.playing)
{
// A server's client has no playing jukebox of its own: its songs
// stop with level event 1011, or when they end.
let playing = matches!(self.world.block_entities.get(&k), Some(BlockEntity::Jukebox(j)) if j.playing);
if !keeps_song(
self.audio.engine.is_active(id),
self.remote.is_some(),
playing,
) {
self.audio.stop_and_release(id);
self.jukebox_sounds.remove(&k);
}
}
}
}
/// The song a level event 1010's data names: its id in the `jukebox_song`
/// registry the server sent (`Registry.get(int)`). Only a server's client
/// has one; a level that plays its own jukeboxes starts their songs itself.
fn jukebox_song_name(
registries: Option<&horizon_protocol::registry::Dynamic>,
id: i32,
) -> Option<String> {
registries?
.name("minecraft:jukebox_song", id)
.map(str::to_owned)
}
/// Whether a jukebox song keeps playing: until it ends, and, in a level that
/// plays its own jukeboxes, while that jukebox plays. A server's client
/// stops it at level event 1011 (`LevelRenderer.playingJukeboxSongs`).
fn keeps_song(active: bool, server_client: bool, jukebox_playing: bool) -> bool {
active && (server_client || jukebox_playing)
}
/// The jukebox's redstone signal (15 while a song plays) and comparator output.
pub(super) fn jukebox_signals(
world: &horizon_client::world::World,
@@ -284,3 +320,36 @@ pub(super) fn jukebox_signals(
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{jukebox_song_name, keeps_song};
use horizon_protocol::registry::Dynamic;
/// On a server's client the song comes from level event 1010, whose data
/// is the song's id in the server's `jukebox_song` registry (#3173), and
/// it plays on although this client's jukebox isn't playing.
#[test]
fn a_server_clients_jukebox_song_comes_from_level_event_1010() {
let mut registries = Dynamic::default();
registries.registries.insert(
"minecraft:jukebox_song".into(),
["minecraft:11", "minecraft:13", "minecraft:cat"]
.map(|n| (n.to_owned(), None))
.to_vec(),
);
assert_eq!(
jukebox_song_name(Some(&registries), 2).as_deref(),
Some("minecraft:cat")
);
assert_eq!(jukebox_song_name(Some(&registries), 3), None);
assert_eq!(jukebox_song_name(Some(&registries), -1), None);
assert_eq!(jukebox_song_name(None, 2), None);
// A server's client: on until the song ends (or level event 1011).
assert!(keeps_song(true, true, false));
assert!(!keeps_song(false, true, false));
// A level that plays its own jukebox: also until the jukebox stops.
assert!(keeps_song(true, false, true));
assert!(!keeps_song(true, false, false));
}
}
+108 -17
View File
@@ -69,7 +69,8 @@ pub(super) struct LanHost {
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
/// Blocks broken this tick (their sound is a level event for the guests).
pub broken: Vec<[f64; 3]>,
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
/// Hurt time, death and swing time (while swinging) of the mobs guests
/// see; the players' hurt time by entity id.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
@@ -177,7 +178,8 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
| "minecraft:axolotl"
| "minecraft:dolphin"
| "minecraft:squid"
| "minecraft:glow_squid" => Some(16),
| "minecraft:glow_squid"
| "minecraft:sulfur_cube" => Some(16),
_ => None,
}
}
@@ -325,10 +327,12 @@ fn mob_meta(
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, sprinting 0x08 (a dashing
// cat or ocelot), swimming 0x10 (a drowned under water,
// `Drowned.setSwimming`), invisible 0x20 (the effect's flag) and glowing
// 0x40 (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.is_on_fire())
| i8::from(m.extra.sprinting) << 3
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
| i8::from(m.is_currently_glowing()) << 6;
@@ -600,10 +604,11 @@ fn mob_meta(
}
}
// AbstractCubeMob.ID_SIZE.
"minecraft:slime" | "minecraft:magma_cube" => {
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
if e.size > 1 {
meta.push((18, serializer::INT, Value::Int(e.size)));
}
defaults.push((18, serializer::INT, Value::Int(1)));
}
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
// guest swells the creeper from them. The direction always goes in,
@@ -1242,15 +1247,53 @@ impl Game<'_> {
/// Save a guest when they leave or the world closes: an integrated
/// server's owner with the world (`save_game`), anyone else in their file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
// The vehicle the guest rides, for the file that names it.
let vehicle = self.root_vehicle_uuid(guest.vehicle);
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
if guest.placed {
playerdata::store(guest, &mut self.level);
playerdata::store(guest, &mut self.level, vehicle);
}
} else {
playerdata::save(guest, &self.world_root);
playerdata::save(guest, &self.world_root, vehicle);
}
self.save_guest_records(host, guest);
}
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
/// vehicle it rode rides it again, once the vehicle is in the world.
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
let waiting: Vec<(usize, [i32; 4])> = host
.hub
.guests
.iter()
.enumerate()
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
.filter(|(i, _)| {
let g = &host.hub.guests[*i];
let chunk = (
(g.position[0].floor() as i32).div_euclid(16),
(g.position[2].floor() as i32).div_euclid(16),
);
g.placed
&& g.vehicle.is_none()
&& g.dimension == self.dim.id()
&& g.sent_chunks.contains(&chunk)
})
.collect();
for (i, uuid) in waiting {
let raw = host.hub.guests[i].saved.clone();
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
continue;
};
let g = &mut host.hub.guests[i];
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
p.remove(&"RootVehicle".into());
}
g.vehicle = Some(id);
g.saved_vehicle = None;
// Pair the stationary restored entities before their passenger packet.
g.moved = true;
}
}
/// The file name of a player's advancements and statistics: an
/// integrated server's owner keeps single player's.
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
@@ -1321,14 +1364,28 @@ impl Game<'_> {
}
/// The integrated server's owner into the world's player, for `Level::write`.
pub(super) fn store_owner(&mut self) {
let Some(uuid) = self
.lan
.as_ref()
.and_then(|h| h.owner.as_ref())
.map(|o| o.uuid)
else {
return;
};
let riding = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.uuid == uuid && g.placed)
.and_then(|g| g.vehicle)
});
// The vehicle the owner rides, by the UUID it is saved with.
let vehicle = self.root_vehicle_uuid(riding);
let Some(host) = &self.lan else {
return;
};
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
return;
};
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
playerdata::store(g, &mut self.level);
playerdata::store(g, &mut self.level, vehicle);
self.save_guest_records(host, g);
}
}
@@ -1511,6 +1568,8 @@ impl Game<'_> {
m.rider = None;
}
}
// A guest whose file names the vehicle it rode rides it again.
self.restore_guest_vehicles(&mut host);
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
let has_guests = !host.hub.guests.is_empty();
@@ -1527,6 +1586,7 @@ impl Game<'_> {
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
} else {
states.clear();
}
@@ -1793,6 +1853,7 @@ impl Game<'_> {
// A dedicated server has no host player.
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
self.lan_entity_uuids(&mut theirs);
theirs.as_slice()
} else {
entities
@@ -2016,6 +2077,34 @@ impl Game<'_> {
}
seen.insert(m.id, (m.hurt_time, dead, swing));
}
// LivingEntity.hurtServer for a player: a hit that restarted the hurt
// time and was not blocked by a shield broadcasts the damage event,
// so the clients around (and the player's own) flash it red.
let host_player = self.player.as_ref().map(|p| {
(
host.host_entity,
[p.position.x, p.position.y, p.position.z],
p,
)
});
let guests = host.hub.guests.iter().filter(|g| host.hub.here(g));
let players: Vec<_> = host_player
.into_iter()
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
.map(|(id, at, p)| (id, at, p.health.hurt_animation_time, p.hurt_log.last()))
.collect();
for (id, at, hurt, record) in players {
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&(id as u32)));
if hurt > old.map_or(0, |o| o.0) {
// Starving keeps no record: any type flashes the same.
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
host.hub.damage_event(id, kind, None, None, at);
}
seen.insert(id as u32, (hurt, false, None));
}
host.mob_state.insert(self.dim, seen);
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
for attacker in kinetic_hits {
@@ -2124,11 +2213,13 @@ impl Game<'_> {
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
m.age = m.age.wrapping_add(1);
m.extra.main_hand = g
.inventory
.get(g.selected)
.and_then(|s| s.as_ref())
.map(|s| s.item.clone());
// The hurt red flash (LivingEntity.hurtTime).
m.hurt_time = g
.motion
.as_ref()
.map_or(0, |p| p.health.hurt_animation_time);
m.extra
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
.armor
+17 -5
View File
@@ -626,7 +626,9 @@ impl Game<'_> {
}) => Some(key),
_ => None,
};
self.command_block_screen = host_command_block;
// A command block the guest's use opened (Player.openCommandBlock).
let guest_command_block =
std::mem::replace(&mut self.command_block_screen, host_command_block).map(|s| s.pos());
// A mount inventory the guest's action opened (shift + use, or the
// inventory key while riding).
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
@@ -675,9 +677,7 @@ impl Game<'_> {
let new_mode = self.mode_id();
self.mode = mode;
self.creative = creative;
self.selected = std::mem::replace(&mut g.selected, selected);
// `g.selected` now holds the host's value: swap the inventories back.
std::mem::swap(&mut self.selected, &mut g.selected);
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
@@ -779,6 +779,9 @@ impl Game<'_> {
if let Some(key) = guest_lectern {
self.lan_open_lectern(host, conn, key);
}
if let Some(pos) = guest_command_block {
self.lan_open_command_block(host, conn, pos);
}
if let Some(pos) = opened_table {
self.lan_open_crafting(host, conn, pos);
}
@@ -892,6 +895,12 @@ impl Game<'_> {
else {
return false;
};
// The block entity's createMenu: a shulker box opens a ShulkerBoxMenu
// (not a 9x3 ChestMenu), whose slots refuse shulker boxes.
let menu = match slots {
Slots::Block(_) => horizon_client::containers::block_menu_type(name).unwrap_or(menu),
_ => menu,
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
@@ -2035,8 +2044,11 @@ impl Game<'_> {
let d = vanilla::game_dispatcher();
let level = std::mem::replace(&mut self.ui.registries.permission, permission);
let parse = d.parse(command, &self.ui.registries);
let ((start, end), suggestions) = d.suggestions(&parse, command.len(), &self.ui.registries);
let ((start, end), mut suggestions) =
d.suggestions(&parse, command.len(), &self.ui.registries);
self.ui.registries.permission = level;
// ServerCommandSuggestionsProvider.limitSuggestionCount.
suggestions.truncate(1000);
let utf16 = |byte: usize| command[..byte.min(command.len())].encode_utf16().count() as i32;
let mut w = horizon_protocol::buf::Writer::new();
w.varint(id);
@@ -162,6 +162,22 @@ impl Game<'_> {
},
);
}
// LivingEntity.setSprinting: SPEED_MODIFIER_SPRINTING (a dashing cat or ocelot).
if let (true, Some(a)) = (
m.extra.sprinting,
self.synced_attribute(kind, "minecraft:movement_speed"),
) {
add_modifier(
out,
a,
finalize::base_value(m, Attr::MovementSpeed),
AttributeModifier {
id: "minecraft:sprinting".into(),
amount: f64::from(0.3f32),
operation: 2,
},
);
}
let base = |name: &str| {
Attr::ALL
.into_iter()
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff. Show more