Compare commits

..
Author SHA1 Message Date
Ludovic Marie ba85348907 Merge pull request #1191 from ludovic111/perf/mod-content-build
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:08:48 +02:00
Ludovic Marie f73dc03fee Merge pull request #1275 from ludovic111/fix/perf-bench-readiness
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:08:23 +02:00
Ludovic Marie 820dfe1819 Merge pull request #1345 from ludovic111/feat/agent-connect-launcher
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:41:19 +02:00
Ludovic Marie 15b2007f54 Merge pull request #1344 from ludovic111/feat/agent-control-launcher
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:39:51 +02:00
Ludovic Marie d42c246122 Merge pull request #1343 from ludovic111/feat/agent-control-game
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:38:25 +02:00
Ludovic Marie 9e1401cc92 Merge pull request #1342 from ludovic111/feat/no-ai-switch
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 03:37:59 +02:00
Ludovic Marie f6d82abb66 Merge pull request #1358 from ludovic111/feat/site-desktop-hardware
🤖 Agent: merged after verify passed; the desktop benchmark and the breakdowns for the /benchmarks page (site only).
2026-10-23 03:36:33 +02:00
Ludovic Marie 850354ca28 Merge pull request #1337 from ludovic111/feat/telemetry-hardware
🤖 Agent: merged after verify passed. Ludovic asked on 2026-10-23 for every hardware fact players send (opt-in). The end-to-end run against a local server is in the description: breakdowns need ≥3 installs, with no install ID or exact value. The consent version goes up to 2, so players who opted in are asked again, as the privacy page promises.
2026-10-23 03:35:06 +02:00
Ludovic Marie 4fe9d19ca3 Merge pull request #1265 from rdbtCVS/t3/revert-pr-1257-server-threading
🤖 Agent: merged after verify passed and a local run on the Mac mini. lan_e2e on the server thread passes for host and guest: boats, minecarts, frames, ladder, lightning, riding and mounts. Parity --changed passed 25 of 25, and the server-thread default is confirmed. Single player runs on its server thread again. Thanks @rdbtCVS for pushing for the root fix.
2026-10-23 03:16:47 +02:00
Ludovic MarieandClaude Opus 5.5 213cffcf3e Benchmark report: the screen read again at the end of the run
Under Wayland the title screen can come before the window is on a screen, so the
report's hardware.display stayed empty (seen end to end in a headless sway).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:13:09 +02:00
Ludovic MarieandClaude Opus 5.5 0a450d2963 Benchmarks: the desktop (RTX 4080), players' hardware breakdowns, memory in the estimate
- The desktop measured: Horizon v2026.10.22.13 against vanilla 26.3 at render
  distance 12 and 32, 3 runs each, 1280x720, VSync off, in a headless sway on
  the RTX 4080 (never on its screen). Replaces the "not yet measured" note.
- /benchmarks shows each players' group's hardware split (RAM, VRAM, drive,
  system, GPU kind) and, once the telemetry server has some, a "Players'
  hardware" table from /v1/aggregate's breakdowns. Hidden without data.
- The picker takes the memory too. Players' groups whose installs have neither
  the picked RAM nor the GPU's VRAM class count as further away, and the result
  says what they had and when a game's peak memory is a large share of the RAM.
- Each GPU in the spec table has its memory (0: shared).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:11:31 +02:00
Ludovic MarieandClaude Opus 5.5 75d818fad6 Telemetry: the screen also when Wayland names it late
Right after a window is made, Wayland doesn't say which screen it is on, so
current_monitor() is None and display was missing (seen end to end in a headless
sway). Fall back to the primary, else the only, monitor, and read it again when a
session ends; agent control's status (the benchmark report's display) uses the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:55:38 +02:00
Ludovic MarieandClaude Opus 5.5 3bfb345714 horizon-ctl options: the agent page (Horizon Settings > AI Agent)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:47:42 +02:00
Ludovic MarieandClaude Opus 5.5 51be242af0 Launcher Settings: Connect an AI agent, and the No AI switch
Settings gets an AI group with the No AI switch (horizon_core::no_ai, the
same switch the game reads) and, while it is off, a Connect an AI agent
section that replaces the old command-line note: a picker for Claude Code,
Codex, Cursor or any MCP client, the ready-made config with a Copy button,
the client's own add command (claude mcp add / codex mcp add) shown and run
only when the player clicks Add, and Test the connection, which starts the
MCP server as a client would and says how many tools answered. Client CLIs
are found on the PATH or in the folders installers use, since a launcher
started from the desktop gets a short PATH on macOS.

horizon-launcher agent-config --test runs the same test; the launcher
harness checks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:35:09 +02:00
Ludovic MarieandClaude Opus 5.5 ef792ec3b9 Launcher MCP: every CLI command is a tool of the same name, plus settings and agent-config
The MCP tools are generated from the CLI's clap definition (cli_tools.rs):
mods_search is horizon-launcher mods search, with the same arguments, and
a call runs that command with --json. So the mod browser, modpacks, hosted
servers, settings, game arguments, data collection, desktop entry and the
benchmark are all reachable, and new CLI commands become tools by
themselves. Destructive ones need confirm: true and carry destructiveHint.
The session keeps play, server (with server_command/log/stop),
account_list and account_sign_in in-process, renamed after the CLI
(launch_client is play; play takes join for an invite code).

New CLI commands: settings [KEY VALUE] (selected version, folders,
install-new-versions, opening animation) and agent-config, the MCP config
for Claude Code, Codex, Cursor and any client. play takes --world and
--creative. Under No AI, mcp and agent-config are hidden and refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:31:38 +02:00
Ludovic MarieandClaude Opus 5.5 684ec5357e agent_control_e2e manage: close the options with close
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 b62a866f39 agent_control_e2e manage: the player's position is under player
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 a6e3fca1dd horizon-ctl: interact-entity beside hold, away from the logs arm
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 bcbf860aaa horizon-ctl: every player action in the one command table, with a generated reference
The table gains what only the raw socket could do (options pages, connect
and invite codes, disconnect, LAN, Play with Friends, books, signs, mount
inventories, effects, xp, enchant, potion, fill, containers) and new
commands: worlds, world-rename, world-backup, world-delete --confirm (the
Select and Edit World screens, from the title screen only), servers (the
Multiplayer list) and interact-entity (right-click an entity). status,
mouse, pad, release, bench and stats become MCP tools; summon takes a
position. Destructive and network tools carry MCP annotations.

docs/horizon-ctl-commands.md is generated from the table
(horizon-ctl help --markdown) and a test fails when it drifts. The
agent_control_e2e manage scenario exercises the new commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:59 +02:00
Ludovic MarieandClaude Opus 5.5 b117930ae4 No AI: one switch that turns off agent control (socket, horizon-ctl, both MCP servers)
horizon_core::no_ai reads and writes the noAi line of the game's
horizon-extras.txt, so the launcher and the game share one switch. While
it is on the game never opens its control socket, horizon-ctl refuses every
command with exit 4 and kind denied, horizon-ctl mcp and horizon-launcher
mcp refuse to start (and refuse calls if it is turned on mid-session), and
horizon-launcher no-ai [on|off] shows or sets it. The game's extras keep
the key when they rewrite the file. Both e2e harnesses check it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:58 +02:00
Ludovic MarieandClaude Opus 5.5 a57efd4fa0 Launcher lock file: horizon-bench now depends on horizon-telemetry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:54 +02:00
Ludovic Marie 602e86e093 Merge pull request #1132 from ludovic111/feat/switch-to-0-2
🤖 Agent: merged after verify passed. Go-day step 5 (docs/versioning.md): Ludovic said go on 2026-10-23 ("tu peux envoyer la 0.2"). The bridges are out (v2026.10.22 and launcher-v0.17.0), and the latest stable is v2026.10.22.07, built after the freeze. The launcher redesign (#1269) and the mod browser are merged.
2026-10-23 02:16:49 +02:00
Ludovic Marie 8b0152bd11 Merge remote-tracking branch 'origin/main' into sw1132 2026-10-23 02:09:13 +02:00
Ludovic Marie 1c7656ee9b Merge pull request #1269 from ludovic111/feat/design-gaming
🤖 Agent: merged after verify passed. Ludovic approved the redesign on 2026-10-23. Integration run on the desktop by integ-w3 (main + the mod browser stack + this), then main merged and re-checked on the Mac mini (launcher clippy, tests).
2026-10-23 02:08:43 +02:00
Ludovic Marie 91f42fb633 Merge remote-tracking branch 'origin/main' into l269-merge 2026-10-23 02:01:27 +02:00
Ludovic MarieandClaude Opus 5.5 828398b5ec Telemetry: every hardware fact that explains performance, broken down by class
What players send (opt-in, unchanged switches) now describes the whole machine:
CPU cores and base/boost clock, the GPU's maker, integrated or discrete and its
VRAM, the drive under the game folder (NVMe/SSD/HDD), the screen (size, refresh,
scale), on battery or mains, laptop or desktop. Perf, crash and bug items carry
them in `system`; benchmark reports in `hardware`. No serials, host or user
names, or paths. CONSENT_VERSION goes to 2, so players are asked again.

The server keeps the new fields in `systems.extra` (the table is rebuilt once
on old databases, ids kept). /v1/aggregate and /v1/benchmarks/summary add
breakdowns by RAM class, VRAM class, drive, OS and integrated/discrete GPU;
a class is published only with at least 3 different installs, never a raw value.
website/scripts/benchmarks.mjs mirrors the classes on the shared fixtures.

The summary's "not Horizon or plain vanilla" check returned None (accepted)
instead of the rejection; it now matches the site's script.

The launcher's "View what would be sent" and `data show` list every field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:59:39 +02:00
Ludovic MarieandClaude Opus 5.5 43a6fe0f7d Merge main into #1265: mount inventories for a server's players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:53:53 +02:00
Ludovic Marie 92b0ee1347 Merge pull request #1325 from ludovic111/fix/server-mount-inventory
🤖 Agent: merged after verify passed and the local parity run (102 of 102 at a4e68e6a, posted on the PR); lan_e2e mounts passes in both modes.
2026-10-23 01:52:24 +02:00
Ludovic MarieandClaude Opus 5.5 91dc5b0f18 Merge main into #1265: riding for a server's players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:52:02 +02:00
Ludovic Marie 014fc57b97 Merge pull request #1246 from ludovic111/feat/modbrowser-modpacks-port
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:46:33 +02:00
Ludovic Marie a9ccd5458c Merge pull request #1243 from ludovic111/feat/modbrowser-project-page
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:45:07 +02:00
Ludovic Marie 3e99730b98 Merge remote-tracking branch 'origin/main' into sw1132 2026-10-23 01:43:49 +02:00
Ludovic Marie f7b6777af6 Merge pull request #1244 from ludovic111/feat/modbrowser-e2e-harness
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:43:40 +02:00
Ludovic Marie 00b438d0ce Merge pull request #1242 from ludovic111/feat/modbrowser-java-launcher
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:43:13 +02:00
Ludovic Marie 990c609269 Merge pull request #1230 from ludovic111/feat/modbrowser-java-verdicts
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:39:39 +02:00
Ludovic Marie 9cfed309ac Merge pull request #1240 from ludovic111/fix/mod-item-network-ids
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:38:13 +02:00
Ludovic Marie 900bba1269 Merge remote-tracking branch 'origin/main' into l269-merge
# Conflicts:
#	docs/design-system.md
2026-10-23 01:31:40 +02:00
Ludovic Marie 8500adab0c Merge pull request #1314 from ludovic111/fix/server-riding
🤖 Agent: merged after verify passed and the local parity run (102 of 102, posted on the PR); lan_e2e riding passes in both modes.
2026-10-23 01:29:03 +02:00
Ludovic Marie 8b52929723 Merge pull request #1324 from ludovic111/feat/site-live-count
🤖 Agent: merged after verify passed; the Chromium checks are in the description.
2026-10-23 01:20:57 +02:00
Ludovic MarieandClaude Opus 5.5 a4e68e6a53 Mount inventories for a server's players, and llama inventories
A server's players (single player on its server thread, LAN guests,
dedicated-server players) get the horse inventory: shift + use on a tamed
horse, donkey, mule, camel or llama, or the inventory key while riding one
(ServerboundPlayerCommandPacket OPEN_INVENTORY), opens HorseInventoryMenu
over the mount with ClientboundMountScreenOpenPacket. Clicks run on the
menu engine's new Kind::Mount (saddle and body ArmorSlots that take what
the mount may wear, the chest, AbstractMountInventoryMenu.quickMoveStack),
and the slots go back to the mount with the item's equip sound.

The client shows a server's mount screen, predicts its clicks with the
same engine and sends OPEN_INVENTORY when the inventory key is pressed
while riding. Llamas now have their inventory in the window too: the
carpet slot and a chest of `strength` columns, no saddle slot.

lan_e2e `mounts` checks it for the host and a guest, with and without
--server-thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:20:51 +02:00
Ludovic MarieandClaude Opus 5.5 4eb7a98330 Website: "Playing now" on the home page, from the telemetry server's live count
The home page's "At a glance" list gets a fifth row with how many play right now, read from
https://telemetry.playhorizon.dev/v1/live (3 s timeout, again every minute while the tab is
visible). It stays hidden while nobody plays or the server doesn't answer. The FAQ no longer
says the "I'm playing" ping is off: the server is live since #1276.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:19:42 +02:00
Ludovic Marie 27bf66b354 Merge pull request #1274 from ludovic111/feat/site-releases-bench
🤖 Agent: merged after verify passed; Ludovic approved the site design. The Releases and Benchmarks pages go live with this.
2026-10-23 01:14:23 +02:00
Ludovic Marie b3e9d8ebd1 Merge pull request #1276 from ludovic111/feat/telemetry-live
🤖 Agent: merged after verify passed. telemetry.playhorizon.dev is live on Railway (service telemetry in horizon-site, volume /data): /healthz, /v1/live (a test ping counted) and /v1/benchmarks/summary (CORS for playhorizon.dev) answer. The PR's local end-to-end runs are in the description.
2026-10-23 01:14:18 +02:00
Ludovic Marie baf013dd3f Merge remote-tracking branch 'origin/main' into feat/site-releases-bench 2026-10-23 01:12:59 +02:00
Ludovic Marie e310a5ca5e Merge pull request #1259 from ludovic111/feat/website-blocks
🤖 Agent: merged after verify passed; Ludovic approved the site design.
2026-10-23 01:12:08 +02:00
Ludovic MarieandClaude Opus 5.5 c7f6903144 Merge feat/modbrowser-modpacks-port (now with main) into feat/design-gaming
launcher/src/main.rs: main (#1271) removed the Game menus setting (one menu
style now) where this branch restyled Settings. Kept the Game and About
group headings, dropped the Game menus row as main does, and renumbered the
rows after it (game arguments n+4, Horizon n+5, Launcher n+6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:09:23 +02:00
Ludovic MarieandClaude Opus 5.5 05497ddd10 Merge origin/main into fix/mod-item-network-ids
No conflicts: brings main (with #1271) so CI tests this head on current main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:34 +02:00
Ludovic MarieandClaude Opus 5.5 038e52f87e Merge feat/modbrowser-project-page (now with main) into feat/modbrowser-modpacks-port
No conflicts: carries the stack's merge of origin/main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:33 +02:00
Ludovic MarieandClaude Opus 5.5 69887b87d1 Merge feat/modbrowser-java-launcher (now with main) into feat/modbrowser-project-page
No conflicts: carries the stack's merge of origin/main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:33 +02:00
Ludovic MarieandClaude Opus 5.5 4786d56d5b Merge feat/modbrowser-java-launcher (now with main) into feat/modbrowser-e2e-harness
No conflicts: carries the stack's merge of origin/main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:32 +02:00
Ludovic MarieandClaude Opus 5.5 a5d68dc890 Merge feat/modbrowser-java-verdicts (now with main) into feat/modbrowser-java-launcher
No conflicts: carries #1230's merge of origin/main up the stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:31 +02:00
Ludovic MarieandClaude Opus 5.5 9ae041ccd3 Merge origin/main into feat/modbrowser-java-verdicts
No conflicts: brings main (with #1271) under the mod browser stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:08:30 +02:00
Ludovic Marie 63507e7c26 Merge remote-tracking branch 'origin/main' into feat/website-blocks
# Conflicts:
#	docs/design-system.md
2026-10-23 00:58:57 +02:00
Ludovic Marie 52aa397b26 Merge pull request #1271 from ludovic111/feat/vanilla-menus
🤖 Agent: merged after verify passed. Local proof on the merged head bf274d92: fmt, clippy -D warnings, client and launcher tests, parity --changed origin/main, and test_friends.py --server-thread. CI parity passed on the earlier head. Every Horizon setting is reachable from vanilla-style screens (list in the PR body).
2026-10-23 00:56:15 +02:00
Ludovic MarieandClaude Opus 5.5 33dcc66379 Merge main into #1265: keep the vehicle checks, take the server-thread default
lan_e2e takes main's version: #1280 kept `boats` (passing with and
without --server-thread) and added `minecarts`, `frames` and `lightning`
next to `ladder`, and main's run() already sets HORIZON_SERVER_THREAD
explicitly as this branch does. The code keeps this branch's change:
single player runs on its server thread by default again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:42:15 +02:00
Ludovic MarieandClaude Opus 5.5 525129fbe6 Riding animals for a server's players
Single player on its server thread, LAN guests and dedicated-server
players could not ride: a server player's turn had no mount input, and
its client had nothing to sit on.

The server now drives a server player's animal from that player's
PlayerInput (forward, back, strafe, jump, sneak, sprint) and rotation,
through the same tick_mount_riding the window's player uses:
- the jump bar charges per player (MountState swapped with the turn);
- sneaking gets off, with SetPassengers and the teleport beside it;
- a mount ridden by another player can't be taken or fed (isVehicle);
- an animal whose rider left stops taking its input.
The client sits where positionRider puts it on the animal it was sent,
and charges its own jump bar for the HUD.

Ridden movement (pre-existing, also in the window): Mob.setSpeed sets zza
too, so the ridden input set before the speed was lost and pigs and
striders crawled while horses walked on with no key held; the input is
now set after the speed (travelRidden). The ridden speed uses the
effective movement speed (a cold strider's modifier, potions), and a
shivering strider rides at 0.35 of it (Strider.getRiddenSpeed).

lan_e2e riding: the host and a guest saddle and ride a horse (walk and
jump), a pig and a strider with their sticks, a camel and a donkey, sit
on a llama, harness and fly a happy ghast, and get off, with and
without --server-thread. `mobs` reports ridden, controlled, saddled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:40:34 +02:00
Ludovic MarieandClaude Opus 5.5 bf274d9254 Merge origin/main into feat/vanilla-menus
With #1285 Play with Friends works on the server thread: friends_view
offers sharing there, so the pause menu's Friends icon is active on both
paths and inactive only on another server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:04:42 +02:00
Ludovic Marie 07e7179773 Merge pull request #1285 from ludovic111/fix/friends-server-thread
🤖 Agent: merged on verify plus local proof. tools/test_friends.py passes with the host on its server thread and in the frame (code, self-hosted relay and short code, stop). Parity --changed: 22/0. Whichever of #1271 and this lands second drops #1271's server-thread greying of the Friends icon.
2026-10-22 23:56:10 +02:00
Ludovic Marie a161487994 Merge pull request #1280 from ludovic111/fix/1222-server-vehicles
🤖 Agent: merged on verify plus local proof, since CI parity is queued behind the Mac mini runners. Parity --changed origin/main on this head: 102/0. lan_e2e passes with and without --server-thread, for the host and for a LAN guest: boats, minecarts, frames, ladder, lightning. The default is unchanged here; #1265 follows once riding animals work for a server's players too.
2026-10-22 23:55:38 +02:00
Ludovic MarieandClaude Opus 5.5 8c2f683e0a Merge origin/main into feat/vanilla-menus
Conflict: #1237's weather now returns its own vertices; the title draws
none (the live title backdrop is gone).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:15:35 +02:00
Ludovic Marie 190faa4f55 Merge pull request #1268 from rdbtCVS/perf/surface-biome-cells
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 23:07:27 +02:00
Ludovic Marie 36aa1ec0a2 Merge pull request #1237 from rdbtCVS/perf/cache-rain-snow-params-157e7038
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 23:00:50 +02:00
Ludovic Marie 8ddb74031c Merge pull request #1273 from rdbtCVS/perf/end-island-volumes
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v7: core 99 and worldgen 81 tests, 23 worldgen/density/noise parity harnesses 23/0; bit-exact by construction, with a test against the scalar path).
2026-10-22 22:52:05 +02:00
Ludovic MarieandClaude Opus 5.5 7f7a05cbd0 Play with Friends on single player's server thread
Sharing a world with an invite code refused with the integrated server
on its own thread ("open a singleplayer world first"): the window is a
client there. It now shares that server as Open to LAN publishes it:
friends_start publishes it when it isn't, the invite tunnel points at
its port, stopping sharing unpublishes it again, and the pause menu
shows the Friends entry with the players the server lists.

test_friends.py --server-thread runs the host's world on its server
thread; the host's own move and blocks go through /tp, /fill and
/setblock, which the server answers in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:49:31 +02:00
Ludovic MarieandClaude Opus 5.5 e5fbba281c Releases: a title made of issue numbers reads its line from the notes
"Horizon 2026.10.22.07: #1255, mob pushes, fix, #1222" now shows "Single player runs in the frame again
until server players have vehicles and 3 more".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:36:45 +02:00
Ludovic MarieandClaude Opus 5.5 63d59b26cf Benchmarks: the ThinkPad's runs, and Java's per-scene wins
- ThinkPad (i7-7500U, HD Graphics 620, Mesa): Horizon v2026.10.22.04 (two flights per scene) against vanilla
  26.3 (two games) at render distance 12. Java loads chunks faster there and flies the mountain faster; the
  page says so. Render distance 32 isn't published for it: neither game loaded the spawn in 5 minutes, and
  Horizon kept only 900 to 1,400 columns resident when given 25.
- "Where Java is ahead" also lists scenes where vanilla's average beats Horizon's by more than 5%.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:35:05 +02:00
Ludovic MarieandClaude Opus 5.5 72b749cdb0 Merge origin/main into feat/vanilla-menus
Conflicts: the menu blur (#1249) now runs without the Horizon style check,
chat and the tab list draw under every screen as before the blur split,
and the HUD's second range draws without the removed colour grade. Play
with Friends blurs the world behind it like vanilla's other menus.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:30:12 +02:00
Ludovic MarieandClaude Opus 5.5 1a8dae9f21 lan_e2e boats: the pool in a stone basin, for worlds that float
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:23:32 +02:00
Ludovic MarieandClaude Opus 5.5 5d77ace9a4 Merge main into #1237: keep #1234's column table and #1238's bolt quads
#1234 replaced column_size with a LazyLock table and #1238 added the
lightning quad count to gfx.rs's max chain; this branch rewrote both
places. weather.rs keeps this branch's code with #1234's table and tests;
gfx.rs takes the max over cloud, lightning and weather quads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:22:19 +02:00
Ludovic MarieandClaude Opus 5.5 afd3874482 Lightning for a server's players: the bolt and the sky flash
With single player on its server thread the window drew no lightning,
and LAN guests and dedicated-server players never did: the server never
sent the bolt (it lived outside the tracked entities) and the client had
nothing to draw for a lightning_bolt AddEntity.

The server gives each bolt an entity id and tracks it like the other
entities; the client adds a visual-only bolt for it (LightningBolt.tick
off the server: sounds, the sky flash, the drawn bolt) and ticks bolts
and the flash in its level tick. `status` reports bolts and flashes, and
lan_e2e's `lightning` check summons one beside each player and asserts
both sides get the bolt and the flash.

lan_e2e: the vehicle checks put the players apart first, and run()
sets HORIZON_SERVER_THREAD explicitly either way, as #1265 does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:16:05 +02:00
Ludovic MarieandClaude Opus 5.5 16b7e60074 Fix #1222 at the root: vehicles for a server's players
Single player on its server thread, LAN guests and dedicated-server
players could not board, drive or break boats and minecarts, nor use
item frames and armor stands: the client sent Interact/Attack only for
mobs, the server aimed only at mobs, and nothing carried riding over
the network.

Client (remote.rs, remote_vehicles.rs):
- Interact/Attack for boats, minecarts, item frames, armor stands and
  paintings, with the hit location (Minecraft.hitResult).
- SetPassengers seats the player; a boat ticks on the rider's client and
  goes to the server as MoveVehicle and PaddleBoat every tick; a
  MoveVehicle back puts it where the server says; a minecart moves on the
  server and the rider sits in it.

Server (lan_vehicles.rs, lan_actions.rs, hub.rs):
- look_at_entity aims at every pickable object, at the client's hit point.
- The guest's vehicle is its Entity.vehicle during its turn; tick_boats
  and tick_carts leave guest-ridden vehicles to their riders.
- handleMoveVehicle with its checks (invalid values kick, too quickly,
  moved wrongly, colliding with anything new), handlePaddleBoat.
- Player.rideTick: sneaking gets out, then SetPassengers and the
  teleport beside the vehicle (dismountTo); a minecart moves with the
  rider's input; a passenger's moves only turn it.
- SetPassengers for guests through hub.riding; one rider per vehicle.

lan_e2e: boats (host and guest board, paddle, the other side sees the
boat move, sneak out, break), minecarts and frames (item frame and armor
stand) for both, passing with and without --server-thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:16:05 +02:00
Ludovic Marie 83fa14e51b Merge pull request #1235 from rdbtCVS/t3/cache-end-sky-mesh
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 22:12:48 +02:00
Ludovic Marie 7a0bdbc05e Merge pull request #1238 from rdbtCVS/t3/cache-lightning-bolt-geometry
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 22:12:23 +02:00
Ludovic Marie 67277d4571 Merge pull request #1234 from rdbtCVS/t3/cache-column-size-table
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v5: workspace tests 1887/0, naga 0 failures, parity --changed 304/0). Rendering checked with before/after scenes: the screenshots match, and per-frame CPU and GPU time goes down.
2026-10-22 22:10:50 +02:00
Ludovic MarieandClaude Opus 5.5 d3bad12383 Merge main into #1268: keep the End shortcut around the cached surface pass
#1264 wrapped build_surface in `if !self.identity_end_surface(pos)` where
this branch adds the uniform_biomes field to the same SurfaceQueries. Kept
the condition with the new field inside it; both tests are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:04:39 +02:00
Ludovic Marie a9682c5554 Merge pull request #1264 from rdbtCVS/perf/end-identity-surface
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 22:02:53 +02:00
Ludovic Marie 663b11d8dd Merge pull request #1251 from rdbtCVS/t3/fix-firework-tooltip-display
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 22:01:25 +02:00
Ludovic Marie 4779b48d2b Merge pull request #1250 from rdbtCVS/t3/fix-slash-command-autocomplete
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 22:01:00 +02:00
Ludovic Marie 01f1a0360d Merge pull request #1249 from rdbtCVS/t3/fix-menu-background-blur
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:59:29 +02:00
Ludovic Marie dcb3749ed1 Merge pull request #1248 from rdbtCVS/t3/fix-block-place-animation
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:57:58 +02:00
Ludovic Marie 96a031792b Merge pull request #1247 from rdbtCVS/t3/fix-q-drop-animation
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:56:24 +02:00
Ludovic Marie 08a73694ab Merge pull request #1245 from rdbtCVS/t3/fix-instant-mined-block-delay
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:55:59 +02:00
Ludovic Marie fb7665d88e Merge pull request #1231 from rdbtCVS/perf/saved-light-loading
🤖 Agent: merged after verify passed and the wave 2 integration run on the desktop (main + 16 contributor PRs, tree 71afc562: workspace tests 1887/0, launcher 113, naga translations 0 failures, parity --changed 304/0).
2026-10-22 21:54:19 +02:00
Ludovic MarieandClaude Opus 5.5 9e1c3ec860 perf_bench: the readiness gate only with a chunk target; a test for the quiet start
Without min_chunks (the old call) wait_loaded behaves as before, which tests/test_perf_bench.py's
old-builds case relies on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:51:16 +02:00
Ludovic MarieandClaude Opus 5.5 be16401f90 Telemetry: send to telemetry.playhorizon.dev, a benchmarks summary, the Railway config
- DEFAULT_ENDPOINT is https://telemetry.playhorizon.dev. The heartbeat, the launcher's live count and the
  outbox upload all go there; HORIZON_TELEMETRY_URL still names another server, and HORIZON_TELEMETRY_URL=off
  sends nothing at all. CI workflows and the harnesses that start the game or the launcher set off. Consent
  is unchanged: the four switches stay opt-in, only the anonymous ping is always on.
- GET /v1/benchmarks/summary: players' benchmark uploads grouped by GPU, CPU, render distance and game, groups
  of 3+ installs only, medians per install then across installs, no install ID, day, OS version, driver,
  memory or screen. Cached 10 minutes, CORS for the website. Same rules as website/scripts/benchmarks.mjs,
  tested on the same fixtures; a deleted install leaves its groups at once.
- /v1/aggregate no longer lists the newest 50 raw benchmark reports (hardware, driver, day, one install each):
  uploads are published only through the summary.
- Behind Railway's proxy the rate limit counts X-Real-IP, else the last X-Forwarded-For entry (the one the
  proxy adds; a client could choose the first). Still in memory only, never stored or logged.
- crates/horizon-telemetry-server/railway.json: Dockerfile build from the repository root, health check
  /healthz, one replica in europe-west4 (one SQLite file on the /data volume).
- Docs: docs/telemetry.md, the server README (the summary's shape, Railway setup), the privacy page (the ping
  is on, where the data lives, how benchmark uploads are shown).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:46:32 +02:00
Ludovic MarieandClaude Opus 5.5 7eded537d6 perf_bench: wait for Horizon to actually load before timing a scene
Right after a teleport, and at spawn, Horizon v2026.10.22.04 reports nothing queued and steady chunk counts
for over a second before it starts loading. wait_loaded() took that for "loaded" and stopped the clock with
0 to 3 sections meshed (load 0.1 s), so the flights ran over empty terrain and the frame rates meant nothing.

Now the counts must change once since the wait began (or stay at the full view for 5 s), and the resident
chunks must reach 80% of the view circle (pi x RD^2), before a steady second counts as loaded.

Measured with it on the Mac mini and the ThinkPad for the website's Benchmarks page (#1274). At RD 32 a 4-thread
laptop needs --timeout above the default 300 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:45:57 +02:00
Ludovic MarieandClaude Opus 5.5 fd9e6a7e78 Benchmarks: the Mac mini's runs, Horizon v2026.10.22.04 against vanilla 26.3
Three runs of each game at render distance 12 and 32 (tools/perf_bench.py, seed 1234, 1280x720, VSync off).
Horizon's average sits at the 144 Hz display (macOS presents in step with it even with VSync off): marked as
capped on the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:43:37 +02:00
Ludovic MarieandClaude Opus 5.5 aae60c16ba Website: a Releases page, "Recently added" on the home page, and a Benchmarks page
- /releases: every game and launcher release on one page, newest first, Stable / Alpha / Launcher marked and
  filterable. The newest three are open; each shows what it added and fixed, the full notes, its downloads and
  a link to GitHub. Baked at build time, refreshed from GitHub when a newer release is out. /releases#release-<tag>
  opens one.
- Home: "Recently added" replaces the patch notes block: the last five releases on one line each and three
  highlights from what they added.
- /benchmarks: Horizon against vanilla Java Edition 26.3 measured on our machines (average FPS, 1% lows, p95
  frame time, start to world, chunk loads, memory, at render distance 12 and 32), saying where Java is ahead;
  a GPU + CPU picker that shows a measurement when there is one and otherwise a marked estimate with a range,
  from our own spec table (method explained on the page); players' results read live from the telemetry
  server's GET /v1/benchmarks/summary, falling back to the baked public/benchmarks.json, groups of 3+ installs
  only.
- scripts/benchmarks.mjs builds public/benchmarks.json from our reports (and, for tests, telemetry items) with
  the same rules as the server; fixtures shared with the server's test.
- Releases and Benchmarks in the nav and footer; docs/performance.md links the page.
- Pixelify Sans' "fi" ligature drew "fixes" as "Axes": common ligatures off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:43:36 +02:00
rdbtCVS 4e04e97565 Reuse eligible End island candidates across bounded density volumes 2026-10-22 20:29:00 +01:00
Ludovic MarieandClaude Opus 5.5 fc8643b380 Docs: settings screenshots show the vanilla Video Settings and Advanced Graphics screens
Captured on the Mac mini with the game's offscreen capture (1280x720),
replacing the Horizon menu style's Video page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:06:51 +02:00
Ludovic MarieandClaude Opus 5.5 954bb6a1fa Play with Friends: the pause icon takes controller focus, help moves to a Troubleshooting button
The pause menu's Friends icon is recorded as a widget, so a controller
reaches it and the control socket names it. The sharing screen's long help
URL ran off the screen: a Troubleshooting... button beside Done opens it,
and the hint drops out when it would reach the footer. The graphics docs
say what the two Horizon screens hold (vanilla's own settings stay on Video
Settings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:06:51 +02:00
Ludovic MarieandClaude Opus 5.5 042adc674a One menu style, vanilla's: Horizon's settings join vanilla's Options
Drop the Horizon menu style (options_horizon.rs, front.rs, the live title
backdrop and its post grade, the launcher's menu-style setting, CLI and MCP
tool). Every screen is vanilla's. Horizon's own settings hang off vanilla's
Options the way mods add theirs: Horizon Settings... and Mods... take the
Telemetry/Credits slots, and Horizon Settings opens Graphics, Advanced
Graphics, Shader Packs, Controller Settings, Extras and Mods, each a vanilla
options list with tooltips. Play with friends moves behind vanilla's Friends
buttons: the pause menu's opens Play with Friends (share, copy, stop), the
title screen's opens Join a Friend, and Direct Connection takes a full code.

Old files load as before: horizon.txt's menuStyle line and
horizon-graphics.txt's level line are ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:06:51 +02:00
rdbtCVS cd494e74ba Cache uniform resident biome cells during surface generation 2026-10-22 19:45:38 +01:00
Ludovic MarieandClaude Opus 5.5 f1f8170c58 Launcher: the offline name without its key, and Home before any version
The game keeps the offline name as "name:Player12345" in horizon-player.txt;
the top bar and the friends-only server list showed the whole line. Both
now read the name after the key.

With no version installed, Home has no landscape: its title starts below
the top bar like the other pages, and the sidebar says "No version
installed" instead of "Needs preparing".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:33:35 +02:00
Ludovic MarieandClaude Opus 5.5 700c9ec8cd Merge main into #1264: keep the --snapshot usage line
main's warmed-timing change and this branch both edited the usage comment
at the top of examples/chunkgen_bench.rs. Kept this branch's line, which
adds [--snapshot PATH]; no code change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:29:09 +02:00
rdbtCVS 35ffbebcdd Revert #1257: keep the integrated server thread enabled by default
Revert commit cdaf1d79a0, preserving the ladder regression check added afterward.

Server-player bugs also affect dedicated servers and Open to LAN. Running single player in the frame only hides those bugs there; keep the shared server-thread path as the default and fix the underlying issues.
2026-10-22 19:18:03 +01:00
Ludovic Marie 1ec2762fe6 Merge pull request #1263 from ludovic111/fix/1255-ladder-fall
🤖 Agent: merged on verify plus local harness proof (CI parity is queued behind about 15 runs). The new lan_e2e ladder check: on main, ten blocks of ladder took the guest from 20 to 13 health; with the fix it lands at full health. Main was merged in, keeping both the ladder and boats checks.
2026-10-22 20:14:47 +02:00
Ludovic MarieandClaude Opus 5.5 2f8d913b85 Launcher: long lists draw only the rows near the screen
Versions and Patch notes list a hundred releases, and every frame laid out
and painted all of them. Rows more than a screen away from the viewport are
now empty blocks as tall as when they were last drawn (cull.rs), so the
page keeps its length; a row being installed or showing an error is always
drawn. Scrolling Versions on the Mac mini: median frame 8.2 -> 7.0 ms.

HORIZON_LAUNCHER_PROJECT=<slug> opens a project's page on the Mods page,
for screenshots, as HORIZON_LAUNCHER_PAGE opens a page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:14:29 +02:00
Ludovic Marie 6c4309d36f Merge pull request #1205 from pirut/claude/upstream-mob-pushes
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:11:51 +02:00
Ludovic Marie 95d0a06ba5 Merge pull request #1228 from ItsPotatoTime/fix/entity-metadata
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:05:23 +02:00
Ludovic MarieandClaude Opus 5.5 90afc3dbf4 Merge main into fix/1255-ladder-fall (lan_e2e: keep both the ladder and boats checks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:04:28 +02:00
Ludovic Marie cdaf1d79a0 Merge pull request #1257 from ludovic111/fix/1222-boat-interact
🤖 Agent: merged on verify plus local harness proof. It is a release-blocker fix, and CI parity is queued behind about 15 runs on the Mac mini. lan_e2e boats fails on main with --server-thread (riding None) and passes with this PR's default (in the frame, the path that was the default until #1114). fmt and clippy -D warnings pass. Server players' vehicles are tracked in FEATURES.md 4x.
2026-10-22 20:03:55 +02:00
Ludovic Marie d68ca84275 Merge pull request #1223 from rdbtCVS/perf/chunk-palette-loads
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:01:51 +02:00
Ludovic MarieandClaude Opus 5.5 270ba3fea9 Launcher: a blocky, Minecraft-like look in dark and light
Blocks palettes (dark default, light, or the system's, chosen in Settings), Pixelify Sans for
headings and controls, Pixelarticons icons, square panels with an outline, a bevel and a lip,
inventory tabs, and pixel art drawn in code: a stone texture behind the pages and a landscape
behind Home. A top bar with the pages and the account chip, and a sidebar with the instances
(Horizon shows the grass block the mod browser draws from the player's prepared textures), the
hosted servers, and the launcher's version and channel. The mod browser's pages (kinds as tabs,
project icons in slots, verdicts as badges, the project page) are restyled, not rebuilt. The
benchmark also measures scrolling the Versions list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:01:13 +02:00
Ludovic Marie 4f4dc33370 Merge pull request #1214 from pirut/claude/upstream-mojang-java
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 20:00:23 +02:00
Ludovic MarieandClaude Opus 5.5 59adff16ff Merge main into #1205: keep push_tests next to teams_tests and sensor_scan_tests
#1200 and the teams change appended test modules at the same place as this
branch's push_tests. Main's modules are kept as they are and push_tests
follows them unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:57:27 +02:00
Ludovic Marie 160076e15f Merge pull request #1229 from rdbtCVS/perf/aquifer-neighborhood
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:54:21 +02:00
Ludovic Marie 658d54e578 Merge pull request #1225 from pirut/claude/upstream-macos-pointer-lock
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:54:10 +02:00
Ludovic Marie 3576a7a1c0 Merge pull request #1208 from pirut/claude/upstream-perlin-floor
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:54:05 +02:00
Ludovic Marie 0ed1bd8deb Merge pull request #1207 from pirut/claude/upstream-feature-speedups
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:53:54 +02:00
Ludovic Marie f254871c1f Merge pull request #1206 from pirut/claude/upstream-perlin-climate
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:53:50 +02:00
Ludovic Marie 9f687dd1ec Merge pull request #1204 from pirut/claude/upstream-furnaces
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:49 +02:00
Ludovic Marie dc6b648f69 Merge pull request #1203 from pirut/claude/upstream-hoppers
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:37 +02:00
Ludovic Marie 36a5696abf Merge pull request #1202 from pirut/claude/upstream-mob-scans
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:26 +02:00
Ludovic Marie 48d6211781 Merge pull request #1201 from pirut/claude/upstream-chunk-bookkeeping
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:15 +02:00
Ludovic Marie 1ef5f8fae5 Merge pull request #1200 from pirut/claude/upstream-mob-world
🤖 Agent: merged after verify passed and the integration run on the desktop (main 34c3c1cf + the 14 contributor PRs: workspace tests 1,869/0, launcher 113, parity --changed 320/0).
2026-10-22 19:52:03 +02:00
rdbtCVS 0928c83d4c Skip provably unchanged native End surface traversal 2026-10-22 18:40:01 +01:00
Ludovic MarieandClaude Opus 5.5 724f42355f Fix #1255: server players climbing down a ladder take no fall damage
Player's MoveSimulationType is AUTHORITATIVE_SIDE_AND_SERVER, so a
ServerPlayer travels on the server too, and every tick on a ladder or
vine LivingEntity.handleOnClimbable resets its fall distance. Horizon's
server only replayed the client's moves (ServerPlayer.doCheckFallDamage),
so the fall distance grew all the way down a ladder and hurt the player
where it landed: LAN guests, dedicated-server players and, while single
player ran on the server thread (#1114), the single player.

lan_e2e ladder: the guest slides down ten blocks of ladder and lands with
full health (before: 20 -> 13).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:37:21 +02:00
Ludovic MarieandClaude Opus 5.5 70c06224fe Website: the Blocks look, a blocky Minecraft-like design in Horizon's purple
horizon-theme gains the Blocks palettes (light and dark, WCAG AA on every panel and on the stone),
Pixelify Sans (OFL) and Pixelarticons (MIT); tokens.css now carries the Blocks colours, the pixel
font and the block edges. The site sets headings, navigation and buttons in Pixelify Sans, turns
the index lists into stacks of panels with an outline, a bevel and a lip, puts a stone texture
behind the page and Horizon's pixel landscape (a dawn or a sunset) behind the hero, both drawn in
code by scripts/art.mjs. Light and dark still follow the system, with the toggle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:29:41 +02:00
Ludovic MarieandClaude Opus 5.5 c0fd8dabfd Fix #1222: single player runs in the frame again until server players have vehicles
Since #1114 single player's window is a player of its own server thread,
and a server's players can't use boats or minecarts: the client sends
Attack and Interact only for mobs (remote_start_attack, remote_use_entity),
the server aims a player's attack or use only at mobs and dragon parts
(look_at_entity), and neither side handles riding (no SetPassengers on the
client, no MoveVehicle or PaddleBoat packets). So a boat on water could not
be boarded or broken in single player.

The server thread is opt-in again (--server-thread or
HORIZON_SERVER_THREAD=1); --no-server-thread still works.

lan_e2e boats: the host right-clicks a boat on water and rides it, sneaks
out, and breaks it. Without --server-thread the host now runs the game's
default instead of forcing the frame, so the check covers what players get.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:15:44 +02:00
Ludovic MarieandClaude Opus 5.5 da991045f9 Modpacks: jars only CurseForge serves are left off, "CurseForge downloads coming soon"
The pack lists them with the mods left off, the port's log and fetch label say
the same, and docs/modpacks.md no longer asks for a key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:54:33 +02:00
Ludovic MarieandClaude Opus 5.5 f926d1d2b1 Modpacks from the mod browser: ported in the same step, mods loaded together
Importing a modpack from the Mods page (or `mods install`) now ports it right away
for the selected version, so Play works straight after. port-pack leaves a
namespace two carried mods both claim to none of them (YUNG's mods all ship
`yungsapi`, which made the game drop three of them), and the launcher applies
the game's first-wins rule before it starts a pack, listing what it left off.

Measured on Vanilla Perfected, Slimes Adventure and All the Mods 10 (docs/modpacks.md):
2, 20 and 42 mods ported, every world loads, no crash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:52:12 +02:00
Ludovic MarieandClaude Opus 5.5 b1dc23551b tools: end-to-end check of a Java mod from the mod browser
mod_browser_harness.py searches, checks and installs a mod through the launcher's
mods CLI in a scratch game folder, opens a world with it offscreen, places one of
its blocks and crafts one of its recipes on a crafting table, and times the world
load with and without it. Macaw's Fences: check 4.0 s, install 3.1 s, world load
5.4 s -> 7.3 s, hedge placed, 4 crafted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:52:03 +02:00
Ludovic MarieandClaude Opus 5.5 f53998674d Launcher mod browser: project pages, search filters, project icons and gallery
A result's title opens its page in the launcher: icon, authors, downloads,
followers, licence, verdict (for a Java mod, what ports and what doesn't) and
Install, links, then Description (Markdown), Gallery and Versions tabs
(browser::details, Source::details on Modrinth's project and members).

Filters: sort (relevance, downloads, follows, newest, updated), loader, game
version, environment and categories, from the source's tag lists.

Icons and gallery images are fetched at run time over HTTPS from the sources'
image CDNs only, size-capped, decoded and scaled to PNGs in a 128 MB disk cache
(browser::images); a placeholder shows until they arrive. Vanilla's mark is a
grass block drawn from the player's prepared game files. Modrinth's logo isn't
shown: its branding is all rights reserved without written permission.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:51:56 +02:00
Ludovic MarieandClaude Opus 5.5 b8f79247b8 Mod browser: CurseForge is coming soon
Ludovic dropped CurseForge for now (its API needs a paid key). The Mods page shows
"CurseForge (coming soon)" beside the Modrinth switch, disabled and inert;
browser::sources() is Modrinth only, so no search or install goes to CurseForge,
and `--source curseforge` says it is coming soon. Text that asked for a key or
an environment variable now says CurseForge support is coming soon. Importing a
CurseForge zip or instance still works; jars only CurseForge serves are listed
as "CurseForge downloads coming soon".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:50:25 +02:00
rdbtCVS a97d8098c7 Fix firework tooltip translations 2026-10-22 17:47:29 +01:00
rdbtCVS d67779d5a1 Make completion regression deterministic and include screenshots 2026-10-22 17:42:56 +01:00
rdbtCVS e7d42dc9d0 Fix in-world menu background blur 2026-10-22 17:41:17 +01:00
rdbtCVS ecbb0368c5 Fix hand swing prediction for rejected block placements 2026-10-22 17:37:42 +01:00
rdbtCVS 15b8514584 Fix registry-backed slash command suggestions on connected clients 2026-10-22 17:37:23 +01:00
Ludovic MarieandClaude Opus 5.5 6e83037fca end sky test: as_chunks instead of chunks_exact (clippy 1.99)
Rust 1.99's clippy denies chunks_exact with a constant size
(chunks_exact_to_as_chunks), so `cargo clippy --all-targets -D warnings`
failed on this test. Same check, written as as_chunks::<4>().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:32:54 +02:00
rdbtCVS 6d36d468e7 Fix empty-hand drop animations 2026-10-22 17:29:55 +01:00
rdbtCVS d86a1b2882 Fix cooldown after instant survival breaks in multiplayer 2026-10-22 17:26:39 +01:00
rdbtCVS ca2d5e04dc Remove benchmark report from pull request 2026-10-22 17:15:46 +01:00
rdbtCVS b054c8db11 Remove benchmark report from pull request 2026-10-22 17:15:44 +01:00
rdbtCVS fba42920e4 Remove benchmark report from pull request 2026-10-22 17:15:41 +01:00
Ludovic MarieandClaude Opus 5.5 a3f81f3a30 Launcher: test a Java mod's install, dependencies, id clash and removal offline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:10:20 +02:00
Ludovic MarieandClaude Opus 5.5 240e76160b Mod content: give mod items and blocks network ids, so they don't travel as air
The game and its integrated server map items and blocks to network ids through
the registries report (registries.json), which the content root left vanilla:
a mod's item in a slot, given or picked up, reached the client as air. The
content root now rewrites registries.json too, appending the new items and
blocks after vanilla's ids in plan order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:07:16 +02:00
rdbtCVS c9c7cda779 docs: record the completed ten-agent optimization sequence 2026-10-22 17:06:09 +01:00
Ludovic MarieandClaude Opus 5.5 3530560581 Launcher: Java mods in the mod browser, checked before install; Modpacks moves into Mods
The Mods page gets a Mods tab: Fabric, NeoForge, Forge and Quilt mods for 26.3
(1.21 and later with Show everything). Each result's jar is downloaded (allowed
CDNs, checksum), checked with horizon-port-pack (Works, Partly, Doesn't run, with
what ports and what doesn't) and the answer cached per file. Install copies the
carry into horizon-mods/<id>/ and turns it on in horizon-mods.txt, with required
mods that port; libraries with nothing to port are listed as not ported; id or
namespace clashes are refused. Remove deletes it and turns it off.

The Modpacks page becomes the Mods page's Modpacks tab: imported packs first,
then the search. CLI: mods search --kind mod, mods check <project>.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:00:24 +02:00
Ludovic MarieandClaude Opus 5.5 4a350bb0a0 port-pack carry: leave out recipes for items it didn't declare
A recipe whose result is one of the mod's own items that the carry didn't declare
(its block had no vanilla template) loaded and crafted air. Vanilla refuses a
recipe for an unknown item; the carry now moves such recipes to outside/ and lists
them (recipes_unregistered), and check says "N recipes for left-out blocks".
Found by the mod browser's end-to-end run on Macaw's Fences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:00:23 +02:00
Ludovic MarieandClaude Opus 5.5 da996b9fd1 port-pack: check one mod jar (Works, Partly, Doesn't run) from its content and classes
`horizon-port-pack check <jar> <out>` carries a single jar into a folder content
mod and reads its class files (constant pool, superclass, declared methods) for
what the carry can't take: block entities, menus, entities, blocks and items with
behaviour of their own, recipe types, worldgen code, Mixins, game events. The
verdict and the lists of what ports and what is skipped go to check.json, for the
launcher's mod browser to show before install.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 18:00:23 +02:00
Ludovic Marie 022600481e Merge pull request #1236 from ludovic111/feat/microsoft-coming-soon
🤖 Agent: merged. Microsoft sign-in shows "Coming soon" while builds have no client ID (Ludovic, 2026-10-22). verify passes; launcher tests 96+14+1.
2026-10-22 17:58:51 +02:00
rdbtCVS fa14833b41 perf(client): retain seeded lightning bolt geometry 2026-10-22 16:56:23 +01:00
rdbtCVS e4ea9e0199 Cache weather column parameters and animate atlas UVs in shader 2026-10-22 16:54:09 +01:00
Ludovic MarieandClaude Opus 5.5 93bde90aaf Launcher: Microsoft sign-in says "Coming soon" while the build has no client ID
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:51:51 +02:00
rdbtCVS 92b8cd692d perf(client): retain the End sky mesh on the GPU 2026-10-22 16:50:29 +01:00
rdbtCVS 05400438c7 perf(client): cache weather column direction table 2026-10-22 16:44:32 +01:00
Ludovic MarieandClaude Opus 5.5 5b44452284 fix(protocol): read PARTICLES as a list; keep every particle's data
Review fixes on top of the particle serializers:

- PARTICLES (17) is List<ParticleOptions>: a VarInt count, then each
  particle. Reading it as one particle only worked for empty lists, so a
  living entity with effect particles (LivingEntity.DATA_EFFECT_PARTICLES)
  misread everything after it. Value::Particles holds the list.
- Item particles use ItemStackTemplate.STREAM_CODEC (item, count, patch),
  not the optional slot codec.
- Vibration keeps its position source (block position or entity id and
  y offset) so it writes back the bytes it read; geyser data keeps its
  Int exactly instead of going through f32.
- Enchantment names come from the server's synced registry, not the
  builtin report (enchantment is data-driven).
- Armor stands: 26.3's DEFAULT_DEPTH_CLEAR_VALUE is 0.0 (bytecode
  dconst_0), so a marker's empty box falls back to a range of 4 and a
  visible marker stand draws; restore that. Holograms stay hidden through
  the invisible flag.

Test: particles_round_trip covers every data shape, a PARTICLES list and
the entry after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:35:57 +02:00
Ludovic Marie 34c3c1cffe Merge pull request #1232 from ludovic111/feat/alpha-channel
🤖 Agent: merged. The fast channel is now "Alpha" (Ludovic, 2026-10-22); old "nightly" / beta_builds settings still read as Alpha. verify passes; launcher tests 96+14+1, release tools' Python tests 27, website build OK.
2026-10-22 17:25:47 +02:00
rdbtCVS 756cf4af07 bench: measure read-only saved chunk loading 2026-10-22 16:18:40 +01:00
rdbtCVS 0eaa13020c perf: load uniform saved light without expanding nibble layers 2026-10-22 16:18:40 +01:00
Ludovic MarieandClaude Sonnet 5.5 5eb224a909 Rename the Nightly update channel to Alpha
The whole of Horizon is in beta, so the channel that installs every build as
soon as it is built is called Alpha; Stable keeps its name. The launcher's
Settings, the update-channel command, the MCP tool, the release notes footer
and the docs say Alpha. update_channel is saved as "alpha"; "nightly" and the
old beta_builds switch still read as Alpha.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 17:17:33 +02:00
Ludovic Marie 15b6313292 Merge pull request #1226 from ludovic111/docs/releases-channel-posts
🤖 Agent: merged (docs only; verify passes).
2026-10-22 17:04:07 +02:00
rdbtCVS a648622b92 bench: report exact warmed generation wall timings 2026-10-22 15:46:40 +01:00
rdbtCVS 97edfec914 perf: reuse decoded aquifer neighborhoods during terrain fill 2026-10-22 15:46:40 +01:00
Ludovic Marie ca125cb028 Merge pull request #1227 from ludovic111/docs/website-current
🤖 Agent: merged (docs/website only, skip-release; verify passes).
2026-10-22 16:33:54 +02:00
Ludovic MarieandClaude Sonnet 5.5 fe5e20507a Website: docs match what ships (Mods page, shader packs page, dynamic lights, LAN workstations); the live ping is off
The Mods page (launcher 0.17) replaces "a mod browser is planned"; the Horizon
shader format is no longer called planned; Nightly-only things say Nightly.
The "I'm playing" ping has no server (DEFAULT_ENDPOINT is None), so the FAQ and
the privacy page say it is off instead of sent. The channel switch says it
needs the launcher release after 0.17. The README says what deploys the site.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 16:29:58 +02:00
Ludovic MarieandClaude Opus 5.5 41e6c3eca3 release-flow: #releases shows every Nightly silently, pings only for Stable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 16:23:59 +02:00
Ludovic MarieandClaude Opus 5.5 92d2e2b86d launcher: leave Java alone when the preparer finds one itself
With a JDK installed, the launcher passed the preparation the home of the
JDK horizon-prepare finds (javac/../..) as JAVA_HOME and on the PATH.
Found through the PATH, that is often a stub: on macOS /usr/bin/javac
gives JAVA_HOME=/usr, and `JAVA_HOME=/usr /usr/bin/javac -version` never
returns (the stub calls itself), so the preparer subprocess hung on a Mac
with Temurin installed. When horizon-prepare finds a JDK by itself, run
the preparation as before, in an unchanged environment; only Minecraft's
own Java (the official launcher's, or one downloaded from Mojang) is
passed on.

Also skip the Mojang download on x86-64 Linux without glibc's loader
(NixOS without nix-ld, musl distributions): Mojang's Java can't start
there, so say so instead of downloading 115 MB first. And keep each
runtime home once (dedup only dropped neighbours).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 16:20:42 +02:00
itspotatotimeandcairn-code ad676af5eb fix(protocol): decode particle entity-data serializers so later slots survive
Serializer 17 (particles) and 16 hit a catch-all that returned Ok(None),
which stopped EntityData::read from walking the rest of the metadata list.
On armor stands that carry a level-particle effect slot, slots 2 and 3
(CustomName text and visible flag) were silently discarded, so serverside
holograms (Gamster's social-media panels and leaderboards) rendered as
nothing.

Implement both serializers as value-preserving decodes: a ParticleData
enum covering every 26.3 particle shape (state varints for ids 1/2/36/121/125,
colours, dust, dust_color_transition, effect/instant, scalar, shriek, trail,
item, vibration and geyser variants) backed by a static minecraft:particle_type
id table, with write_value arms for round-trips.

Co-Authored-By: cairn-code (GLM-5.3-Flash Free) <[email protected]>
2026-10-22 17:15:16 +03:00
itspotatotimeandcairn-code 9f6d781782 fix(client): read empty-root component names through their extra spans
Co-Authored-By: cairn-code (GLM-5.3-Flash Free) <[email protected]>
2026-10-22 17:15:16 +03:00
itspotatotimeandcairn-code a20ebde7ab fix(client): carry every server item component into tooltips
to_stack kept only damage, dyed colour, map id and a head profile, so a server item's custom name, lore, enchantments and custom data never reached the inventory tooltip. Build the vanilla `components` compound from the decoded patch and run it through the same decoder saved item NBT uses, then show lore lines between the name and the details.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:15:16 +03:00
Claude 71edc9d41c Prepare with Minecraft's own Java 25 when no JDK is installed
On a Mac without a JDK, preparing failed with "Java 25 is needed ...
/usr/bin/javac -version printed no version: Unable to locate a Java
Runtime": /usr/bin/javac is macOS's placeholder. Mojang's Java runtimes
are whole JDKs, javac included (java-runtime-epsilon is 25.0.1 on every
platform), and the official launcher has already downloaded one for
anyone who played 26.3.

- horizon-prepare: java::find also tries Minecraft's runtimes
  (<game dir>/runtime/<component>/<platform>/<component>, jre.bundle on
  macOS) and the launcher's pick (Options::java_home), and names the
  macOS placeholder for what it is.
- launcher: when no JDK is found, downloads the runtime the version JSON
  names (javaVersion.component) from Mojang into its own game folder,
  every file checked against its sha1, executables and links as the
  official launcher lays them out. A version's own preparer binary or
  Python script gets it through JAVA_HOME and the PATH.

Checked for real on Linux: the download is 147 files, its javac 25.0.1
compiles and runs a class with --release 25.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AxbYUJt4ShJFCdyLNCxF21
2026-10-22 14:12:42 +00:00
itspotatotimeandcairn-code d01327862d fix(client): hide marker stands and read server entity names
A marker armor stand has an empty box, so vanilla's shouldRenderAtSqrDistance computes a zero range and the model never draws; Horizon substituted a 4-block range, which turned the stacked marker stands a lobby uses for holograms into a visible tower. Follow vanilla and let the zero edge cull the model. Server NPCs and mobs also carry CustomName, CustomNameVisible and the shared invisible flag in entity data; Horizon only read those for armor stands. Apply them to every mob, keep the styled component for the name tag, and hide the body when the flag is set.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:11:32 +03:00
itspotatotimeandcairn-code c139387e2a fix(client): read server map art and item profiles
A server lobby draws its signs as walls of framed maps and puts player heads on its NPCs. Horizon dropped every map_item_data packet, so framed maps stayed blank, and the item component decoder rejected attack_range (30) and profile (72), which made the whole equipment and container packets that carried them undecodable. Decode both components, carry a profile into the item data the head model reads, and apply server map patches to the map texture. The map pool now holds a wall of maps so the atlas does not re-upload every map every frame.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:11:32 +03:00
itspotatotimeandcairn-code a5e0645610 fix(client): draw armor stand custom names as name tags
Holograms are invisible armor stands carrying a CustomName. Armor stands parsed neither CustomName nor CustomNameVisible, and name tags came only from mobs, so a hologram drew nothing. Read both fields from NBT and entity data, keep them across a save, and put armor stands in the name tag pass at their bounding-box height even when their model is skipped.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:09:34 +03:00
itspotatotimeandcairn-code 399382084a fix(client): show server players' and NPCs' skins
The tab list resolved skins only for listed players and never handed them to the level's player models, so every player and NPC in a server world kept the default Steve or Alex texture. Resolve every profile's textures each frame and give each player entity its skin, including entities that are never in the tab list. Player heads read the minecraft:profile component with the insecure lookup vanilla uses, so unsigned textures from offline servers draw too.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-22 17:09:34 +03:00
rdbtCVS 753c071c37 docs: record early-stopped chunk optimization attempts 2026-10-22 15:09:08 +01:00
rdbtCVS 5d82b4c999 perf: load list palettes without unpacking discarded indices 2026-10-22 14:48:08 +01:00
rdbtCVS 4e46f2a57a bench: profile chunk generation and saved chunk loading 2026-10-22 14:48:08 +01:00
Claude e01296f99b Keep the pointer locked on macOS when the game captures it
winit's macOS set_cursor_position re-associates the mouse with the
pointer (CGAssociateMouseAndMouseCursorPosition(true)), so warping to
the window centre right after the Locked grab released it again and the
pointer moved freely in first person. Warp before grabbing on macOS;
Wayland still warps after, since it moves a pointer only while locked.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0188LkUPntcZdfMF3wAqR2tK
2026-10-22 13:48:04 +00:00
Ludovic MarieandClaude Opus 5.5 ac60b337aa worldgen: chunks far apart still make a region (map past the grid)
TerrainRegion::new refused a region whose chunks bounding box passed
2^20 columns, where it took any set of distinct chunks before: the
bulk_section parity fixture puts a chunk at x = 4194304 beside the
origin and failed with InvalidChunks. Keep the grid for the usual small
box and fall back to a map past it; both refuse a position twice. The
lookup now subtracts in i64, so no coordinate can wrap into the grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:40:07 +02:00
Ludovic MarieandClaude Opus 5.5 a49cf5502d geometry: floor_i32 saturates below i32::MIN, as (int) Math.floor
Vanilla 26.3 Mth.floor(double) is (int) Math.floor(v), so a value below
i32::MIN floors to i32::MIN. The truncate-and-step version wrapped to
i32::MAX there (block_collision_scan parity failed at the chunk of
x = -2147483648.8: Rust 134217727, vanilla -134217728). Step down with
saturating_sub; the fast path is unchanged, and the test now checks
the ends of the i32 range, infinities and f64::MIN/MAX against
f64::floor as i32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:37:37 +02:00
Ludovic Marie b88218d019 Merge pull request #1209 from ludovic111/feat/update-channels
🤖 Agent: merged. Nightly/Stable update channels (Ludovic's request 2026-10-22): Settings choice, nightly train 5/20 min, one stable promotion a day at 20:00 UTC, Nightly launchers check every 30 min. verify passes on b197682d (main merged in: Settings keeps update_channel and #1185's ping_notice_seen). Ships in [email protected]; launcher.yml stays disabled until #1132.
2026-10-22 15:28:00 +02:00
Ludovic Marie d5972490a5 Merge pull request #1193 from ludovic111/docs/modpacks-12-numbers
🤖 Agent: merged last in wave 2 (docs: modpacks-12 numbers), after the modpack PRs it describes.
2026-10-22 15:22:18 +02:00
Ludovic Marie 54f01d6bb1 Merge pull request #1192 from ludovic111/perf/linux-helpers-behind
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15): fmt, clippy, workspace + launcher tests, release build and every affected parity harness pass. main merged into the branch with the same resolution as the tested tree; verify passes on that head.
2026-10-22 15:21:27 +02:00
Ludovic Marie 44e289f919 Merge pull request #1188 from ludovic111/fix/lan-guest-workstations-8
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15): fmt, clippy, workspace + launcher tests, release build and every affected parity harness pass. main merged into the branch with the same resolution as the tested tree; verify passes on that head.
2026-10-22 15:21:15 +02:00
Ludovic MarieandClaude Opus 5.5 b197682dc8 Merge origin/main into feat/update-channels (Settings: keep update_channel and main's ping_notice_seen)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:19:31 +02:00
Ludovic MarieandClaude Opus 5.5 86e08072e4 streaming: bound ExtraCover's z range too
ExtraCover::new checked only x0/x1 against the i32 range, so two areas
near i32::MAX or i32::MIN in z built a grid whose row arithmetic
(cz + dz) overflows: a panic in debug builds, wrong cells in release.
Check z the same way, and test both ends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:14:26 +02:00
Ludovic MarieandClaude Opus 5.5 c4a12964dc Merge origin/main into perf/linux-helpers-behind (game.rs: helpers behind on every run, plus main's live-count ping)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:13:44 +02:00
Ludovic Marie 41055168af Merge pull request #1196 from ludovic111/perf/spawn-lists-borrowed
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:13:09 +02:00
Ludovic Marie 55eabf4b20 Merge pull request #1195 from ludovic111/perf/lan-entity-states
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:58 +02:00
Ludovic Marie 4332014673 Merge pull request #1187 from ludovic111/fix/world-spawn-one-worker
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:45 +02:00
Ludovic Marie f787bc9b75 Merge pull request #1186 from ludovic111/fix/secure-chat-leave-order
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:31 +02:00
Ludovic Marie 1a3fb08d68 Merge pull request #1185 from ludovic111/feat/live-player-count
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:17 +02:00
Ludovic Marie 2ae6ae2aeb Merge pull request #1184 from ludovic111/fix/dedicated-server-waits
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:12:04 +02:00
Ludovic Marie 90ff954f23 Merge pull request #1182 from ludovic111/fix/online-mode-log-order
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:11:37 +02:00
Ludovic Marie bdad451ab4 Merge pull request #1181 from ludovic111/perf/spread-chunk-packets
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:11:15 +02:00
Ludovic Marie ba1e7e23cf Merge pull request #1179 from ludovic111/perf/render-thread-ahead
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:11:03 +02:00
Ludovic Marie ec9d62858a Merge pull request #1178 from ludovic111/perf/span-cpu-time
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:51 +02:00
Ludovic Marie 631160263f Merge pull request #1177 from ludovic111/feat/formations-fountain
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:39 +02:00
Ludovic Marie badc91f94b Merge pull request #1176 from ludovic111/feat/ctov-compat-sky-view
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:27 +02:00
Ludovic Marie 554932e9fd Merge pull request #1175 from ludovic111/feat/mo-structures
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:15 +02:00
Ludovic Marie 98c77f6fc7 Merge pull request #1174 from ludovic111/fix/wayland-surface-format-test
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:10:03 +02:00
Ludovic Marie 6323b4a517 Merge pull request #1173 from ludovic111/fix/launcher-says-no-sounds
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:09:52 +02:00
Ludovic Marie 82122f4970 Merge pull request #1155 from ludovic111/fix/riptide-spin-attack
🤖 Agent: merged after integration wave 2 on the desktop (integ/w2 @ 38307d15 = main c16b41ab + this wave): fmt, clippy -D warnings, workspace + launcher + telemetry-server tests, release build and every affected parity harness pass; the 3 harness failures and the panel test passed on rerun (known flakes).
2026-10-22 15:08:32 +02:00
Ludovic MarieandClaude Opus 5.5 8859d7a3a3 Merge origin/main into fix/lan-guest-workstations-8 (tools/lan_e2e.py: keep both sides' checks)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:08:10 +02:00
Ludovic MarieandClaude Opus 5.5 d02b66fd9a Install page: say Nightly's 30-minute check under Stable and Nightly only
Keeps the Updating list as on main, so it merges cleanly with #1132.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:07:54 +02:00
Ludovic MarieandClaude Opus 5.5 aa65c74e0f Update channels: stable at 20:00 UTC, Nightly checks every 30 minutes
The daily promotion moves to 20:00 UTC (21:00 in Paris in winter time):
PROMOTE_AT and promote.yml's cron (20:07, retries until 21:37), with the docs
that give the time. A launcher left open on Nightly looks for new releases
every 30 minutes; Stable keeps 4 hours. The launcher README's Settings
screenshot shows the Updates choice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:57:50 +02:00
Ludovic MarieandClaude Opus 5.5 ea5994a58a Update channels: Stable (daily) and Nightly (every build) in the launcher
The launcher's "Beta builds" switch becomes an Updates choice of two channels,
stored as "update_channel": "stable" | "nightly" (an old "beta_builds": true
reads as nightly). The CLI (update-channel) and MCP (update_channel) show and
set it, and list each release's channel as stable or nightly.

Nightly ships constantly: the release train waits 5 minutes for a merge wave to
settle (20 at most) instead of 15 (an hour). Stable moves once a day:
promote.yml runs at 19:00 UTC (retries until 20:37) and promotes the newest beta
that had been out 6 hours by 19:00, so late runs and retries pick the same one.
The stalled-promotion issue waits 30 hours. Release notes, docs, the website's
install and FAQ pages say Stable and Nightly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:30:19 +02:00
Ludovic MarieandClaude Opus 5.5 8a03758c3a Load time: build a pack's content root and load its sprites and models in parallel
A content pack's first start rewrote 19 per-state tables one after another
and reopened each zip mod's archive for every file it copied (its whole
index each time). Every start then decoded the atlases' mod textures and
parsed the mods' block models one file at a time on the main thread: with
ATM10's 42 data mods that was about 70 % of the sampled startup.

- mod_content: the tables are rewritten on up to three threads, largest
  first; a zip mod's archive is opened once for all its files
  (horizon-mods' Content::reader).
- hud::atlas_sprites and ParsedModels::load list their files first, then
  decode and parse them on every core (par::par_map, results in the input
  order). The sprites come out in the order the one-by-one loop gave, and
  the content root is byte-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:47:03 +02:00
Claude 5bad717213 Perlin noise without libm floor or int-to-float conversions, same values
floor_i32 is now Java's Mth.floor (truncate, step down for negative
fractions): f64::floor is a libm call on baseline x86-64, made for every
Perlin sample. Perlin's gradient table is stored as floats (every
component is -1, 0 or 1, exact either way), so the corner dot products
skip three conversions each. About 10% fewer instructions generating
terrain; values unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01M3pNrSHA2ifNrH7SAMHgSW
2026-10-22 08:43:40 +00:00
Claude bc9e5afacc Faster feature decoration: region grid, ore column fast path, lazy biome cache
TerrainRegion indexes its chunks through a grid over their bounding box.
OreFeature asks the world whether any column under its blob reaches the
floor heightmap in one call, and tracks tested positions in a bitset.
BiomeTemperature no longer preallocates its map and queue. Same output.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01M3pNrSHA2ifNrH7SAMHgSW
2026-10-22 06:26:36 +00:00
Claude dfe795bc09 Faster Perlin volumes and climate search, same results
PerlinNoise::add_volume worked out each Y coordinate's cell, offset and
fade once per X/Z column. Every column walks the same Y coordinates, so
they are now computed once per volume. The arithmetic is unchanged, so
the output is bit for bit the same.

ClimateIndex kept its tree as nodes with a Vec of child ids each. The
built tree is now renumbered breadth first into flat arrays, so a
branch's children are one contiguous run. The search visits the same
nodes in the same order, so ties resolve exactly as before.

terrain_bench (seed 12345, 8x8 chunks, 1 thread, medians of 3 runs):
terrain 14.5 -> 12.4 ms/chunk, biomes 1.20 -> 1.06 ms/chunk.
Checked against vanilla 26.3 in-process: 2304 chunks on seeds 12345, -1
and 987654321987 identical, and the biome-source and biome-generation
parity suites (2.1M lines) match.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01M3pNrSHA2ifNrH7SAMHgSW
2026-10-22 02:42:52 +00:00
Claude 9019d708d0 Streaming: answer "near a player?" from a grid of the players' squares
On a server every player is an extra area, and streaming asks of every
loaded and wanted column, several times a tick (unloading, dropping
protos, finishing, decoration, the generation scan), whether one of
those squares reaches it: a scan of every player's square per question.
The squares are now one grid of distances, made again when a player
changes chunk, and in_extra reads it. Differential test against the scan
over 60 random layouts, every margin.

At 50 players, 6081 columns, 3 questions each: 0.45 ms -> 0.04 ms, plus
0.02 ms to make the grid.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:40:47 +00:00
Claude 186777110f Hoppers: skip the item scan when no item reaches the hopper's column
HopperBlockEntity.suckInItems scanned every item entity for each hopper
that ticked without a container above. During the block-entity pass the
game now keeps the set of block columns item boxes reach; a hopper whose
column is not in it has nothing to suck, and the scan (same order, same
item taken) runs only for the rest. Items do not move within the pass:
removals leave a superset, and additions (pushed at the end with a new
id) or removing the last item rebuild the set. It is dropped at the end
of the pass.

  500 hoppers,  200 items:  1.36 -> 0.05 ms per tick
  2000 hoppers, 500 items: 13.52 -> 0.26 ms per tick
  2000 hoppers, 2000 items: 60.6 -> 3.3 ms per tick

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:40:38 +00:00
Claude cb0bbe7d19 Sculk: skip the loaded-chunk diff while no chunk came or went
sculk_sync_index collected every loaded chunk key into a set and diffed
it against the known chunks every ten ticks, thousands of keys on a busy
server. It now remembers the chunk map's membership revision and skips
the diff while it is unchanged; a replaced chunk keeps its key and was
not rescanned before either.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:06 +00:00
Claude 863a309161 Allays and piglins: hoist per-tick lookups out of the per-mob loop
tick_allays scanned every block entity of the level for each allay,
every tick, to find a playing jukebox within 10 blocks. The playing
jukeboxes are now collected once, at the first allay (no allay starts
or stops one).

piglin_pickups looked up the piglin_loved tag and hashed every item
entity's id once per piglin. Each item's flag is now computed once and
kept until an item is added (always pushed at the end under a new id)
or removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:06 +00:00
Claude 3917f35e3e Natural spawning: answer mob box queries from a grid after a few scans
GameLevel's isUnobstructed, noCollision (living shulkers) and the
spawners' entity count each scanned every mob, computing each 1.5 KB
mob's box. A spawn pass holds the game immutably, so after eight
queries it builds a grid of the mobs' boxes once and reads candidates
from it; the first queries still scan, as the build costs a few scans.

  100 box tests, 2000 mobs: 3.48 -> 0.31 ms (grid build included)

Also gives the mob_ai_probe example the new others_index field.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude a893fa6125 Villagers: count the others' POI tickets once per search
pois_in_range (AcquirePoi and the job, bed and meeting point searches)
asked, for every candidate POI in range, how many tickets it had, and
each answer scanned every other mob for villagers holding it: POIs times
mobs per search. The others' tickets are now counted into a map at the
first candidate and read from it; this villager's own memories are
still checked per POI, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 3b21a975bb Mob goals: read nearby mobs from the tick's grid in four more places
FollowParentGoal (every baby, every tick), the pufferfish's puff check,
the enderman's block placing and the iron golem's DefendVillage scanned
every mob. They now read candidates from the position grid around the
area they test (one block wider where they test positions, as a box
whose bottom face holds the position meets it), in snapshot order, and
test exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 67ca65bb3b Mob tick: drop gone mobs' guest bindings with a set, not a scan per binding
On a server every ticked mob is bound to its nearest player, and each tick
the bindings of mobs that are gone were dropped by scanning every mob for
each binding. Mob is 1.5 KB, so the scan misses the cache on every mob:
with 1000 loaded mobs that was 0.44 ms a tick, with 2000 1.86 ms; a set of
the live ids makes it 0.02 and 0.04 ms (stand-in structs of Mob's size).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 6216b84669 Mob tick: build the mob world once a tick, index the snapshot
tick_creature_entities called mob_world_base for every mob. That rebuilt
the player view (its allies set scans every mob UUID) and, with any
scoreboard team, an owned copy of every mob's team, each a clone of the
team's player set: cubic in the mobs on a team. The base is now built
once and cloned per mob, with the teams borrowed behind an Arc.

  100 mobs, half on a team:  37.7 ms -> 0.06 ms per tick
  500 mobs, half on a team:  4297 ms -> 0.31 ms per tick

MobWorld::seen looked mobs up by scanning the snapshot; the mob ticks
now pass an id index (first slot wins, as the scan did): 2000 lookups
over 2000 mobs 1.09 -> 0.01 ms. The nearest-living-entity sensors, the
look-at goal, the ravager's roar and MobWorld::nearby read candidates
from the tick's position grid instead of every mob, in the same order:
2000 mobs 0.39 -> 0.11 ms per tick.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:10:05 +00:00
Claude 2ecbdd57eb Mob pushes: one list per mob once a crowd is pushing
Pushes between mobs wait in a list until their target ticks; each mob's
tick scanned the whole list, and take_mob_pushes removed each push from
the events in place, shifting the rest. In a pen where every mob pushes
its neighbours both grow with the square of the crowd per mob.

The pending pushes stay a list while short (64) and become one list per
mob past that, each in push order, so every velocity adds up the same
values in the same order. take_mob_pushes splits the events in one pass,
keeping the order of the others. A test checks velocities bit for bit
and the remaining events against the old code.

  30 mobs pushing each other:   0.11 -> 0.04 ms per tick
  100 mobs pushing each other:  1.73 -> 0.37 ms
  200 mobs pushing each other:  9.07 -> 2.61 ms

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:02:34 +00:00
Claude 62874d8dc4 Furnaces: look up the cooking recipe in an index, without cloning it
Every lit furnace, smoker and blast furnace called Recipes::cook each
tick, which scanned all cooking recipes (string compares and hash
lookups) and the furnace then cloned the recipe found, its ingredient's
item set included. Recipes now keeps, built at the first lookup, the
first recipe of each kind for each item, which is what the scan
returned; the furnace borrows it.

  1000 lookups over 320 recipes: 2.07 -> 0.08 ms

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:02:00 +00:00
Claude da314fa658 Server tick: cheaper view requeue and chunk ticket levels
Two costs that grow with players on the server thread:

- Hub::update_view rebuilt a player's chunk queue on every chunk crossing
  by scanning the queue (a Vec) once for each chunk of the view. It now
  checks a set. Same queue, same order (differential test against the old
  scan). 111 -> 16 us per crossing at view 10, 6.5 ms -> 0.13 ms at 32.
- ChunkTickets rebuilt a hash map of every ticketed chunk whenever any
  player changed chunk, and every block entity, mob and ticking chunk
  hashed into it. Levels are now a grid over the tickets' bounding box,
  with the map kept for tickets spread too far apart. At 50 players:
  build 0.23 -> 0.08 ms, lookups about 2x faster (differential test over
  200 random ticket layouts).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UxXpf7d4RgpespL2G2UsGE
2026-10-22 01:02:00 +00:00
Ludovic MarieandClaude Opus 5.5 6127790456 Switch releases to 0.2 (merge only when Ludovic says go)
.github/release-line.txt = 0.2: the release train's next runs publish v0.2.0 (a beta, as always)
and [email protected] at once, "the first release of 0.2" (docs/versioning.md). Merge it only after
the bridges (the last dated game release, made stable, and launcher-v0.17.0) are out: the go-day
steps are in docs/versioning.md.

Also, because they say 0.2 is out: the site's FAQ explains the numbers, Install says an old launcher
updates twice, hosting.md gives the one command for a server that says it is up to date, and the
launcher's Cargo.toml says 0.2.0 so builds from source (packages) are on the 0.2 line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:24:30 +02:00
371 changed files with 31354 additions and 7773 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
0.2
+4
View File
@@ -18,6 +18,10 @@ concurrency:
# A new push to a pull request replaces its running checks; main's runs all finish (releases follow them).
group: checks-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
verify:
runs-on: ubuntu-latest
+13 -32
View File
@@ -8,8 +8,7 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
@@ -50,6 +49,10 @@ concurrency:
group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }}
cancel-in-progress: false
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
decide:
runs-on: ubuntu-latest
@@ -102,13 +105,6 @@ jobs:
mac: true
asset: horizon-launcher-macos-arm64.zip
binary: horizon-launcher
mac_arch: arm64
- name: macos-x64
mac: true
target: x86_64-apple-darwin
asset: horizon-launcher-macos-x64.zip
binary: horizon-launcher
mac_arch: x64
- name: windows
os: windows-latest
asset: horizon-launcher-windows-x64.zip
@@ -121,8 +117,6 @@ jobs:
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
# variable, not a secret. Unset, the launcher says sign-in is not configured.
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
@@ -146,8 +140,6 @@ jobs:
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -168,17 +160,17 @@ jobs:
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
out=$(target/release/${{ matrix.binary }} --version)
echo "$out"
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
- name: Only system libraries
run: |
file="$BIN/${{ matrix.binary }}"
file=target/release/${{ matrix.binary }}
case "$RUNNER_OS" in
Windows) python ../tools/check_imports.py "$file" ;;
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
@@ -193,24 +185,13 @@ jobs:
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
rm -rf "$HORIZON_LAUNCHER_HOME"
launcher="$PWD/$BIN/${{ matrix.binary }}"
launcher="$PWD/target/release/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
retry "$launcher" versions
"$launcher" links --json
"$launcher" menu-style classic
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
"$launcher" menu-style horizon
# A new platform's launcher can come out before any stable game release has its build (Intel Macs, #1161):
# then there is nothing to install yet.
stable_build='import json, sys; sys.exit(not any(r["available_here"] and r["channel"] == "stable" for r in json.load(sys.stdin)))'
if ! retry "$launcher" --json versions | ${{ runner.os == 'Windows' && 'python' || 'python3' }} -c "$stable_build"; then
echo "::warning::No stable game release has a build for ${{ matrix.name }} yet: install not tested"
rm -rf "$HORIZON_LAUNCHER_HOME"
exit 0
fi
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
@@ -241,11 +222,11 @@ jobs:
# Developer ID): packaging/macos.
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
rm -rf out/Horizon.app
else
mkdir -p dist
cp "$BIN/${{ matrix.binary }}" dist/
cp target/release/${{ matrix.binary }} dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
else
+4
View File
@@ -23,6 +23,10 @@ permissions:
concurrency:
group: nightly-audit
cancel-in-progress: false
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
audit:
runs-on: [self-hosted, horizon-game]
+4
View File
@@ -15,6 +15,10 @@ on:
required: true
permissions:
contents: read
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity:
runs-on: [self-hosted, horizon-game]
+12 -9
View File
@@ -1,17 +1,20 @@
name: promote
# Beta game releases become stable (docs/release-flow.md, "Channels"): every 30 minutes tools/release_channels.py
# promotes the newest beta that has been out 6 hours, passed its release smoke test and isn't covered by an issue
# labelled release-blocker. By hand, from GitHub's app too (Actions > promote > Run workflow):
# gh workflow run promote -f tag=v2026.10.19.03 # that beta now, without the wait
# gh workflow run promote -f tag=v2026.10.19.03 -f force=true # even blocked or without its smoke test
# gh workflow run promote -f dry_run=true # say what would be promoted
# Beta game releases become stable once a day (docs/release-flow.md, "Channels"): at 20:00 UTC (21:00 in Paris in
# winter time, 22:00 in summer) tools/release_channels.py promotes the newest beta that had been out 6 hours by then,
# passed its release smoke test and isn't covered by an issue labelled release-blocker. Launchers on the Alpha
# channel install every beta when it is published; Stable gets the day's promotion. The later runs of the evening are
# retries for a run GitHub starts late or skips: they promote the same beta, so stable still moves once.
# By hand, from GitHub's app too (Actions > promote > Run workflow):
# gh workflow run promote -f tag=v0.2.3 # that beta now, without waiting for 20:00
# gh workflow run promote -f tag=v0.2.3 -f force=true # even blocked or without its smoke test
# gh workflow run promote -f dry_run=true # say what would be promoted
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
# When the oldest waiting beta is 24 hours old, an issue "Promotion to stable has stalled" says so; it closes itself
# after the next promotion.
# When the oldest waiting beta is 30 hours old (it missed a promotion it was due for), an issue "Promotion to stable
# has stalled" says so; it closes itself after the next promotion.
on:
schedule:
- cron: '13,43 * * * *'
- cron: '7,37 20-21 * * *'
workflow_dispatch:
inputs:
tag:
+18 -30
View File
@@ -1,15 +1,14 @@
name: release
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
# a release when pull requests merged since the last one changed what players run and the newest merged 15 minutes
# ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now.
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub): Apple Silicon, and
# Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161), then
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
# stable after 6 hours. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
# stable in its daily promotion. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
# (packaging/macos/README.md); unset (forks), ad hoc.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
@@ -99,13 +98,6 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
# programs under Rosetta 2.
- name: macos-x64
mac: true
target: x86_64-apple-darwin
dir: horizon-macos-x64
asset: horizon-macos-x64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
@@ -114,8 +106,6 @@ jobs:
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
defaults:
run:
shell: bash
@@ -130,8 +120,6 @@ jobs:
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -140,11 +128,11 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
@@ -159,15 +147,15 @@ jobs:
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
codesign --verify --strict --verbose=2 "$BIN/$binary"
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="$BIN/$binary${{ matrix.exe }}"
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
@@ -178,7 +166,7 @@ jobs:
- name: Every program starts
run: |
set -euo pipefail
bin=$BIN
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
@@ -202,7 +190,7 @@ jobs:
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/$BIN/horizon-client"
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
@@ -217,9 +205,8 @@ jobs:
EOF
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
# One smoke-test.json per release, the Apple Silicon build's; the Intel build must pass its own run too.
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted' && !matrix.target
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
@@ -228,7 +215,7 @@ jobs:
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
@@ -312,7 +299,7 @@ jobs:
TAG: ${{ needs.decide.outputs.tag }}
run: |
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -362,11 +349,12 @@ jobs:
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
# A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md).
# A beta (pre-release), the Alpha channel's: promote.yml makes it stable and latest in its daily promotion
# (docs/release-flow.md).
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip \
dist/horizon-server-linux-x86_64.tar.gz dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
+4
View File
@@ -11,6 +11,10 @@ permissions:
concurrency:
group: run-parity-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
HORIZON_TELEMETRY_URL: "off"
jobs:
parity:
if: github.event.label.name == 'run-parity'
Generated
+1
View File
@@ -1395,6 +1395,7 @@ dependencies = [
name = "horizon-bench"
version = "0.1.0"
dependencies = [
"horizon-telemetry",
"serde",
"serde_json",
]
+5 -5
View File
@@ -28,9 +28,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon (Intel Macs from the next release) or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or a Mac on macOS 11 or later. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -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, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -56,8 +56,8 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
<td colspan="2"><img src="docs/media/sunset.webp" alt="Sunset over a savanna river valley and a plains village"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/menu-title.webp" alt="Horizon's title screen over a veiled snowy landscape"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></td>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
+12
View File
@@ -36,3 +36,15 @@ SOFTWARE.
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1.1
(`crates/horizon-theme/fonts/OFL-PixelifySans.txt`).
## Pixelarticons
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
+2
View File
@@ -8,3 +8,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
+51 -19
View File
@@ -16,8 +16,22 @@ pub struct Hardware {
pub cpu: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_threads: Option<usize>,
/// Physical cores.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_cores: Option<u16>,
/// Base and top boost clock, where the OS gives them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_base_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_boost_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ram_mb: Option<u64>,
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
/// `laptop` or `desktop`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub form: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<Gpu>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -33,6 +47,9 @@ pub struct Gpu {
pub backend: String,
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
pub kind: String,
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vram_mb: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -47,17 +64,50 @@ pub struct Display {
impl Hardware {
pub fn detect() -> Hardware {
let probes = probes();
Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
display: None,
}
}
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
Hardware {
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
..Hardware::detect()
}
}
}
/// What the telemetry probes read (`horizon_telemetry::system`).
struct Probes {
cores: Option<u16>,
base: Option<u32>,
boost: Option<u32>,
form: Option<String>,
}
fn probes() -> Probes {
let s = horizon_telemetry::payload::System::detect();
let some = |n: u32| (n > 0).then_some(n);
Probes {
cores: (s.cores > 0).then_some(s.cores),
base: some(s.cpu_base_mhz),
boost: some(s.cpu_boost_mhz),
form: (!s.form.is_empty()).then_some(s.form),
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
@@ -82,25 +132,7 @@ pub fn load_average() -> Option<f64> {
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
if cfg!(target_os = "macos") {
let batt = output("pmset", &["-g", "batt"])?;
return Some(if batt.contains("'Battery Power'") {
"battery"
} else {
"ac"
});
}
let mut on_battery = None;
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
let read =
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
match read("type").trim() {
"Mains" if read("online").trim() == "1" => return Some("ac"),
"Battery" => on_battery = Some("battery"),
_ => {}
}
}
on_battery
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
@@ -1,7 +1,7 @@
//! Firework tooltip companion for tools/test_firework_tooltip_parity.py: the
//! lines `item_tooltip::firework_lines` gives each stack (SNBT, one per line;
//! the `#resources` line names the prepared resources for en_us).
use horizon_client::{item_data::parse_stack, item_tooltip::firework_lines, lang::Language};
use horizon_client::{enchant::Enchantments, item_data::parse_stack, item_tooltip::firework_lines};
use std::{error::Error, fmt::Write, path::PathBuf};
fn main() -> Result<(), Box<dyn Error>> {
@@ -12,8 +12,9 @@ fn main() -> Result<(), Box<dyn Error>> {
.find_map(|l| l.strip_prefix("#resources "))
.map(PathBuf::from)
.ok_or("no #resources line")?;
let language = Language::load(&[&root], "en_us");
let translate = |key: &str| language.get(key).map(str::to_owned);
// Use the inventory's filtered translations so missing tooltip keys are caught.
let enchantments = Enchantments::load(&root);
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
let mut output = String::new();
for line in input.lines().filter(|l| !l.starts_with('#')) {
let stack = parse_stack(line)?.ok_or("empty stack")?;
@@ -14,13 +14,22 @@ fn main() {
for count in [0, 1, 4, 64] {
let mut slot = Some(ItemStack::new("minecraft:stone", count));
let mut dropped = 0;
let mut swings = 0;
let mut packets = 0;
for _ in 0..2 {
if let Some(stack) =
take_held_drop(&mut slot, all, mode == GameMode::Spectator)
{
dropped += stack.count;
swings += 1;
}
println!("{} {}", slot.as_ref().map_or(0, |s| s.count), dropped);
if mode != GameMode::Spectator {
packets += 1;
}
println!(
"{} {dropped} {swings} {packets}",
slot.as_ref().map_or(0, |s| s.count),
);
}
}
}
@@ -0,0 +1,153 @@
//! Read-only saved chunk benchmark. All timings exclude resource/corpus setup.
//!
//! load_bench RESOURCES DIMENSION_DIR [--rounds N] [--limit N] [--checksum]
//! Add --profile-worker when a sampling collector cannot sample the main thread.
//! Reports warm filesystem SaveDir::load (read, inflate, NBT, decode and light),
//! plus isolated inflate/NBT and native decode. No files in the save are changed.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
save::{SaveDir, SaveTables, decode_with},
};
use horizon_world::{nbt::Limits, region::RegionFile};
use sha2::{Digest, Sha256};
use std::{hint::black_box, path::PathBuf, sync::Arc, time::Instant};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
if std::env::args().any(|a| a == "--profile-worker") {
std::thread::spawn(|| run().map_err(|e| e.to_string()))
.join()
.expect("benchmark worker")
.map_err(Into::into)
} else {
run()
}
}
fn run() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let usage = "load_bench RESOURCES DIMENSION_DIR [--rounds N] [--limit N] [--checksum]";
let root = PathBuf::from(args.first().ok_or(usage)?);
let dimension = PathBuf::from(args.get(1).ok_or(usage)?);
let flag = |name: &str, default: usize| -> Result<usize> {
Ok(match args.iter().position(|a| a == name) {
Some(i) => args.get(i + 1).ok_or(usage)?.parse()?,
None => default,
})
};
let rounds = flag("--rounds", 5)?;
let limit = flag("--limit", 256)?;
if rounds == 0 || limit == 0 {
return Err("rounds and limit must be positive".into());
}
let assets = Assets::load(&root)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let biomes = Biomes::load(&root)?;
let tables = Arc::new(SaveTables::new(&assets).with_block_entities(&facts));
let source = SaveDir::new(dimension.clone());
let _ = source.tables.set(tables.clone());
let mut paths: Vec<_> = std::fs::read_dir(dimension.join("region"))?
.map(|e| e.map(|e| e.path()))
.collect::<std::io::Result<_>>()?;
paths.retain(|p| p.extension().is_some_and(|e| e == "mca"));
paths.sort();
let mut corpus = Vec::new();
'regions: for path in paths {
let mut region = RegionFile::open(path)?;
let mut positions: Vec<_> = region.chunks().map(|(pos, _)| pos).collect();
positions.sort_by_key(|p| (p.x, p.z));
for pos in positions {
let record = region.read(pos)?.ok_or("missing record")?;
let tag = record.nbt(Limits::default())?;
if decode_with(&tag, assets.registry, &biomes, Some(&tables)).is_none() {
continue;
}
corpus.push((pos, record, tag));
if corpus.len() == limit {
break 'regions;
}
}
}
if corpus.is_empty() {
return Err("no full chunks in corpus".into());
}
let mut records_hash = Sha256::new();
for (pos, record, _) in &corpus {
records_hash.update(pos.x.to_le_bytes());
records_hash.update(pos.z.to_le_bytes());
records_hash.update((record.compressed.len() as u64).to_le_bytes());
records_hash.update(&record.compressed);
}
println!(
"{}",
serde_json::json!({"corpus": dimension, "chunks": corpus.len(), "rounds": rounds,
"compressed_records_sha256": format!("{:x}", records_hash.finalize()),
"compressed_bytes": corpus.iter().map(|(_, r, _)| r.compressed.len()).sum::<usize>()})
);
// Prime SaveDir's region handles and the filesystem cache before measurement.
for (pos, _, _) in &corpus {
black_box(
source
.load(pos.x, pos.z, assets.registry, &biomes)
.ok_or("load failed")?,
);
}
for round in 0..rounds {
let started = Instant::now();
for (pos, _, _) in &corpus {
black_box(
source
.load(pos.x, pos.z, assets.registry, &biomes)
.ok_or("load failed")?,
);
}
let load_s = started.elapsed().as_secs_f64();
let started = Instant::now();
for (_, record, _) in &corpus {
black_box(record.nbt(Limits::default())?);
}
let nbt_s = started.elapsed().as_secs_f64();
let started = Instant::now();
for (_, _, tag) in &corpus {
black_box(
decode_with(tag, assets.registry, &biomes, Some(&tables)).ok_or("decode failed")?,
);
}
let decode_s = started.elapsed().as_secs_f64();
println!(
"{}",
serde_json::json!({"round": round, "load_s": load_s,
"inflate_nbt_s": nbt_s, "decode_s": decode_s})
);
}
if args.iter().any(|a| a == "--checksum") {
let mut hash = Sha256::new();
let mut light_loaded = 0;
for (pos, _, _) in &corpus {
let chunk = source
.load(pos.x, pos.z, assets.registry, &biomes)
.ok_or("load failed")?;
hash.update(pos.x.to_le_bytes());
hash.update(pos.z.to_le_bytes());
light_loaded += usize::from(chunk.light_loaded);
for section in &chunk.sections {
for i in 0..4096 {
hash.update(section.get(i).to_le_bytes());
hash.update([section.light(i)]);
}
for i in 0..64 {
hash.update(section.biomes.get(i).to_le_bytes());
}
}
}
println!(
"{}",
serde_json::json!({"blocks_biomes_light_sha256": format!("{:x}", hash.finalize()),
"light_loaded": light_loaded})
);
}
Ok(())
}
@@ -1027,6 +1027,7 @@ fn main() -> horizon_client::Result<()> {
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
others: $snapshot,
others_grid: $grid,
others_index: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
game_time: world.game_time,
day_time: world.day_time,
@@ -1132,7 +1133,7 @@ fn main() -> horizon_client::Result<()> {
}
#[allow(unused_mut)] // filled only with the rng-trace feature
let mut traces: Vec<String> = Vec::new();
let mut pushes = Vec::new();
let mut pushes = horizon_client::mobs::PendingPushes::default();
let mut born = Vec::new();
for i in 0..mobs.len() {
if mobs[i].removed {
@@ -3,6 +3,7 @@
//! what the render thread pays for it in `Streaming::update`.
//!
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
//! [--dimension overworld|the_nether|the_end] [--center-x X] [--center-z Z]
//!
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
//! at that speed for T seconds while frames run at F per second. Each phase reports
@@ -72,14 +73,33 @@ static ALLOCATOR: CountingAllocator = CountingAllocator;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn cpu_seconds() -> f64 {
std::fs::read_to_string("/proc/self/stat")
.ok()
.and_then(|stat| {
let fields = stat
.rsplit_once(')')?
.1
.split_whitespace()
.collect::<Vec<_>>();
Some((fields[11].parse::<f64>().ok()? + fields[12].parse::<f64>().ok()?) / 100.0)
})
.unwrap_or(0.0)
}
#[derive(Default)]
struct Phase {
updates: Vec<f64>,
local: (u64, u64),
uploads: usize,
stage_samples: [u64; 7],
cpu_start: f64,
}
impl Phase {
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
if self.updates.is_empty() {
self.cpu_start = cpu_seconds();
}
let before = LOCAL.with(Cell::get);
let started = Instant::now();
let (uploads, _) = streaming.update(world);
@@ -89,6 +109,9 @@ impl Phase {
self.local.1 += after.1 - before.1;
self.uploads += uploads.len();
drop(uploads);
for (sum, (_, count)) in self.stage_samples.iter_mut().zip(streaming.stages()) {
*sum += count as u64;
}
}
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
let mut sorted = self.updates.clone();
@@ -106,6 +129,16 @@ impl Phase {
at(0.99),
sorted.last().copied().unwrap_or(0.0),
);
println!(
"BENCH {}",
serde_json::json!({
"phase": name, "wall_s": wall.as_secs_f64(),
"cpu_s": cpu_seconds() - self.cpu_start,
"chunks": streaming.generated_total - generated, "sections": self.uploads,
"updates": self.updates.len(), "update_ms": total,
"mean_stages": self.stage_samples.map(|n| n as f64 / self.updates.len() as f64),
})
);
if TRACK {
println!(
" update allocations: {} ({:.1} MiB)",
@@ -142,6 +175,22 @@ fn main() -> Result<()> {
let fly = flag("--fly", 0.0);
let seconds = flag("--seconds", 10.0);
let hz = flag("--hz", 240.0);
let dimension = args
.iter()
.position(|a| a == "--dimension")
.and_then(|i| args.get(i + 1))
.map(|d| {
if d.contains(':') {
d.clone()
} else {
format!("minecraft:{d}")
}
})
.unwrap_or_else(|| "minecraft:overworld".into());
let center = (
flag("--center-x", 0.0) as i32,
flag("--center-z", 0.0) as i32,
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
@@ -154,11 +203,12 @@ fn main() -> Result<()> {
models::load_visibility(&root, assets.registry)?,
)?);
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
let terrain = Arc::new(VanillaTerrain::new(
let terrain = Arc::new(VanillaTerrain::for_dimension(
&root.join("worldgen"),
assets.registry,
&biomes,
seed,
&dimension,
)?);
let context = Arc::new(WorkerContext {
assets,
@@ -170,8 +220,12 @@ fn main() -> Result<()> {
let mut world = World::new(facts.air, seed);
let mut streaming = Streaming::new(terrain, context, threads);
streaming.radius = radius;
streaming.center = (0, 0);
println!("{threads} workers, radius {radius}, seed {seed}");
streaming.center = center;
streaming.nether_shading = dimension == "minecraft:the_nether";
streaming.sky_light = dimension != "minecraft:the_nether";
println!(
"{threads} workers, radius {radius}, seed {seed}, dimension {dimension}, center {center:?}"
);
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
// Fill: update as often as the game would uncapped, until settled.
@@ -198,12 +252,12 @@ fn main() -> Result<()> {
let mut behind = Vec::new();
while started.elapsed().as_secs_f64() < seconds {
let next = Instant::now() + frame;
let x = (started.elapsed().as_secs_f64() * fly) as i32;
streaming.center = (x, 0);
let x = center.0 + (started.elapsed().as_secs_f64() * fly) as i32;
streaming.center = (x, center.1);
phase.update(&mut streaming, &mut world);
// Columns within 2 chunks of the player still missing their meshes.
let near = (-2..=2)
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, center.1 + dz)))
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
.count();
behind.push(near);
@@ -11,6 +11,7 @@ fn bits(value: f32) -> String {
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let mut output = String::new();
let mut parameters = weather::ParameterCache::default();
for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() {
let p: Vec<&str> = line.split_whitespace().collect();
let int = |i: usize| p[i].parse::<i32>().unwrap();
@@ -38,6 +39,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
bits(column.v_offset),
column.light_coords
)?;
assert_eq!(
parameters
.get(x, z)
.column(kind != 0, ticks, x, bottom, top, z, light, partial),
column,
"cached parameters differ from original random source at case {case}"
);
let max_alpha = if kind == 0 { 1.0 } else { 0.8 };
for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) {
write!(
+19
View File
@@ -140,7 +140,22 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
driver: text(&renderer["driver"]),
backend: text(&renderer["backend"]).to_lowercase(),
kind: text(&renderer["device_type"]),
vram_mb: None,
});
if let Some(gpu) = report.hardware.gpu.as_mut() {
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
let vendor = if gpu.vendor.is_empty() {
vendor
} else {
gpu.vendor.as_str()
}
.to_owned();
if gpu.kind.contains("iscrete") {
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
}
}
report.hardware.storage =
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
replace_world(&setup.game_dir)?;
@@ -224,6 +239,10 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let status = control.status();
let renderer = &status["renderer"];
// On Wayland the window is on a screen only once it has been shown: ask again.
if report.hardware.display.is_none() {
report.hardware.display = serde_json::from_value(status["display"].clone()).ok();
}
let c = &mut report.conditions;
c.scenario = Some(setup.scenario.clone());
c.seed = Some(SEED.to_string());
@@ -32,16 +32,18 @@ pub enum Group {
Move,
Act,
Screens,
Multiplayer,
World,
Scripting,
}
impl Group {
pub const ALL: [Group; 7] = [
pub const ALL: [Group; 8] = [
Group::Session,
Group::Observe,
Group::Move,
Group::Act,
Group::Screens,
Group::Multiplayer,
Group::World,
Group::Scripting,
];
@@ -52,6 +54,7 @@ impl Group {
Group::Move => "Move and look",
Group::Act => "Act on the world",
Group::Screens => "Screens, menus and chat",
Group::Multiplayer => "Multiplayer, LAN and friends",
Group::World => "World and cheats",
Group::Scripting => "Scripting and benchmarks",
}
@@ -117,6 +120,50 @@ const OVER: Param = flag(
"turn with mouse motion spread over this many seconds (eased), as a player would",
);
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
const BOX: [Param; 6] = [
p("x1", Ty::Int, "corner X"),
p("y1", Ty::Int, "corner Y"),
p("z1", Ty::Int, "corner Z"),
p("x2", Ty::Int, "opposite X"),
p("y2", Ty::Int, "opposite Y"),
p("z2", Ty::Int, "opposite Z"),
];
const PAGES: Ty = Ty::Enum(&[
"main",
"video",
"sound",
"controls",
"keybinds",
"controller",
"controller-binds",
"mouse",
"chat",
"accessibility",
"language",
"font",
"packs",
"mods",
"graphics",
"graphics-advanced",
"extras",
"horizon",
"shader-packs",
"agent",
]);
/// `--confirm`, required by destructive commands: the player agreed.
const CONFIRM: Param = Param {
name: "confirm",
ty: Ty::Bool,
required: true,
flag: true,
help: "true once the player agreed: it cannot be undone",
};
/// MCP tools that delete or overwrite the player's things (`destructiveHint`).
pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
/// MCP tools that reach other players or the network (`openWorldHint`): only when the player asks.
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
macro_rules! cmd {
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
@@ -164,7 +211,19 @@ pub const COMMANDS: &[Cmd] = &[
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
mcp: true, local: true),
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
"", ["horizon-ctl status"], &[], mcp: false, local: false),
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
"Needs the title screen (no world open).", ["horizon-ctl world-rename Test \"My Base\""],
&[p("folder", Ty::Str, "world folder from worlds"), p("name", Ty::Rest, "the new name")], mcp: true, local: false),
cmd!("world-backup", Session, "world-backup FOLDER", "zip a world into backups/, as Edit World's Make Backup does",
"Needs the title screen. Replies once the zip is written, with its size.", ["horizon-ctl world-backup Test"],
&[p("folder", Ty::Str, "world folder from worlds")], mcp: true, local: false),
cmd!("world-delete", Session, "world-delete FOLDER --confirm", "delete a world for good, as Select World's Delete does",
"Needs the title screen and --confirm: ask the player first, it cannot be undone (world-backup keeps a copy).",
["horizon-ctl world-delete Test --confirm"],
&[p("folder", Ty::Str, "world folder from worlds"), CONFIRM], mcp: true, local: false),
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
@@ -190,10 +249,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box",
"At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.",
["horizon-ctl light 0 64 0 15 70 15"],
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
&BOX, mcp: true, local: false),
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
&BOX, mcp: true, local: false),
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
@@ -264,12 +323,15 @@ pub const COMMANDS: &[Cmd] = &[
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
// Screens
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
@@ -317,7 +379,31 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
cmd!("options", Screens, "options [PAGE]", "open the options screen, or one of its pages (Horizon's too)",
"Then ui lists its widgets and click, set and type change them, as with the mouse. options-set changes an options.txt value without the screen.",
["horizon-ctl options video", "horizon-ctl options horizon"], &[opt("page", PAGES, "the page (default: the main options)")], mcp: true, local: false),
cmd!("book", Screens, "book [next|back|take|done]", "the open book or lectern: its page text, or press one of its buttons",
"", ["horizon-ctl book", "horizon-ctl book next"], &[opt("action", Ty::Enum(&["next", "back", "take", "done"]), "a button to press")], mcp: true, local: false),
cmd!("signedit", Screens, "signedit X Y Z [front|back]", "open the sign editor of the sign at X Y Z (then type and close)",
"", ["horizon-ctl signedit 3 64 1"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("side", Ty::Enum(&["front", "back"]), "which side")], mcp: true, local: false),
cmd!("mount-menu", Screens, "mount-menu", "open the inventory of the horse, donkey, mule or llama being ridden",
"As the inventory key does while riding; also opens the nearest one's.", ["horizon-ctl mount-menu"], &[], mcp: true, local: false),
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
// Multiplayer
cmd!("connect", Multiplayer, "connect ADDRESS [NAME]", "join a multiplayer server, or a friend's world by its invite code, from the title screen",
"ADDRESS is HOST[:PORT], or friend:CODE for an invite code. Only when the player asks: it reaches the network.",
["horizon-ctl connect play.example.net", "horizon-ctl connect localhost:25565", "horizon-ctl connect friend:0B33K-Q405Q-..."],
&[p("address", Ty::Str, "HOST[:PORT] or friend:CODE"), opt("name", Ty::Str, "player name when offline")], mcp: true, local: false),
cmd!("disconnect", Multiplayer, "disconnect", "leave the server, back to the server list", "", ["horizon-ctl disconnect"], &[], mcp: true, local: false),
cmd!("servers", Multiplayer, "servers", "the Multiplayer screen's server list: name and address",
"Add, edit and remove them on the Multiplayer screen (click-button Multiplayer, then ui).", ["horizon-ctl servers"], &[], mcp: true, local: false),
cmd!("lan", Multiplayer, "lan open [PORT] [commands] [force] | lan close | lan status", "open the world to LAN (Open to LAN), stop, or see the guests",
"PORT 0 or none picks one; commands lets guests use cheats; force forces the game mode. Only when the player asks.",
["horizon-ctl lan open", "horizon-ctl lan status", "horizon-ctl lan close"],
&[p("action", Ty::Enum(&["open", "close", "status"]), "what to do"), opt("port", Ty::Int, "port for open"), opt("commands", Ty::Bool, "allow cheats"), opt("force", Ty::Bool, "force the game mode")], mcp: true, local: false),
cmd!("friends", Multiplayer, "friends host|stop|status", "Play with Friends: share the open world behind an invite code, stop, or see who joined",
"status gives the code: share it only when the player asks. Friends join with connect friend:CODE.",
["horizon-ctl friends host", "horizon-ctl friends status"], &[p("action", Ty::Enum(&["host", "stop", "status"]), "what to do")], mcp: true, local: false),
// World
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
@@ -325,8 +411,20 @@ pub const COMMANDS: &[Cmd] = &[
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob (in front of the player without a position)", "Replies with its id.", ["horizon-ctl summon cow", "horizon-ctl summon pig 10 64 3 baby"],
&[p("kind", Ty::Str, "mob type"), opt("x", Ty::Num, "X"), opt("y", Ty::Num, "Y"), opt("z", Ty::Num, "Z"), opt("baby", Ty::Bool, "a baby")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect, or clear them all", "", ["horizon-ctl effect speed 30 1", "horizon-ctl effect clear"],
&[p("id", Ty::Str, "effect id such as speed, or clear"), opt("seconds", Ty::Num, "duration"), opt("amplifier", Ty::Int, "level - 1 (default 0)")], mcp: true, local: false),
cmd!("effects", World, "effects", "the player's active status effects", "", ["horizon-ctl effects"], &[], mcp: true, local: false),
cmd!("xp", World, "xp LEVELS", "add experience levels (negative removes)", "", ["horizon-ctl xp 30"], &[p("levels", Ty::Int, "levels to add")], mcp: true, local: false),
cmd!("enchant", World, "enchant ID LEVEL", "enchant the held item (a book stores it)", "", ["horizon-ctl enchant sharpness 5"],
&[p("id", Ty::Str, "enchantment id"), p("level", Ty::Int, "level")], mcp: true, local: false),
cmd!("potion", World, "potion ITEM POTION", "give a potion item", "", ["horizon-ctl potion splash_potion healing"],
&[p("item", Ty::Enum(&["potion", "splash_potion", "lingering_potion", "tipped_arrow"]), "which item"), p("potion", Ty::Str, "potion id")], mcp: true, local: false),
cmd!("fill", World, "fill X1 Y1 Z1 X2 Y2 Z2 BLOCK", "set a box of loaded blocks", "", ["horizon-ctl fill 0 64 0 4 64 4 glass"],
&[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!("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.",
@@ -340,8 +438,8 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
["horizon-ctl mcp"], &[], mcp: false, local: true),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
@@ -366,8 +464,9 @@ pub fn overview() -> String {
}
out.push_str(
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
success, 1 when the game replied with an error, 2 for a usage error, 3 when the\n\
game cannot be reached or started and 4 while No AI is on (the launcher's settings).\n\
Actions (goto, break, place...) block until done.\n\
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
@@ -402,6 +501,73 @@ pub fn detail(c: &Cmd) -> String {
out
}
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
pub fn markdown() -> String {
let cell = |s: &str| s.replace('|', "\\|");
let mut out = String::from(
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
# horizon-ctl commands\n\n\
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
arguments. Positional arguments come first, in order; `--flags` after them. \
`horizon-ctl help --game` lists the game's own commands, test hooks included. \
Design rules and the full inventory: [agent-control.md](agent-control.md).\n",
);
for group in Group::ALL {
out.push_str(&format!(
"\n## {}\n\n| Command | MCP tool | What it does | Arguments |\n| --- | --- | --- | --- |\n",
group.title()
));
for c in COMMANDS.iter().filter(|c| c.group == group) {
let tool = if c.mcp {
let mut t = format!("`{}`", tool_name(c));
if DESTRUCTIVE.contains(&c.name) {
t.push_str(" (destructive)");
} else if OPEN_WORLD.contains(&c.name) {
t.push_str(" (network)");
}
t
} else {
"CLI only".to_owned()
};
let args: Vec<String> = c
.params
.iter()
.map(|p| {
let ty = match p.ty {
Ty::Int => "integer".to_owned(),
Ty::Num => "number".to_owned(),
Ty::Str => "string".to_owned(),
Ty::Rest => "text".to_owned(),
Ty::Bool => "boolean".to_owned(),
Ty::Enum(v) => v.join(", "),
};
let name = if p.flag {
format!("--{}", p.name)
} else {
p.name.to_owned()
};
let need = if p.required { ", required" } else { "" };
format!("`{name}` ({ty}{need}): {}", cell(p.help))
})
.collect();
out.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
cell(c.usage),
tool,
cell(&capitalize(c.summary)),
if args.is_empty() {
"none".to_owned()
} else {
args.join("<br>")
}
));
}
}
out
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
chars
@@ -640,6 +806,43 @@ mod tests {
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
}
#[test]
fn the_generated_reference_is_up_to_date() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/horizon-ctl-commands.md"
);
let on_disk = std::fs::read_to_string(path).unwrap_or_default();
assert!(
on_disk == markdown(),
"docs/horizon-ctl-commands.md is out of date: run `horizon-ctl help --markdown > docs/horizon-ctl-commands.md`"
);
for c in COMMANDS {
assert!(
on_disk.contains(&format!("`{}", c.usage.replace('|', "\\|"))),
"{} undocumented",
c.name
);
}
}
#[test]
fn destructive_and_network_tools_exist() {
for name in DESTRUCTIVE.iter().chain(OPEN_WORLD) {
assert!(find(name).is_some_and(|c| c.mcp), "{name}");
}
let delete = find("world-delete").unwrap();
assert_eq!(
line_for(delete, &json!({ "folder": "A", "confirm": true })).unwrap(),
["world-delete", "A", "--confirm"]
);
let lan = find("lan").unwrap();
assert_eq!(
line_for(lan, &json!({ "action": "open", "commands": true })).unwrap(),
["lan", "open", "commands"]
);
}
#[test]
fn help_mentions_every_command() {
let text = overview();
@@ -20,6 +20,8 @@ use serde_json::{Value, json};
const GAME_ERROR: u8 = 1;
const USAGE: u8 = 2;
const UNREACHABLE: u8 = 3;
/// No AI is on (`horizon_core::no_ai`): agent control is off.
const DENIED: u8 = 4;
/// A failure, with the exit status it maps to.
#[derive(Debug)]
@@ -34,6 +36,13 @@ impl Failure {
status: USAGE,
}
}
/// No AI is on in the game's home.
fn denied() -> Self {
Self {
reply: json!({ "error": horizon_core::no_ai::REFUSAL, "kind": "denied" }),
status: DENIED,
}
}
fn unreachable(message: impl Into<String>) -> Self {
Self {
reply: json!({ "error": message.into(), "kind": "connection" }),
@@ -98,6 +107,8 @@ impl Client {
pub struct Session {
pub socket: PathBuf,
pub timeout: Option<Duration>,
/// The game's home, where No AI is read (`horizon_core::no_ai`).
pub home: PathBuf,
client: Option<Client>,
}
@@ -106,9 +117,14 @@ impl Session {
Self {
socket,
timeout,
home: horizon_client::logging::game_home(),
client: None,
}
}
/// A failure while No AI is on: every command refuses (help aside).
pub fn allowed(&self) -> Result<(), Failure> {
horizon_core::no_ai::check(&self.home).map_err(|_| Failure::denied())
}
fn client(&mut self) -> Result<&mut Client, Failure> {
if self.client.is_none() {
self.client = Some(Client::connect(&self.socket, self.timeout)?);
@@ -137,6 +153,7 @@ impl Session {
let Some(name) = words.first().map(String::as_str) else {
return Err(Failure::usage("no command; try horizon-ctl help"));
};
self.allowed()?;
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
return Err(Failure::usage(
"use horizon-ctl COMMAND --help outside scripts",
@@ -174,6 +191,18 @@ impl Session {
ready["launch"] = reply;
Ok(ready)
}
"interact-entity" => {
let id = match words {
[_, id] if id.parse::<i64>().is_ok() => id,
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
};
self.game(&format!("look-at entity {id}"))?;
self.game("mouse right click")?;
self.game("wait 2")?;
let mut screen = self.game("screen")?;
screen["ok"] = json!(true);
Ok(screen)
}
"hold" => {
let (key, seconds) = match words {
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
@@ -183,7 +212,7 @@ impl Session {
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
self.game(&format!("key {key} up"))
}
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
"logs" => logs(&self.home, &words[1..]),
"wait-ready" => {
let limit = if words.len() > 1 {
number(1, "SECONDS")?
@@ -417,6 +446,10 @@ fn main() -> ExitCode {
print!("{}", commands::overview());
0
}
Some("help") if args.get(1).is_some_and(|a| a == "--markdown") => {
print!("{}", commands::markdown());
0
}
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
None => {
print!("{}", commands::overview());
@@ -451,6 +484,10 @@ fn main() -> ExitCode {
}
}
}
Some("mcp") if session.allowed().is_err() => {
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
DENIED
}
Some("mcp") => match mcp::serve(&mut session) {
Ok(()) => 0,
Err(e) => {
@@ -581,6 +618,27 @@ mod tests {
assert_eq!(f.status, USAGE);
}
#[test]
fn no_ai_refuses_every_command() {
let home = std::env::temp_dir().join(format!("horizon-ctl-no-ai-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
s.home = home.clone();
assert_eq!(
s.run(&["state".to_owned()]).unwrap_err().status,
UNREACHABLE
);
horizon_core::no_ai::set(&home, true).unwrap();
for words in [vec!["state"], vec!["logs"], vec!["launch", "--world", "A"]] {
let words: Vec<String> = words.into_iter().map(str::to_owned).collect();
let f = s.run(&words).unwrap_err();
assert_eq!(f.status, DENIED, "{words:?}");
assert_eq!(f.reply["kind"], "denied");
assert_eq!(f.reply["error"], horizon_core::no_ai::REFUSAL);
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn screenshot_paths_become_absolute() {
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
@@ -23,7 +23,13 @@ fn tools() -> Vec<Value> {
schema = json!({ "type": "object", "properties": {} });
description = "Capture the game window and return it as a PNG image.".into();
}
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
if commands::DESTRUCTIVE.contains(&c.name) {
tool["annotations"] = json!({ "destructiveHint": true });
} else if commands::OPEN_WORLD.contains(&c.name) {
tool["annotations"] = json!({ "openWorldHint": true });
}
tool
})
.collect()
}
+1 -1
View File
@@ -528,7 +528,7 @@ impl Furnace {
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient).cloned() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
+96
View File
@@ -1102,7 +1102,11 @@ pub struct Recipes {
facts: HashMap<Arc<str>, Facts>,
/// The `#minecraft:curse` enchantments.
curses: HashSet<String>,
/// The first cooking recipe of each kind taking each item, built at the
/// first lookup (furnaces look theirs up every tick).
cook_index: std::sync::OnceLock<CookIndex>,
}
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, usize>>;
/// `MinMaxBounds.Ints` as (min, max) with the given defaults.
fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) {
match v {
@@ -1421,6 +1425,7 @@ impl Recipes {
meta,
facts,
curses,
cook_index: Default::default(),
})
}
/// Match a `size`x`size` grid (row-major) against the crafting recipes.
@@ -1606,6 +1611,22 @@ impl Recipes {
out
}
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
let index = self.cook_index.get_or_init(|| {
let mut index = CookIndex::new();
for (i, c) in self.cooking.iter().enumerate() {
let of_kind = index.entry(c.kind.clone()).or_default();
for item in &c.ingredient.0 {
of_kind.entry(item.clone()).or_insert(i);
}
}
index
});
let i = *index.get(kind)?.get(&*input.item)?;
self.cooking.get(i)
}
/// `cook` by scanning every recipe, as it was before the index.
#[cfg(test)]
fn cook_scan(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
self.cooking
.iter()
.find(|c| c.kind == kind && c.ingredient.test(input))
@@ -1625,6 +1646,80 @@ mod tests {
s.data_mut().banner_patterns = vec![BannerLayer::new("minecraft:cross", "red")];
s
}
/// About vanilla's count of cooking recipes: four kinds, ingredients of
/// one to twelve items, overlapping (several recipes take some items).
fn cooking_book() -> Recipes {
let kinds = ["smelting", "blasting", "smoking", "campfire_cooking"];
let cooking = (0..320usize)
.map(|i| Cooking {
id: format!("r{i}"),
category: "misc".into(),
group: String::new(),
kind: kinds[i % 4].into(),
ingredient: ingredient(
&(0..1 + i % 12)
.map(|k| format!("minecraft:item_{}", (i * 7 + k * 13) % 400))
.collect::<Vec<_>>()
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
),
result: ItemStack::new("minecraft:stone", 1),
experience: 0.1,
time: 200,
})
.collect();
Recipes {
crafting: Vec::new(),
cooking,
meta: HashMap::new(),
facts: HashMap::new(),
curses: HashSet::new(),
cook_index: Default::default(),
}
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
for kind in [
"smelting",
"blasting",
"smoking",
"campfire_cooking",
"other",
] {
for n in 0..420 {
let stack = ItemStack::new(&format!("minecraft:item_{n}"), 1);
let a = recipes.cook(kind, &stack).map(|c| &c.id);
let b = recipes.cook_scan(kind, &stack).map(|c| &c.id);
assert_eq!(a, b, "{kind} item_{n}");
}
}
}
/// A furnace's recipe lookup, once per lit furnace per tick.
#[test]
#[ignore]
fn cook_cost() {
let recipes = cooking_book();
let stacks: Vec<ItemStack> = (0..1000)
.map(|n| ItemStack::new(&format!("minecraft:item_{}", n % 400), 1))
.collect();
let t = std::time::Instant::now();
let a = stacks
.iter()
.filter(|s| recipes.cook_scan("smelting", s).cloned().is_some())
.count();
let before = t.elapsed().as_secs_f64() * 1e3;
recipes.cook("smelting", &stacks[0]);
let t = std::time::Instant::now();
let b = stacks
.iter()
.filter(|s| recipes.cook("smelting", s).is_some())
.count();
let after = t.elapsed().as_secs_f64() * 1e3;
assert_eq!(a, b);
println!("1000 furnace lookups (scan and clone -> index): {before:.3} ms -> {after:.3} ms");
}
fn run(special: &Special, cells: &[ItemStack]) -> Option<ItemStack> {
let recipes = Recipes {
crafting: Vec::new(),
@@ -1632,6 +1727,7 @@ mod tests {
meta: HashMap::new(),
facts: HashMap::new(),
curses: HashSet::new(),
cook_index: Default::default(),
};
let ctx = Context {
recipes: &recipes,
+2
View File
@@ -193,6 +193,8 @@ impl Enchantments {
|| k.starts_with("item.minecraft.splash_potion")
|| k.starts_with("item.minecraft.lingering_potion")
|| k.starts_with("item.minecraft.tipped_arrow")
|| k.starts_with("item.minecraft.firework_rocket.")
|| k.starts_with("item.minecraft.firework_star.")
|| k.starts_with("trim_")
|| k.starts_with("potion.")
|| k.starts_with("container.")
+6
View File
@@ -583,6 +583,10 @@ pub struct ArmorStand {
pub no_base_plate: bool,
pub invisible: bool,
pub marker: bool,
/// `CustomName`: the text of a hologram.
pub custom_name: Option<crate::text::Text>,
/// `CustomNameVisible`.
pub name_visible: bool,
/// Game time of the last hit (the wobble and the second-hit window).
pub last_hit: i64,
}
@@ -603,6 +607,8 @@ impl ArmorStand {
no_base_plate: false,
invisible: false,
marker: false,
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
}
}
+140
View File
@@ -43,6 +43,7 @@ struct Out {
@location(8) back_color: vec4<f32>,
// entity.vsh's lightMapColor, multiplied in after the overlay.
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -109,6 +110,13 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position, uv, overlay, color, light, normal);
}
// Retained bolt-local geometry, translated using the same f64 camera
// subtraction as the former CPU path before adding each local f32 position.
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(6) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
offset: vec4<f32>,
colors: array<vec4<f32>, 4>,
@@ -209,3 +217,135 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
return vec4(0.0);
}
// WEATHER_MATH_BEGIN
// Unsigned 64-bit helpers (low word first). Snow adds a random double to a
// float drift before casting to float. Retaining the 53-bit base avoids early
// rounding, including ties, without requiring optional GPU float64 support.
fn weather_shr_jam(v: vec2<u32>, n: u32) -> vec2<u32> {
if n == 0u { return v; }
if n < 32u {
return vec2((v.x >> n) | (v.y << (32u - n)) | select(0u, 1u, (v.x << (32u - n)) != 0u), v.y >> n);
}
if n == 32u { return vec2(v.y | select(0u, 1u, v.x != 0u), 0u); }
if n < 64u {
return vec2((v.y >> (n - 32u)) | select(0u, 1u, v.x != 0u || (v.y << (64u - n)) != 0u), 0u);
}
return vec2(select(0u, 1u, any(v != vec2(0u))), 0u);
}
fn weather_add64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
let lo = a.x + b.x;
return vec2(lo, a.y + b.y + select(0u, 1u, lo < a.x));
}
fn weather_sub64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
return vec2(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x));
}
fn weather_double_plus_float(base: vec2<u32>, delta: f32) -> f32 {
// The random base is nonnegative and either zero or >= 2^-53.
if all(base == vec2(0u)) { return select(delta, 0.0, delta == 0.0); }
let db = bitcast<u32>(delta);
let mag = db & 0x7fffffffu;
let be = i32((base.y >> 20u) & 2047u) - 1023;
var de = i32(mag >> 23u) - 127;
var dm = (mag & 0x7fffffu) | 0x800000u;
if mag < 0x800000u {
dm = mag;
if dm == 0u { de = -200; }
else {
let shift = countLeadingZeros(dm) - 8u;
dm <<= shift;
de = -126 - i32(shift);
}
}
let e = max(be, de);
// Leading significand bit at 61, with room for addition and guard bits.
var a = vec2(base.x << 9u, ((base.y & 0xfffffu) | 0x100000u) << 9u | (base.x >> 23u));
var b = vec2(0u, dm << 6u);
a = weather_shr_jam(a, u32(e - be));
b = weather_shr_jam(b, u32(e - de));
var sign = 0u;
var value: vec2<u32>;
if (db >> 31u) == 0u { value = weather_add64(a, b); }
else if a.y > b.y || (a.y == b.y && a.x >= b.x) { value = weather_sub64(a, b); }
else { value = weather_sub64(b, a); sign = 0x80000000u; }
if all(value == vec2(0u)) { return 0.0; }
let leading = select(31u - countLeadingZeros(value.x), 63u - countLeadingZeros(value.y), value.y != 0u);
var exponent = e + i32(leading) - 61;
// Normalize the leading bit to 62. After jamming, bit 38 is the guard
// bit and bits 0..37 distinguish a tie from a value above the midpoint.
let shift = 62u - leading;
if shift >= 32u { value = vec2(0u, value.x << (shift - 32u)); }
else if shift != 0u { value = vec2(value.x << shift, (value.y << shift) | (value.x >> (32u - shift))); }
// Vanilla first rounds the addition to double, then casts to float.
// Round at bit 10 (53 retained bits) before the 24-bit rounding below;
// otherwise a double-rounding midpoint can select the wrong float.
let double_guard = (value.x & 512u) != 0u;
let double_sticky = (value.x & 511u) != 0u;
let double_odd = (value.x & 1024u) != 0u;
value.x &= 0xfffffc00u;
if double_guard && (double_sticky || double_odd) {
value = weather_add64(value, vec2(1024u, 0u));
}
if (value.y & 0x80000000u) != 0u {
value = weather_shr_jam(value, 1u);
exponent += 1;
}
var mantissa = value.y >> 7u;
let guard = (value.y & 64u) != 0u;
let sticky = (value.y & 63u) != 0u || value.x != 0u;
if guard && (sticky || (mantissa & 1u) != 0u) { mantissa += 1u; }
if mantissa >= 0x1000000u { mantissa >>= 1u; exponent += 1; }
return bitcast<f32>(sign | (u32(exponent + 127) << 23u) | (mantissa & 0x7fffffu));
}
fn weather_phase(animation: vec4<f32>, bases: vec4<u32>, snow: u32, time: vec4<f32>) -> vec2<f32> {
if snow == 0u {
let ticks = time.x + animation.y;
let t = ticks + time.z;
let step = -t / 32.0;
let offset = step * animation.x;
return vec2(0.0, offset % 32.0);
}
let t = time.x + time.z;
let u_time = t * 0.01;
let du = u_time * animation.z;
let v_time = t * animation.w;
let dv = v_time * 0.001;
let u = weather_double_plus_float(bases.xy, du);
let v = weather_double_plus_float(bases.zw, dv);
let fall_time = time.y + time.z;
let fall = -fall_time / 512.0;
return vec2(u, fall + v);
}
// Atlas sampler is nearest with no mipmaps. Clamp the integer texel inside
// the sprite even when a fractional coordinate rounds up at its far edge.
fn weather_texel(uv: vec2<f32>, rect: vec4<f32>, dimensions: vec2<u32>) -> vec2<i32> {
let size = vec2<f32>(dimensions);
let origin = vec2<i32>(round(rect.xy * size));
let extent = max(vec2<i32>(round(rect.zw * size)), vec2(1));
let local = min(vec2<i32>(floor(fract(uv) * vec2<f32>(extent))), extent - vec2(1));
return origin + local;
}
// WEATHER_MATH_END
struct WeatherTime { values: vec4<f32> }
@group(1) @binding(1) var<uniform> weather_time: WeatherTime;
@vertex fn vs_weather(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> Out {
let phase = weather_phase(animation, bases, snow, weather_time.values);
var out = entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
out.weather_rect = rect;
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
var color = textureLoad(atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
+52
View File
@@ -13,6 +13,7 @@ use crate::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
uuid_from_tag,
},
text::Text,
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -555,6 +556,14 @@ fn custom_name(tag: &Tag) -> Option<String> {
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
@@ -606,6 +615,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.persistent = flag(tag, "PersistenceRequired");
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
if m.kind.category == "creature" {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
@@ -1266,6 +1276,21 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Marker".into());
}
if a.name_visible {
put(&mut tag, "CustomNameVisible", Tag::Byte(1));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"CustomNameVisible".into());
}
// A name from the save keeps its own shape unless it changed.
if let Some(name) = &a.custom_name
&& !tag.get("CustomName").is_some_and(|t| name_text(t) == *name)
{
put(
&mut tag,
"CustomName",
crate::scoreboard::json_to_tag(&name.component()),
);
}
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
@@ -1294,6 +1319,8 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.velocity = motion(tag);
a.health = float(tag.get("Health")).unwrap_or(20.0) as f32;
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
a.custom_name = tag.get("CustomName").map(name_text);
a.name_visible = flag(tag, "CustomNameVisible");
if let Some(eq) = tag.get("equipment") {
for (i, slot) in ArmorStand::SLOTS.iter().enumerate() {
a.equipment[i] = eq.get(slot).and_then(item_from_nbt);
@@ -1574,6 +1601,31 @@ mod tests {
assert!(ominous_item_spawner_from_nbt(&raw).is_none());
}
#[test]
fn armor_stand_names_keep_their_formatting_across_a_save() {
let tag = Tag::compound([
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
("CustomName", string("§6Hello")),
("CustomNameVisible", Tag::Byte(1)),
]);
let a = armor_stand_from_nbt(&tag, 7).unwrap();
let name = a.custom_name.as_ref().unwrap();
assert_eq!(name.plain(), "Hello");
assert!(name.spans[0].style.color.is_some());
assert!(a.name_visible);
let saved = armor_stand_to_nbt(&a, [0; 4], Some(&tag));
let restored = armor_stand_from_nbt(&saved, 7).unwrap();
assert_eq!(restored.custom_name, a.custom_name);
assert!(restored.name_visible);
}
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
+7 -1
View File
@@ -107,6 +107,8 @@ pub struct Extras {
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -135,6 +137,7 @@ impl Default for Extras {
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
no_ai: false,
}
}
}
@@ -198,6 +201,7 @@ impl Extras {
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
_ => {}
}
}
@@ -205,7 +209,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -234,6 +238,8 @@ impl Extras {
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
+303
View File
@@ -0,0 +1,303 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
pub const TITLE: &str = "Play with Friends";
/// What the screen shows (game/friends.rs fills it).
#[derive(Clone, Debug, Default)]
pub struct FriendsView {
/// A singleplayer world (not a server): sharing is offered.
pub shown: bool,
pub sharing: bool,
pub code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
pub status: String,
/// Friends on the internet can reach the host.
pub reachable: bool,
/// Where the troubleshooting is, when they can't.
pub help: Option<String>,
/// The players in the world besides the host.
pub guests: Vec<String>,
pub notice: Option<String>,
/// The code was just copied.
pub copied: bool,
}
/// A button's action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
Done,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
struct Button {
x: f32,
y: f32,
w: f32,
label: &'static str,
action: Action,
active: bool,
}
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
view: &FriendsView,
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut y = 40.0;
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
for line in wrap(text, width, atlas) {
lines.push((line, color, *y));
*y += 10.0;
}
};
let mut buttons = Vec::new();
if view.sharing {
lines.push(("Invite Code".to_owned(), grey(), y));
y += 12.0;
match &view.code {
Some(code) => {
for line in code_lines(atlas, code, width) {
lines.push((line, WHITE, y));
y += 10.0;
}
}
None => add(&mut lines, &view.status, WHITE, &mut y),
}
y += 6.0;
buttons.push(Button {
x: cx - 154.0,
y,
w: 150.0,
label: if view.copied { "Copied" } else { "Copy Code" },
action: Action::Copy,
active: view.code.is_some(),
});
buttons.push(Button {
x: cx + 4.0,
y,
w: 150.0,
label: "Stop Sharing",
action: Action::Stop,
active: true,
});
y += 30.0;
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let tint = if view.reachable { grey() } else { WHITE };
add(&mut lines, &view.status, tint, &mut y);
y += 4.0;
}
let here = if view.guests.is_empty() {
"No friends here yet.".to_owned()
} else {
format!("Here now: {}", view.guests.join(", "))
};
add(&mut lines, &here, grey(), &mut y);
if let Some(notice) = &view.notice {
add(&mut lines, notice, WHITE, &mut y);
}
// Where friends paste the code, while it fits above the footer.
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, HINT, grey(), &mut y);
}
} else {
add(
&mut lines,
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
WHITE,
&mut y,
);
y += 4.0;
add(&mut lines, HINT, grey(), &mut y);
buttons.push(Button {
x: cx - 100.0,
y: y + 8.0,
w: 200.0,
label: "Start Sharing",
action: Action::Share,
active: view.shown,
});
}
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
// friends on the internet can't reach the host.
if view.sharing && view.help.is_some() {
buttons.push(Button {
x: cx - 154.0,
y: h - 27.0,
w: 150.0,
label: "Troubleshooting...",
action: Action::Help,
active: true,
});
buttons.push(Button {
x: cx + 4.0,
y: h - 27.0,
w: 150.0,
label: "gui.done",
action: Action::Done,
active: true,
});
} else {
buttons.push(Button {
x: cx - 100.0,
y: h - 27.0,
w: 200.0,
label: "gui.done",
action: Action::Done,
active: true,
});
}
(lines, buttons)
}
/// The code's groups packed into lines of at most `width`.
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in code.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn label(key: &str) -> Text {
if key.starts_with("gui.") {
Text::translate(key, &[])
} else {
Text::literal(key)
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &FriendsView,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(TITLE);
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), view);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
ui::button(
batch,
atlas,
b.x,
b.y,
b.w,
20.0,
&label(b.label),
b.active,
hover,
);
}
}
/// The button under a click, if it is active.
pub fn click(
atlas: &HudAtlas,
size: (f32, f32),
cursor: (f32, f32),
view: &FriendsView,
) -> Option<Action> {
layout(atlas, size, view)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.action)
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn share_then_copy_and_stop() {
let atlas = atlas();
let size = (427.0, 240.0);
let idle = FriendsView {
shown: true,
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &idle);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert_eq!(
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
Some(Action::Share)
);
let sharing = FriendsView {
shown: true,
sharing: true,
status: "Finding how friends can reach you…".into(),
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &sharing);
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
assert!(!copy.active, "nothing to copy before the code");
let with_code = FriendsView {
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
help: Some("https://example.invalid/help".into()),
..sharing
};
let (lines, buttons) = layout(&atlas, size, &with_code);
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"the text stays above the footer"
);
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
let b = buttons.iter().find(|b| b.action == action).unwrap();
assert_eq!(
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
Some(action)
);
}
}
#[test]
fn a_server_offers_no_sharing() {
let atlas = atlas();
let view = FriendsView::default();
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert!(!share.active);
}
}
File diff suppressed because it is too large. Load diff
+181 -193
View File
@@ -5,7 +5,6 @@ mod advancements_screen;
mod agent;
mod allays;
mod armor_stands;
mod backdrop;
mod banners;
mod beacon;
mod beehives;
@@ -72,10 +71,12 @@ mod lan_chat;
mod lan_crafting;
mod lan_entities;
mod lan_mobs;
mod lan_mount_menu;
mod lan_pvp;
mod lan_screen;
mod lan_stations;
mod lan_trading;
mod lan_vehicles;
mod lan_waypoints;
mod leads;
mod level_particles;
@@ -121,9 +122,11 @@ mod remote;
mod remote_objects;
mod remote_projectiles;
mod remote_stations;
mod remote_vehicles;
mod render_control;
mod render_tick;
mod respawn_anchor;
mod saves_control;
mod saving;
mod scoreboard;
mod screens;
@@ -214,6 +217,7 @@ use horizon_world::{
registry::BlockStateId,
};
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
path::PathBuf,
sync::{
@@ -453,6 +457,9 @@ enum Screen {
Advancements,
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, from the pause menu's Friends button
/// (`friends_screen`, game/friends.rs).
Friends,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
}
@@ -1110,6 +1117,7 @@ struct Game<'a> {
/// `FogRenderer`'s evolving state.
fog: horizon_client::fog::FogState,
screen_fx: horizon_client::screen_effects::ScreenEffects,
weather_parameters: horizon_client::weather::ParameterCache,
panorama_rotation: horizon_client::gfx::panorama::Rotation,
world: World,
streaming: Streaming,
@@ -1312,14 +1320,12 @@ struct Game<'a> {
settings: Settings,
/// Where the options screen returns: true = title, false = pause menu.
options_from_title: bool,
/// The live title backdrop (game/backdrop.rs).
backdrop: backdrop::Backdrop,
/// When the pause menu opened (Horizon style's entrance).
pause_since: Option<Instant>,
dragging: bool,
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// Bolts that struck here and sky flashes they made, in total (`status`).
lightning_seen: [u32; 2],
/// The head of the bed while sleeping; the visual timer also survives waking.
sleeping: Option<[i32; 3]>,
sleep_timer: u32,
@@ -1387,7 +1393,7 @@ struct Game<'a> {
map_patches: Vec<horizon_client::font::Patch>,
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
skin_downloads: horizon_client::skins::Downloader,
skin_downloads: RefCell<horizon_client::skins::Downloader>,
skin_slots: Vec<(String, usize)>,
cart_models: minecarts::CartModels,
/// The boat the player rides.
@@ -1417,6 +1423,8 @@ struct Game<'a> {
arrow_model: Option<horizon_client::mobs::Part>,
tnts: Vec<tnt::PrimedTnt>,
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
control: Option<horizon_client::control::Server>,
frame_stats: horizon_client::control::FrameStats,
@@ -4448,6 +4456,7 @@ impl Game<'_> {
| Screen::Stats
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::CommandBlock
) || self.book.is_some())
}
@@ -4485,10 +4494,17 @@ impl Game<'_> {
};
if capture {
self.mouse.set_ignore_first_move();
// macOS warps by re-associating the mouse with the pointer,
// which undoes a Locked grab and leaves the pointer free: warp
// first there. Wayland moves a pointer only while it is locked,
// so everywhere else warp after grabbing.
#[cfg(target_os = "macos")]
warp(window);
self.captured = window
.set_cursor_grab(CursorGrabMode::Locked)
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
.is_ok();
#[cfg(not(target_os = "macos"))]
if self.captured && self.focused {
warp(window);
}
@@ -4668,20 +4684,12 @@ impl Game<'_> {
}
}
if let Some(title) = &self.title {
if horizon_client::ui_theme::horizon() {
let fade = self.backdrop_fade();
let mut ui =
horizon_client::ui_theme::Ui::new(&self.gui.atlas, &mut batch, self.cursor);
title.draw_horizon(&mut ui, fade);
ui.finish();
} else {
title.draw(
&self.gui.atlas,
&mut batch,
self.cursor,
self.settings.file.bool("hideSplashTexts"),
);
}
title.draw(
&self.gui.atlas,
&mut batch,
self.cursor,
self.settings.file.bool("hideSplashTexts"),
);
return batch;
}
let atlas_ptr: *const HudAtlas = &self.gui.atlas;
@@ -4698,9 +4706,7 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
// Horizon's pause menu sits over the blurred world alone.
let horizon_pause = self.horizon_pause();
let show_hud = !self.ui.hide_gui && !horizon_pause;
let show_hud = !self.ui.hide_gui;
if show_hud {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
@@ -4834,10 +4840,11 @@ impl Game<'_> {
let tw = HudBatch::text_width(atlas, text);
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
}
if !horizon_pause {
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
}
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
// Screen.extractBlurredBackground blurs the world and HUD, while the
// screen's background, widgets, toasts and debug overlay stay sharp.
batch.blur_at = batch.vertices.len() as u32;
match self.screen {
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
// PauseScreen(false): only its title, at the top.
@@ -4858,6 +4865,7 @@ impl Game<'_> {
Screen::Stats => self.draw_stats(&mut batch),
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
@@ -5469,6 +5477,16 @@ impl Game<'_> {
let mut v = vec![None; 45];
v.extend((0..9).map(Some));
(Kind::ItemPicker, v)
} else if let Some(columns) = self
.mount_menu_columns()
.filter(|_| self.remote.is_some() && self.mount_active())
{
// A server's HorseInventoryMenu: the saddle, the body armour,
// the chest, then the inventory.
let mut v = vec![Some(mount_menu::SADDLE), Some(mount_menu::ARMOR)];
v.extend((0..columns * 3).map(|i| Some(mount_menu::CHEST + i)));
v.extend((9..36).chain(0..9).map(Some));
(Kind::Mount(columns), v)
} else if merchant_open {
// MerchantMenu: two payment slots, the result, then the inventory.
let mut v: Vec<Option<usize>> = vec![
@@ -5601,6 +5619,9 @@ impl Game<'_> {
None => game_of.iter().position(|x| *x == Some(g)),
};
let mut menu = Menu::new(kind);
if matches!(kind, Kind::Mount(_)) {
menu.mount = self.mount_menu_entity();
}
let total = menu.slot_count();
let before: Vec<Option<ItemStack>> = game_of
.iter()
@@ -7696,6 +7717,10 @@ impl Game<'_> {
fullscreen: s.fullscreen,
graphics: s.graphics_preset.clone(),
};
// Again now: on Wayland the window is on a screen only once it has been shown.
if let Some(window) = &self.window {
horizon_client::telemetry::note_display(window);
}
self.perf
.finish(&horizon_client::logging::game_home(), settings);
}
@@ -8942,89 +8967,84 @@ impl Game<'_> {
}
}
/// WeatherEffectRenderer: rain and snow columns within the weather radius.
fn weather_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
fn weather_geometry(
&mut self,
camera: glam::DVec3,
alpha: f64,
) -> Vec<horizon_client::weather::Vertex> {
use horizon_client::weather;
let (intensity, _) = self.world.weather.levels(alpha as f32);
if intensity <= 0.0 {
return;
return Vec::new();
}
let atlas = &self.gui.atlas;
let (Some(rain_tex), Some(snow_tex)) = (
atlas.sprites.get("environment/rain").copied(),
atlas.sprites.get("environment/snow").copied(),
) else {
return;
return Vec::new();
};
let (cx, cy, cz) = (
camera.x.floor() as i32,
camera.y.floor() as i32,
camera.z.floor() as i32,
);
let radius = self.settings.weather_radius;
let ticks = self.world.game_time;
let partial = alpha as f32;
let eye = [camera.x, camera.y, camera.z];
// Vanilla draws every rain column before the snow ones.
let mut columns = (Vec::new(), Vec::new());
for z in cz - radius..=cz + radius {
for x in cx - radius..=cx + radius {
// The 32x32 direction table supports at most a 15-block radius.
let radius = self.settings.weather_radius.clamp(0, 15);
let eye = camera.to_array();
let mut rain = Vec::new();
let mut snow_vertices = Vec::new();
// Saturation keeps extreme camera coordinates from overflowing the loop.
for z in cz.saturating_sub(radius)..=cz.saturating_add(radius) {
for x in cx.saturating_sub(radius)..=cx.saturating_add(radius) {
let top = self.rain_height(x, z);
let y0 = (cy - radius).max(top);
let y1 = (cy + radius).max(top);
let y0 = cy.saturating_sub(radius).max(top);
let y1 = cy.saturating_add(radius).max(top);
if y1 == y0 {
continue;
}
let Some(snow) = self.precipitation(x, cy, z) else {
continue;
};
let mut random = weather::column_random(x, z);
let parameters = self.weather_parameters.get(x, z);
let light = self.world.light(x, top.max(cy), z);
let coords = i32::from(light & 15) << 4 | i32::from(light >> 4) << 20;
if snow {
columns.1.push(weather::snow_column(
&mut random,
ticks,
x,
y0,
y1,
z,
coords,
partial,
));
// Only light brightening is needed here; UV phases stay zero.
let lc = if snow {
let (block, sky) = (coords >> 4 & 15, coords >> 20 & 15);
((block * 3 + 15) / 4) << 4 | ((sky * 3 + 15) / 4) << 20
} else {
columns.0.push(weather::rain_column(
&mut random,
ticks,
x,
y0,
y1,
z,
coords,
partial,
));
coords
};
let column = weather::Column {
x,
z,
bottom_y: y0,
top_y: y1,
u_offset: 0.0,
v_offset: 0.0,
light_coords: lc,
};
let r = if snow { snow_tex } else { rain_tex };
let vertices = weather::vertices(
&column,
parameters,
snow,
eye,
radius,
intensity,
[r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0],
);
if snow {
snow_vertices.extend(vertices);
} else {
rain.extend(vertices);
}
}
}
let rain = columns.0.iter().map(|c| (c, 1.0, rain_tex));
let snow = columns.1.iter().map(|c| (c, 0.8, snow_tex));
for (column, max_alpha, r) in rain.chain(snow) {
let quad = weather::column_quad(column, eye, max_alpha, radius, intensity);
let color = quad[0].color;
let lc = quad[0].light_coords;
let light = ((lc >> 20 & 15) * 16) as u32 | (((lc >> 4 & 15) * 16) as u32) << 8;
weather::tile(&quad, |pos, uvs| {
for (p, [u, v]) in pos.into_iter().zip(uvs) {
out.push(Vertex {
normal: [0; 4],
pos: p,
uv: [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v],
layer: 0,
color,
light,
});
}
});
}
// Vanilla draws every rain column before the snow ones.
rain.extend(snow_vertices);
rain
}
/// Player.startSleepInBed: night or thunder, no monsters within 8 blocks;
/// the bed becomes the respawn point either way.
@@ -9586,12 +9606,7 @@ impl Game<'_> {
// Camera.getCameraEntityPartialTicks: the view, the hand and the
// player's own model stay smooth while the level is frozen.
let camera_alpha = self.clock.camera_fraction();
let live = self.backdrop_live();
let view = if live {
self.backdrop_view()
} else {
self.camera_view(camera_alpha as f32)
};
let view = self.camera_view(camera_alpha as f32);
let (camera, underwater, view_yaw, view_pitch) = match view {
Some((c, yaw, pitch)) => {
let s =
@@ -9739,9 +9754,7 @@ impl Game<'_> {
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
let title = self.title.is_some()
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
let (yaw, pitch) = if live {
(view_yaw, view_pitch)
} else if title {
let (yaw, pitch) = if title {
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
(
self.panorama_rotation.update(
@@ -9753,9 +9766,7 @@ impl Game<'_> {
} else {
(view_yaw, view_pitch)
};
let fov_degrees = if live {
72.0
} else if title {
let fov_degrees = if title {
85.0
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
@@ -9782,15 +9793,17 @@ impl Game<'_> {
self.entity_geometry(camera, alpha, entities)
});
}
let bolts = if title {
Vec::new()
} else {
self.bolt_instances()
};
let mut additive = Vec::new();
if !title {
self.bolt_geometry(camera, &mut additive);
if self.dim == Dim::End {
let mut dragon = Vec::new();
self.dragon_geometry(camera, alpha, &mut dragon, &mut additive);
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
entities.extend(dragon);
}
if !title && self.dim == Dim::End {
let mut dragon = Vec::new();
self.dragon_geometry(camera, alpha, &mut dragon, &mut additive);
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
entities.extend(dragon);
}
// A Java pack's `clouds` replaces the cloud option (`renderClouds`:
// "false", "fast", "true"), as Iris overrides `getCloudsType`.
@@ -9804,7 +9817,7 @@ impl Game<'_> {
}
.to_owned()
};
let (celestial, stars) = if title && !live {
let (celestial, stars) = if title {
(Vec::new(), None)
} else if self.dim == Dim::Overworld
&& self.potions.effects.get("darkness").is_none()
@@ -9812,8 +9825,8 @@ impl Game<'_> {
{
// Camera.doesMobEffectBlockSky: no sun, moon or stars.
self.celestial_geometry(&sky)
} else if self.dim == Dim::End {
(self.end_sky_geometry(), None)
} else if self.dim == Dim::End && self.settings.horizon.sky {
(self.end_flash_geometry(), None)
} else {
(Vec::new(), None)
};
@@ -9830,11 +9843,11 @@ impl Game<'_> {
if !title {
self.end_geometry(camera, &mut entities);
}
if (!title || live) && self.dim == Dim::Overworld && self.settings.horizon.weather {
horizon_client::spans::time("weather", || {
self.weather_geometry(camera, alpha, &mut entities)
});
}
let weather = if !title && self.dim == Dim::Overworld && self.settings.horizon.weather {
horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha))
} else {
Vec::new()
};
let border = (!title)
.then(|| self.border_draw(camera.to_array(), alpha as f32))
.flatten();
@@ -9845,6 +9858,8 @@ impl Game<'_> {
self.gather_dynamic_lights(camera, alpha);
}
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
shader: self.shader_inputs(camera, alpha as f32),
nether_lighting: self.dim == Dim::Nether,
camera,
@@ -9857,9 +9872,15 @@ impl Game<'_> {
detail_distance: (self.streaming.radius * 16) as f32,
depth_far,
underwater,
title: title && !live,
title,
skybox: self.dim == Dim::Overworld && self.settings.horizon.sky,
end_sky: self.dim == Dim::End && self.settings.horizon.sky,
end_sky_sprite: self
.gui
.atlas
.sprites
.get("environment/end_sky")
.map(|r| [r.u0, r.v0, r.u1, r.v1]),
environment_fog: (self.dim == Dim::Nether).then_some((sky.fog_start, sky.fog_end)),
cave_culling,
water_fog,
@@ -9885,7 +9906,7 @@ impl Game<'_> {
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
hud: &hud,
clouds: ((!title || live)
clouds: (!title
&& self.dim == Dim::Overworld
&& horizon_client::clouds::visible(&clouds_mode, sky.cloud_color[3]))
.then(|| self.gui.atlas.sprites.get("horizon/white"))
@@ -9902,6 +9923,13 @@ impl Game<'_> {
}),
entities: &entities,
dynamic_lights: &self.dynamic_lights,
lightning: horizon_client::gfx::lightning::Input {
bolts: &bolts,
uv: [
self.gui.atlas.font.white().u0,
self.gui.atlas.font.white().v0,
],
},
additive: &additive,
see_through: &see_through,
celestial: if self.settings.horizon.sky || title {
@@ -9933,13 +9961,6 @@ impl Game<'_> {
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
block_flicker: self.fog.block_light_flicker,
post: if live {
Some(horizon_client::post::PostGrade::FROST)
} else if self.horizon_pause() {
Some(horizon_client::post::PostGrade::PAUSE)
} else {
None
},
menu_blur: self.menu_blur(),
};
let result = match self.renderer.as_mut() {
@@ -10007,17 +10028,18 @@ impl Game<'_> {
};
level_loading::draw(batch, atlas, Some(&status), Some(self.loading_progress));
}
/// `Screen.extractBlurredBackground` over the panorama: every classic
/// screen of the title menus but the title itself (`TitleScreen`
/// doesn't blur), when `menuBackgroundBlurriness` is at least 1.
/// `Screen.extractBlurredBackground` over the panorama or the world and
/// HUD, when `menuBackgroundBlurriness` is at least 1.
fn menu_blur(&self) -> Option<f32> {
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
let blurred = !horizon_client::ui_theme::horizon()
&& (loading
|| self.title.as_ref().is_some_and(|t| {
self.screen == Screen::Options
|| !matches!(t.mode, horizon_client::title::Mode::Main)
}));
let blurred = loading
|| self.title.as_ref().is_some_and(|t| {
self.screen == Screen::Options
|| !matches!(t.mode, horizon_client::title::Mode::Main)
})
|| (self.title.is_none()
&& self.player.is_some()
&& self.screen.blurs_background(self.pause_menu));
let radius = self.settings.menu_blur as f32;
(blurred && radius >= 1.0).then_some(radius)
}
@@ -10096,7 +10118,7 @@ impl Game<'_> {
}
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
Screen::Dead => {}
Screen::Stats => self.screen = Screen::Paused,
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::Advancements => self.close_advancements(),
Screen::WorldOptions => self.world_options_escape(),
Screen::CommandBlock => self.close_command_block(),
@@ -10497,6 +10519,7 @@ impl ApplicationHandler for Game<'_> {
return;
}
};
horizon_client::telemetry::note_display(&window);
match self.new_renderer(window.clone()) {
Ok(r) => {
self.renderer = Some(r);
@@ -10570,15 +10593,6 @@ impl ApplicationHandler for Game<'_> {
) {
self.last_input = Instant::now();
}
if let WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} = &event
{
// The press state of Horizon-style buttons.
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
}
if (self.pending_entry.is_some() || self.world_loader.is_some())
&& matches!(&event, WindowEvent::KeyboardInput { event, .. }
if event.state == ElementState::Pressed
@@ -10678,9 +10692,7 @@ impl ApplicationHandler for Game<'_> {
if matches!(self.screen, Screen::Inventory | Screen::Creative) {
self.inventory_drag_move();
}
if let Some(title) = &mut self.title
&& !horizon_client::ui_theme::horizon()
{
if let Some(title) = &mut self.title {
title.drag(self.cursor, (b.width, b.height));
title.drag_field(&self.gui.atlas, self.cursor.0);
}
@@ -10706,9 +10718,7 @@ impl ApplicationHandler for Game<'_> {
}) * wheel_sensitivity
};
let d = scaled(raw_y) as f32;
if let Some(title) = &mut self.title
&& !horizon_client::ui_theme::horizon()
{
if let Some(title) = &mut self.title {
let size = self
.window
.as_ref()
@@ -10796,21 +10806,13 @@ impl ApplicationHandler for Game<'_> {
.as_ref()
.map(|r| [r.config.width, r.config.height])
.unwrap_or([1, 1]);
let mut b = HudBatch::new(size);
let b = HudBatch::new(size);
let atlas = &self.gui.atlas;
let shift = self.has_shift_down();
let (action, sound) = self.title.as_mut().map_or((Action::None, false), |t| {
if horizon_client::ui_theme::horizon() {
(
t.click_horizon(atlas, &mut b, self.cursor.0, self.cursor.1),
false,
)
} else {
let clicked =
t.click(self.cursor.0, self.cursor.1, (b.width, b.height));
t.press_field(atlas, self.cursor, (b.width, b.height), shift);
clicked
}
let clicked = t.click(self.cursor.0, self.cursor.1, (b.width, b.height));
t.press_field(atlas, self.cursor, (b.width, b.height), shift);
clicked
});
self.save_title_options();
if sound {
@@ -10946,6 +10948,9 @@ impl ApplicationHandler for Game<'_> {
Screen::WorldOptions if pressed && button == MouseButton::Left => {
self.world_options_click()
}
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
}
Screen::CommandBlock if pressed && button == MouseButton::Left => {
self.command_block_click()
}
@@ -11045,9 +11050,7 @@ impl ApplicationHandler for Game<'_> {
}
}
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
if event.state == ElementState::Pressed
&& self.title.as_ref().is_some_and(|t| !t.skip_opening())
{
if event.state == ElementState::Pressed {
if let Some(shortcut) = self.edit_shortcut(&event) {
self.title_shortcut(shortcut);
return Ok(());
@@ -11140,11 +11143,6 @@ impl ApplicationHandler for Game<'_> {
+ (turn(KeyCode::ArrowDown) - turn(KeyCode::ArrowUp)) * 90.0 * secs)
.clamp(-90.0, 90.0);
}
self.update_backdrop()?;
horizon_client::ui_theme::set_backdrop(self.backdrop_fade());
if self.screen != Screen::Paused {
self.pause_since = None;
}
if self.title.is_none() {
horizon_client::spans::time("update_world", || self.update_world());
horizon_client::spans::time("spawn_player", || self.spawn_player())?;
@@ -11166,8 +11164,6 @@ impl ApplicationHandler for Game<'_> {
}
self.tick_ambience();
self.tick_fov();
} else if self.title.is_some() {
self.tick_backdrop();
}
self.tick_music();
self.tick_sound_engine();
@@ -11438,6 +11434,7 @@ impl<'a> Game<'a> {
gui,
fog: Default::default(),
screen_fx: Default::default(),
weather_parameters: Default::default(),
panorama_rotation: Default::default(),
world,
streaming,
@@ -11577,11 +11574,10 @@ impl<'a> Game<'a> {
hand_strength: Default::default(),
settings,
options_from_title: false,
backdrop: Default::default(),
pause_since: None,
dragging: false,
avatar: Default::default(),
flash: 0,
lightning_seen: [0; 2],
sleeping: None,
sleep_timer: 0,
dim: Dim::Overworld,
@@ -11627,11 +11623,11 @@ impl<'a> Game<'a> {
breath_clouds: Vec::new(),
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
skin_downloads: horizon_client::skins::Downloader::new(
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
std::env::current_dir()
.unwrap_or_default()
.join("assets/skins"),
),
)),
skin_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
@@ -11651,6 +11647,7 @@ impl<'a> Game<'a> {
arrow_model: prepared.arrow_model,
tnts: Vec::new(),
hoppers: HashMap::new(),
item_columns: None,
creative_screen: prepared.creative_screen,
control: None,
frame_stats: Default::default(),
@@ -11949,13 +11946,11 @@ impl Progress {
/// Harness and dedicated runs keep the fully-synchronous startup so their
/// output stays byte-identical; only an ordinary launch shows the window first.
fn synchronous_startup(options: &Options) -> bool {
let classic = std::env::var("HORIZON_MENU_STYLE").ok().as_deref() == Some("classic");
options.frames.is_some()
|| options.world_frames.is_some()
|| options.benchmark.is_some()
|| options.control.is_some()
|| options.dedicated.is_some()
|| classic
|| std::env::var_os("HORIZON_DISPENSE_FIXTURE").is_some()
|| std::env::var_os("HORIZON_SCULK_FIXTURE").is_some()
|| std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some()
@@ -11976,7 +11971,7 @@ fn load_resources(
let requested_root = root.clone();
let threads = settings.horizon.worker_threads();
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
// published at once so the title backdrop can appear while the world bundle
// published at once so the title screen can appear while the world bundle
// loads; the synchronous launch instead carries it in `Loaded`.
let mut loaded_gui = None;
let mut ui_ready = false;
@@ -12349,6 +12344,8 @@ fn loading_renderer(
/// A title-shaped frame: black clear and panorama, only the HUD is drawn.
fn loading_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: horizon_client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
@@ -12363,6 +12360,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
title: true,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
@@ -12377,6 +12375,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
entities: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
@@ -12393,7 +12392,6 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
post: None,
menu_blur: None,
shader: Default::default(),
}
@@ -12420,18 +12418,8 @@ fn start_game<'a>(
let handoff = prepared.is_some();
let mut settings = match settings {
Some(settings) => settings,
None => {
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
Settings::load(&std::env::current_dir()?.join("options.txt"))
}
None => Settings::load(&std::env::current_dir()?.join("options.txt")),
};
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
// screens unless HORIZON_MENU_STYLE asks otherwise.
if (options.frames.is_some() || options.control.is_some())
&& std::env::var_os("HORIZON_MENU_STYLE").is_none()
{
horizon_client::ui_theme::set_style(horizon_client::ui_theme::MenuStyle::Classic);
}
if let Some(d) = options.distance_override {
settings.render_distance = d;
}
@@ -12456,7 +12444,13 @@ fn start_game<'a>(
game.mods.dev = game.options.dev_mods.clone();
game.start_mods();
}
if let Some(path) = &game.options.control {
if game.options.control.is_some() && game.settings.extras.no_ai {
// No AI turns agent control off: the socket is never opened.
log::warn!(
"Horizon: control socket not opened: {}",
horizon_core::no_ai::REFUSAL
);
} else if let Some(path) = &game.options.control {
let server = horizon_client::control::Server::start(path)
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
log::info!("Horizon: control socket {}", server.path().display());
@@ -12537,12 +12531,6 @@ fn start_game<'a>(
&game.options.resources,
);
title.last_server = game.settings.file.string("lastServer");
// The launcher's opening, once per session and only in Horizon's style.
if horizon_client::ui_theme::horizon() && game.options.frames.is_none() {
title
.opening
.set(Some(horizon_client::front::Opening::new()));
}
game.title = Some(title);
}
}
@@ -12629,6 +12617,7 @@ impl Loading {
return;
}
};
horizon_client::telemetry::note_display(&window);
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
match loading_renderer(
window.clone(),
@@ -12900,7 +12889,6 @@ fn run_loading(
pack_error: Option<String>,
started: Instant,
) -> Result<()> {
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
let progress = Progress::new();
let (receiver, loader) =
startup_menu::spawn_loader(&options, &settings, &vanilla_root, started, &progress)?;
+1
View File
@@ -403,6 +403,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
}
}
+16 -6
View File
@@ -9,18 +9,28 @@ impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
let player = self.player.as_ref().map(|p| p.position);
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
for i in 0..self.mobs.len() {
if self.mobs[i].species() != Species::Allay || !self.mobs[i].alive() {
continue;
}
let pos = self.mobs[i].position;
// Dancing: a playing jukebox within 10 blocks.
let dancing = self.world.block_entities.iter().any(|(k, e)| {
matches!(e, BlockEntity::Jukebox(j) if j.playing)
&& (f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
<= 100.0
let jukeboxes = jukeboxes.get_or_insert_with(|| {
self.world
.block_entities
.iter()
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
.map(|(k, _)| *k)
.collect()
});
let dancing = jukeboxes.iter().any(|k| {
(f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
<= 100.0
});
self.mobs[i].extra.flag =
dancing && !horizon_client::mobs::goals::should_panic(&self.mobs[i]);
+11 -1
View File
@@ -432,6 +432,10 @@ impl Game<'_> {
(false, false) => (0.5, 1.975),
};
let edge = (width + height + width) / 3.0 * 4.0;
// 26.3 compares the size with RenderSystem.DEFAULT_DEPTH_CLEAR_VALUE,
// which is 0.0 (reverse Z; the bytecode is `dconst_0`): an empty
// marker box falls back to 4, so a visible marker stand draws.
// Holograms stay hidden through the invisible flag instead.
let range = if edge == 0.0 { 4.0 } else { edge } * 64.0;
let dragon = a.equipment[5]
.as_ref()
@@ -700,9 +704,15 @@ impl Game<'_> {
self.dim == super::Dim::Nether,
)),
};
let profile = stack
.extra
.as_deref()
.and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile"))
.map(|(_, t)| horizon_client::scoreboard::tag_to_json(t))
.unwrap_or(serde_json::Value::Null);
self.pack.item_renderer.submit_special(
&special,
&serde_json::Value::Null,
&profile,
false,
pose,
&self.gui.atlas.sprites,
-303
View File
@@ -1,303 +0,0 @@
//! The live title backdrop (Menu style: Horizon): a frozen landscape that
//! Horizon generates and renders itself behind the title screens, seen
//! through a slow orbiting camera, falling snow, an overcast sky and the cold
//! blur and grade of `gfx::PostGrade`. Nothing is bundled: the chunks come
//! from the vanilla generator for a fixed seed and are cached in
//! `saves/.horizon-title/backdrop` (no level.dat, so no world list entry).
//! The ice-spikes spot is found once with `/locate biome`'s search and kept
//! in `spot.txt` there. The generator is built and the spot found on a
//! worker, so the title keeps drawing its panorama until both are ready.
use super::Game;
use horizon_client::{
Result,
save::{Persistent, SaveDir},
streaming::Streaming,
vanillagen::VanillaTerrain,
world::{Weather, World},
};
use horizon_core::biome::{BiomeZoom, obfuscate_seed};
use std::{
sync::{Arc, Mutex},
time::Instant,
};
/// The world the backdrop is cut from (HORIZON_BACKDROP_SEED overrides it).
const SEED: i64 = 2_026_031_337;
/// The biome the camera looks for.
const BIOME: &str = "minecraft:ice_spikes";
/// Late morning: the snow reads clearly; the grade (`PostGrade::FROST`) makes it cold.
const DAY_TIME: i64 = 4_000;
/// The worker's result: the generator for [`SEED`], or why it failed.
type Built = std::result::Result<Arc<VanillaTerrain>, String>;
#[derive(Default)]
pub(super) struct Backdrop {
pub active: bool,
/// The generator, once the worker has built it; taken when it is installed.
terrain: Arc<Mutex<Option<Built>>>,
/// The generator streams the backdrop's chunks.
installed: bool,
/// The search for the spot, then the spot (block x, z).
spot: Arc<Mutex<Option<Option<(i32, i32)>>>>,
centred: bool,
/// Camera position once the ground under it has loaded.
pub camera: Option<glam::DVec3>,
pub ready: Option<Instant>,
pub started: Option<Instant>,
}
fn seed() -> i64 {
std::env::var("HORIZON_BACKDROP_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(SEED)
}
impl Game<'_> {
/// How visible the live backdrop is (0 until its first chunks are drawn).
pub(super) fn backdrop_fade(&self) -> f32 {
let Some(at) = self.backdrop.ready else {
return 0.0;
};
let x = (at.elapsed().as_secs_f32() / 1.6).clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
/// Whether the frame should draw the backdrop world instead of the panorama.
pub(super) fn backdrop_live(&self) -> bool {
self.title.is_some() && self.backdrop.active && self.backdrop.camera.is_some()
}
/// The orbiting camera: position, yaw and pitch.
pub(super) fn backdrop_view(&self) -> Option<(glam::DVec3, f32, f32)> {
let c = self.backdrop.camera?;
let t = self
.backdrop
.started
.map_or(0.0, |s| s.elapsed().as_secs_f32());
// A full turn in about six minutes.
let yaw = (200.0 + t * 1.0) % 360.0;
let pitch = 6.0 + (t * 0.05).sin() * 2.0;
Some((c, yaw, pitch))
}
/// Called every frame while the title is up.
pub(super) fn update_backdrop(&mut self) -> Result<()> {
let wanted = self.pending_entry.is_none()
&& self.title.is_some()
&& horizon_client::ui_theme::horizon()
&& self.world_renderer_ready
&& self.renderer.is_some()
&& self.options.frames.is_none();
if !wanted {
if self.backdrop.active && self.title.is_some() {
self.stop_backdrop();
}
if self.title.is_none() && self.backdrop.active {
// open_world / connect_server replaced the world and streaming.
self.backdrop = Backdrop::default();
}
return Ok(());
}
if !self.backdrop.active {
self.start_backdrop()?;
}
if !self.backdrop.installed {
let built = self
.backdrop
.terrain
.lock()
.unwrap_or_else(|e| e.into_inner())
.take();
match built {
// Still building: the panorama stays up.
None => return Ok(()),
Some(Ok(terrain)) => self.install_backdrop(terrain)?,
Some(Err(e)) => {
// The panorama stays up for good.
log::warn!("Horizon: title backdrop: {e}");
self.backdrop.installed = true;
return Ok(());
}
}
}
let found = *self.backdrop.spot.lock().unwrap_or_else(|e| e.into_inner());
let Some(Some((x, z))) = found else {
return Ok(());
};
if !self.backdrop.centred {
self.backdrop.centred = true;
self.streaming.center = (x >> 4, z >> 4);
self.streaming.paused = false;
}
self.update_world();
if self.backdrop.camera.is_none()
&& (-2..=2)
.all(|dz| (-2..=2).all(|dx| self.streaming.is_lit((x >> 4) + dx, (z >> 4) + dz)))
{
// Above the tallest block nearby, so no spike pokes through the lens.
let mut top = 64;
for dz in (-12..=12).step_by(3) {
for dx in (-12..=12).step_by(3) {
if let Some(y) = self.surface_y(x + dx, z + dz) {
top = top.max(y);
}
}
}
let ground = self.surface_y(x, z).unwrap_or(top);
let y = (ground + 22).max(top + 4);
self.backdrop.camera = Some(glam::DVec3::new(
f64::from(x) + 0.5,
f64::from(y),
f64::from(z) + 0.5,
));
}
if self.backdrop.camera.is_some()
&& self.backdrop.ready.is_none()
&& self
.renderer
.as_ref()
.is_some_and(|r| r.section_count() > 40)
{
self.backdrop.ready = Some(Instant::now());
log::info!(
"Horizon: title backdrop ready; world rest ready: {}",
self.res.rest.get().is_some()
);
}
Ok(())
}
/// The world tick of the backdrop: time passes for clouds and snow only.
pub(super) fn tick_backdrop(&mut self) {
if self.backdrop.active {
self.world.game_time += 1;
// The snow's fall follows the client tick counter.
self.ticks += 1;
}
}
fn surface_y(&self, x: i32, z: i32) -> Option<i32> {
let air = self.res.facts().air;
(-60..320).rev().find(|&y| self.world.block(x, y, z) != air)
}
/// Start building the generator and finding the spot on a worker
/// (`VanillaTerrain::new` takes seconds on a cold cache).
fn start_backdrop(&mut self) -> Result<()> {
log::info!(
"Horizon: starting title backdrop after {} ms",
self.started.elapsed().as_millis()
);
let seed = seed();
let dir = self.backdrop_root()?.join(format!("{seed}"));
std::fs::create_dir_all(&dir)?;
// The streaming is replaced once the generator is built.
self.streaming.paused = true;
let mut world = World::new(self.res.facts().air, seed);
world.day_time = DAY_TIME;
world.game_time = 6000;
// Light snow: the weather renders as snow in frozen biomes.
world.weather = Weather {
clear_time: 0,
rain_time: i32::MAX / 2,
thunder_time: i32::MAX / 2,
raining: true,
thundering: false,
rain_level: (0.35, 0.35),
thunder_level: (0.0, 0.0),
};
self.world = world;
self.dim = super::Dim::Overworld;
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop {
active: true,
started: Some(Instant::now()),
..Default::default()
};
let (terrain, spot) = (self.backdrop.terrain.clone(), self.backdrop.spot.clone());
let worldgen = self.options.resources.join("worldgen");
let registry = self.pack.assets.registry;
let biomes = self.res.biomes().clone();
let cache = dir.join("spot.txt");
std::thread::Builder::new()
.name("horizon-backdrop".into())
.spawn(move || {
let built = Instant::now();
let inner = match VanillaTerrain::new(&worldgen, registry, &biomes, seed) {
Ok(t) => Arc::new(t),
Err(e) => {
*terrain.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Err(e.to_string()));
return;
}
};
log::info!(
"Horizon: title backdrop generator built in {} ms",
built.elapsed().as_millis()
);
*terrain.lock().unwrap_or_else(|e| e.into_inner()) = Some(Ok(inner.clone()));
// Find (or recall) the spot.
let known = std::fs::read_to_string(&cache).ok().and_then(|t| {
let mut it = t.split_whitespace().map(|v| v.parse::<i32>().ok());
Some((it.next()??, it.next()??))
});
let found = known.or_else(|| {
let allowed = |name: &str| name == BIOME;
let search = horizon_world::worldgen::generator::BiomeSearch {
allowed: &allowed,
origin: [0, 64, 0],
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: -64,
max_y: 320,
};
let found = inner.find_closest_biome(&search);
log::info!("Horizon: title backdrop: {BIOME} at {found:?}");
let (_, p) = found?;
let _ = std::fs::write(&cache, format!("{} {}\n", p[0], p[2]));
Some((p[0], p[2]))
});
*spot.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Some(found.unwrap_or((0, 0))));
})?;
Ok(())
}
fn backdrop_root(&self) -> Result<std::path::PathBuf> {
Ok(std::env::current_dir()?
.join("saves")
.join(".horizon-title")
.join("backdrop"))
}
/// The built generator streams the backdrop's chunks.
fn install_backdrop(&mut self, inner: Arc<VanillaTerrain>) -> Result<()> {
let seed = seed();
let save = Arc::new(SaveDir::new(self.backdrop_root()?.join(format!("{seed}"))));
let (res, pack) = (&*self.res, &self.pack);
let mut streaming = Streaming::new(
Arc::new(Persistent {
inner,
save: save.clone(),
registry: pack.assets.clone(),
biomes: res.biomes().clone(),
}),
pack.context.clone(),
res.threads().min(6),
);
streaming.radius = self.settings.render_distance.clamp(4, 10);
streaming.biome_blend = self.settings.biome_blend;
streaming.smooth_lighting = self.settings.smooth_lighting;
streaming.cutout_leaves = self.settings.cutout_leaves;
streaming.cull_leaves = self.settings.horizon.cull_leaves;
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
streaming.paused = true;
self.streaming = streaming;
self.save = save;
self.backdrop.installed = true;
Ok(())
}
fn stop_backdrop(&mut self) {
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.backdrop = Backdrop::default();
}
}
@@ -24,6 +24,7 @@ impl Game<'_> {
let ticking = self.block_ticking_area();
let mut spawners = Vec::new();
let mut trial_spawners = Vec::new();
self.item_columns = Some(Default::default());
for slot in self.world.block_entity_tickers() {
let pos = slot.0;
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
@@ -56,6 +57,8 @@ impl Game<'_> {
_ => {}
}
}
// Items move between passes: the columns hold for this one only.
self.item_columns = None;
self.update_song_volumes();
self.scan_for_hearts();
// The client ticks (particles, sounds, the turning mob) are for a
+10 -3
View File
@@ -57,7 +57,7 @@ pub(super) const CHEST_SLOTS: usize = 27;
const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
const SEAT: f64 = 0.1875 - 0.6;
pub(super) const SEAT: f64 = 0.1875 - 0.6;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -295,7 +295,7 @@ impl Game<'_> {
}
}
/// `AbstractBoat.tick` for one boat (`controlled` when the player rides it).
fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
pub(super) fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
b.previous = b.position;
b.previous_yaw = b.yaw;
b.old_status = b.status;
@@ -448,9 +448,12 @@ impl Game<'_> {
pub(super) fn tick_boats(&mut self) {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// A server player's boat is driven by its client (lan_vehicles.rs).
let driven = self.guest_vehicles();
for i in 0..self.boats.len() {
let p = self.boats[i].position;
if Some(self.boats[i].id) == self.riding
|| driven.contains(&self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
continue;
@@ -659,7 +662,11 @@ impl Game<'_> {
self.right = false;
return true;
}
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
if sneaking
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
|| self.ridden_by_guest(self.boats[i].id)
{
return false;
}
self.riding = Some(self.boats[i].id);
+10 -1
View File
@@ -165,6 +165,9 @@ impl Game<'_> {
if let Some(value) = self.events_command(&words, reply)? {
return Ok(value);
}
if let Some(value) = self.saves_control(&words, line, reply)? {
return Ok(value);
}
if let Some(value) = self.options_control(&words, line)? {
return Ok(Some(value));
}
@@ -192,6 +195,7 @@ impl Game<'_> {
.chain(super::ui_control::HELP)
.chain(super::options::CONTROL_HELP)
.chain(super::events::HELP)
.chain(super::saves_control::HELP)
.chain(super::pad::HELP)
.collect::<Vec<_>>() }))),
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
@@ -564,6 +568,8 @@ impl Game<'_> {
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"extras" => Page::Extras,
"horizon" => Page::Horizon,
"shader-packs" => Page::ShaderPacks,
other => return Err(format!("unknown options page {other}").into()),
};
if self.screen == Screen::Playing && self.title.is_none() {
@@ -736,6 +742,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
}
}
@@ -903,7 +910,7 @@ impl Game<'_> {
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
"dirty_light": self.world.dirty_light.len(),
"renderer": renderer,
"display": self.window.as_ref().and_then(|w| w.current_monitor()).map(|m| json!({
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
"width": m.size().width,
"height": m.size().height,
"scale": m.scale_factor(),
@@ -926,6 +933,8 @@ impl Game<'_> {
status["heard"] = json!(self.audio.heard);
// Boats, minecarts, armor stands, item frames and paintings.
status["objects"] = json!(self.objects_status());
status["lightning"] = json!({"bolts": self.bolts.len(), "struck": self.lightning_seen[0],
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
status
+31 -5
View File
@@ -330,6 +330,10 @@ impl Game<'_> {
"held": m.extra.main_hand.as_deref(),
"mount": m.extra.mount,
"rider": m.extra.rider,
// A player's input this tick, and whether it steers.
"ridden": m.rider.map(|r| r.steer),
"controlled": horizon_client::mobs::equine::controlled(m),
"saddled": m.extra.saddled,
"enchants": horizon_client::mobs::gear::gear(m).map(|g| g.slots.iter().map(|s| s.iter().map(|(e, l)| format!("{e}:{l}")).collect::<Vec<_>>()).collect::<Vec<_>>()),
"ints": m.extra.ints,
"pois": [m.extra.home, m.extra.bed, m.extra.meeting],
@@ -390,6 +394,7 @@ impl Game<'_> {
player,
others,
others_grid: None,
others_index: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
game_time: self.world.game_time,
day_time: self.world.day_time,
@@ -402,7 +407,7 @@ impl Game<'_> {
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: self.mob_teams(),
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.res.items()),
difficulty: self.local_difficulty(
self.player
@@ -499,13 +504,26 @@ impl Game<'_> {
// The level random, lent to the mobs (brain behaviours draw from
// it) and taken back after their ticks.
let mut scratch = self.world.random.clone();
let mut pushes = Vec::new();
let mut pushes = horizon_client::mobs::PendingPushes::default();
let mut keep = Vec::with_capacity(mobs.len());
let mut grid =
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
// All but the snapshot is the same for every mob of the tick, so
// build it once: the player view and the teams scan every UUID.
let base = self.mob_world_base(&[]);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
for (i, s) in snapshot.iter().enumerate() {
index.entry(s.id).or_insert(i as u32);
}
for i in 0..mobs.len() {
let mut w = self.mob_world_base(&snapshot);
w.others_grid = Some(&grid);
let mut w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
..base.clone()
};
let m = &mut mobs[i];
w.nearest_player = super::natural_spawning::nearest_player(
&players,
@@ -589,7 +607,11 @@ impl Game<'_> {
if let Some(host) = self.lan.as_mut()
&& !guests.is_empty()
{
bindings.retain(|id, _| mobs.iter().any(|m| m.id == *id));
// A set: a scan of every mob for each binding is quadratic, and a
// server's loaded mobs run into the thousands.
let live: horizon_client::fast_hash::FxHashSet<u32> =
mobs.iter().map(|m| m.id).collect();
bindings.retain(|id, _| live.contains(id));
host.mob_players = bindings;
}
self.mobs.append(&mut mobs);
@@ -2929,6 +2951,10 @@ impl Game<'_> {
let scale = f64::from(self.settings.entity_distance);
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
if mob.invisible {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the entity distance option.
+1 -1
View File
@@ -753,7 +753,7 @@ impl Game<'_> {
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
for m in &self.mobs {
if m.removed || m.has_effect("minecraft:invisibility") {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
let pos = [
@@ -1736,7 +1736,9 @@ impl Game<'_> {
mob.head_pitch = old.head_pitch;
mob.persistent = old.persistent;
mob.custom_name = old.custom_name.clone();
mob.custom_name_text = old.custom_name_text.clone();
mob.name_visible = old.name_visible;
mob.invisible = old.invisible;
mob.no_ai = old.no_ai;
mob.silent = old.silent;
mob.no_gravity = old.no_gravity;
+5 -40
View File
@@ -138,47 +138,12 @@ impl Game<'_> {
}
}
/// SkyRenderer.buildEndSky: six faces of end_sky.png tiled 16 times,
/// tinted 0x282828. The atlas does not wrap, so each face is 16×16 quads.
pub(super) fn end_sky_geometry(&self) -> Vec<Vertex> {
let mut out = Vec::with_capacity(6 * 256 * 4);
let Some(r) = self.gui.atlas.sprites.get("environment/end_sky").copied() else {
/// SkyRenderer.renderEndFlash, drawn after the renderer's resident sky mesh.
pub(super) fn end_flash_geometry(&self) -> Vec<Vertex> {
let mut out = Vec::new();
// The old combined sky/flash draw also skipped the flash without a sky sprite.
if !self.gui.atlas.sprites.contains_key("environment/end_sky") {
return out;
};
use std::f32::consts::FRAC_PI_2;
let faces = [
glam::Mat4::IDENTITY,
glam::Mat4::from_rotation_x(FRAC_PI_2),
glam::Mat4::from_rotation_x(-FRAC_PI_2),
glam::Mat4::from_rotation_x(std::f32::consts::PI),
glam::Mat4::from_rotation_z(FRAC_PI_2),
glam::Mat4::from_rotation_z(-FRAC_PI_2),
];
let step = 200.0 / 16.0;
for pose in faces {
for a in 0..16 {
for b in 0..16 {
let (x0, z0) = (-100.0 + step * a as f32, -100.0 + step * b as f32);
let (x1, z1) = (x0 + step, z0 + step);
let corners = [
(x0, z0, [r.u0, r.v0]),
(x0, z1, [r.u0, r.v1]),
(x1, z1, [r.u1, r.v1]),
(x1, z0, [r.u1, r.v0]),
];
for (x, z, uv) in corners {
let p = pose.transform_point3(glam::Vec3::new(x, -100.0, z));
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: 2,
color: 0xFF28_2828,
light: 0,
});
}
}
}
}
// SkyRenderer.renderEndFlash: the end_flash sprite at height 100, scale 60.
let intensity = self.end.flash.intensity(self.clock.fraction() as f32);
+59 -12
View File
@@ -1,4 +1,5 @@
//! Play with friends, host side: "Invite friends" opens the world to LAN if
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
@@ -6,7 +7,12 @@
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, front::FriendsView, text::Text};
use horizon_client::{
Result,
friends_screen::{self, Action, FriendsView},
hud::HudBatch,
text::Text,
};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::time::{Duration, Instant};
@@ -52,10 +58,12 @@ impl Game<'_> {
if self.friends.is_some() {
return Ok(());
}
if self.remote.is_some() || self.title.is_some() {
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan.is_none();
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
@@ -153,7 +161,7 @@ impl Game<'_> {
}
if announce {
self.system_message(Text::literal(
"Your invite code is ready in the pause menu: copy it and send it to your friends.",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
));
}
}
@@ -169,16 +177,55 @@ impl Game<'_> {
}
}
/// What the pause menu shows.
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
}
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the Shader Packs page opens its folder.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_folder(std::path::Path::new(url));
}
}
Action::Done => self.screen = super::Screen::Paused,
}
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = self
.lan
.as_ref()
.map(|h| h.hub.guests.iter().map(|g| g.name.clone()).collect())
.unwrap_or_default();
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none(),
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
+18 -1
View File
@@ -9,12 +9,13 @@ use horizon_assets::item_model::{
};
use horizon_client::{
block_entity_render::{diffuse_lights, vertex_light},
hud::HudBatch,
hud::{HudBatch, Rect},
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
items::ItemStack,
mesher::Vertex,
};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap};
/// The game's answers to what the resolver cannot read from the stack.
pub(super) struct Env<'a, 'g> {
@@ -126,6 +127,8 @@ pub(super) fn local_time(pattern: &str) -> String {
/// Banner and shield pattern textures from the banner pattern registry.
pub(super) struct Host<'a> {
banners: &'a horizon_client::banner::Banners,
sprites: &'a HashMap<String, Rect>,
skins: &'a RefCell<horizon_client::skins::Downloader>,
}
impl SpecialHost for Host<'_> {
fn banner_texture(&self, pattern: &str) -> Option<String> {
@@ -138,6 +141,18 @@ impl SpecialHost for Host<'_> {
.pattern(pattern)
.map(|_| self.banners.shield_texture(pattern))
}
fn player_skin(&self, profile: &Value) -> Option<String> {
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
// accepts a texture property without a signature, unlike the tab list.
let url = horizon_client::skins::profile_skin_url(profile)?;
let location =
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
let _ = self
.skins
.borrow_mut()
.state(horizon_client::skins::Kind::Skin, &url);
self.sprites.contains_key(&location).then_some(location)
}
}
/// `ItemOwner` of an entity holding or showing an item.
@@ -177,6 +192,8 @@ impl Game<'_> {
pub(super) fn item_host(&self) -> Host<'_> {
Host {
banners: &self.res.stations().banners,
sprites: &self.gui.atlas.sprites,
skins: &self.skin_downloads,
}
}
/// The client level as item models read it.
+17 -1
View File
@@ -800,6 +800,16 @@ impl Game<'_> {
self.send_admin_feedback(&mut host, None);
}
let state = self.lan_host_state(&host);
// An animal whose rider left the game (or the level) stops taking
// its input.
for m in &mut self.mobs {
if m.rider.is_some()
&& self.riding != Some(m.id)
&& !host.hub.guests.iter().any(|g| g.vehicle == Some(m.id))
{
m.rider = None;
}
}
// 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();
@@ -807,7 +817,13 @@ impl Game<'_> {
if has_guests {
let host_entity = state.as_ref().map(|s| s.entity_id);
self.lan_assign_ids();
self.lan_riding(host_entity, &mut host.hub.riding);
let guests: Vec<(u32, i32)> = host
.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &mut host.hub, &mut states);
} else {
states.clear();
+87 -9
View File
@@ -49,12 +49,16 @@ pub(super) struct GuestPlay {
/// Swings the guest's actions started this tick, for the players around
/// (`LivingEntity.swing` sends ClientboundSwingAnimationPacket).
swings: u32,
/// The guest's riding jump charge (`LocalPlayer.jumpRidingTicks`).
mount: super::mounts::MountState,
/// The trading screen the guest has open.
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
/// The inventory menu and the crafting table the guest crafts in.
pub(super) crafting: super::lan_crafting::GuestCrafting,
/// The workstation menu the guest has open (game/lan_stations.rs).
pub(super) station: Option<super::lan_stations::GuestStation>,
/// The mount inventory the guest has open (game/lan_mount_menu.rs).
pub(super) mount_window: Option<super::lan_mount_menu::GuestMount>,
/// The lectern the guest reads (game/lan_stations.rs).
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
/// What the guest's body was given for its effects and enchantments.
@@ -289,10 +293,12 @@ impl Game<'_> {
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
let swing = std::mem::replace(&mut self.swing, play.swing);
std::mem::swap(&mut self.mount, &mut play.mount);
let (left, right) = (self.left, self.right);
self.left = play.digging.is_some();
self.right = play.using.is_some() || self.using.is_some();
let riding = self.riding.take();
// The guest's vehicle (Entity.vehicle) for its turn.
let riding = std::mem::replace(&mut self.riding, g.vehicle);
let host_station = self.station.take();
// The guest's bed, sleep counter and respawn point; the host's
// respawn point, anchor and action bar stay theirs.
@@ -467,7 +473,7 @@ impl Game<'_> {
(self.respawn_config, self.level.spawn) = respawn;
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
let guest_message = std::mem::replace(&mut self.message, message);
self.riding = riding;
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
self.left = left;
self.right = right;
play.mining = std::mem::replace(&mut self.mining, mining);
@@ -477,6 +483,7 @@ impl Game<'_> {
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
play.swing = std::mem::replace(&mut self.swing, swing);
std::mem::swap(&mut self.mount, &mut play.mount);
self.container = container;
self.screen = screen;
self.sign_edit = host_sign;
@@ -488,9 +495,16 @@ impl Game<'_> {
_ => None,
};
self.command_block_screen = host_command_block;
self.mount_menu = host_mount_menu;
// 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();
self.keys = host_keys;
let g = &mut host.hub.guests[i];
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
if guest_vehicle.is_none() {
g.vehicle_good = None;
}
g.vehicle = guest_vehicle;
let fell_asleep = bed.is_some() && g.bed.is_none();
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
g.sleeping = g.bed.is_some();
@@ -549,7 +563,24 @@ impl Game<'_> {
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
g.motion = Some(Box::new(m));
if moved > 0.0001 || turned || teleported {
// Entity.teleportTo takes a rider out of its vehicle.
let left_vehicle = match g.vehicle.filter(|_| teleported) {
Some(v) => {
(g.vehicle, g.vehicle_good) = (None, None);
Some(v)
}
None => left_vehicle,
};
// ServerPlayer.dismountTo: the client hears it is out of the
// vehicle before the teleport beside it. A rider sits where its
// vehicle puts it (lan_vehicles.rs), with no teleport.
if let Some(vehicle) = left_vehicle {
g.send(&C::SetPassengers(play::SetPassengers {
vehicle: vehicle as i32,
passengers: Vec::new(),
}));
}
if g.vehicle.is_none() && (moved > 0.0001 || turned || teleported) {
host.hub.teleport(i, Some(at));
}
}
@@ -589,6 +620,9 @@ impl Game<'_> {
if opened_table {
self.lan_open_crafting(host, conn);
}
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
}
/// `ServerLevel.players()` in turn: the host's own player, then each
@@ -900,16 +934,22 @@ impl Game<'_> {
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
if let Some((position, eye)) = self.look_at_entity(host, gi, p.id) {
let _ = (position, eye);
let before = host.hub.guests[gi].vehicle;
if self
.look_at_entity(host, gi, p.id, Some(p.location))
.is_some()
{
let swung = self.as_guest(host, conn, |g| {
let swings = g.swings_started;
let _ = g.use_item();
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_boarded(host, conn, before);
}
}
S::MoveVehicle(p) => self.guest_move_vehicle(host, conn, &p),
S::PaddleBoat(p) => self.guest_paddle_boat(host, conn, [p.left, p.right]),
S::SetCreativeModeSlot(p) => {
let g = &mut host.hub.guests[gi];
if !g.creative() || !(0..46).contains(&p.slot) {
@@ -938,6 +978,14 @@ impl Game<'_> {
S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_station_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_lectern_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_mount_click(host, conn, &p) => {}
// ServerboundPlayerCommandPacket OPEN_INVENTORY: the ridden
// mount's inventory (`HasCustomInventoryScreen`).
S::PlayerCommand(p) if p.action == 5 => {
self.as_guest(host, conn, |g| {
g.open_ridden_mount_menu();
});
}
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
@@ -955,7 +1003,8 @@ impl Game<'_> {
}
S::ContainerClose(p)
if self.lan_crafting_closed(host, conn, Some(p.container))
|| self.lan_station_closed(host, conn, Some(p.container)) =>
|| self.lan_station_closed(host, conn, Some(p.container))
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
{
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
@@ -1147,19 +1196,39 @@ impl Game<'_> {
}
}
/// Aim the swapped-in player at an entity; returns its centre and the eye.
/// Aim the swapped-in player at an entity; returns where it aims and the
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
/// the point the client hit (the interact packet's location) when that
/// is on it, else at its centre.
fn look_at_entity(
&mut self,
host: &mut LanHost,
gi: usize,
id: i32,
location: Option<[f64; 3]>,
) -> Option<([f64; 3], [f64; 3])> {
let object = || {
let (lo, hi, origin) = self.object_target(id)?;
let middle = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
let Some(l) = location else {
return Some(middle);
};
let point = [0, 1, 2].map(|k| origin[k] + l[k]);
// A little inside the box, so the ray from the eye meets it.
let inside = [0, 1, 2].map(|k| {
let margin = ((hi[k] - lo[k]) / 2.0).min(0.01);
point[k].clamp(lo[k] + margin, hi[k] - margin)
});
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
Some(if on { inside } else { middle })
};
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
Some(m) => [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
],
None if self.object_target(id).is_some() => object()?,
// A dragon part or an end crystal (game/dragon.rs).
None => {
let b = self.tracked_special_box(id)?;
@@ -1210,7 +1279,7 @@ impl Game<'_> {
return;
}
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
if self.look_at_entity(host, gi, id).is_none() {
if self.look_at_entity(host, gi, id, None).is_none() {
return;
}
self.as_guest(host, conn, |g| {
@@ -1475,6 +1544,7 @@ impl Game<'_> {
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
self.lan_merchant_tick(host);
self.lan_stations_tick(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
.hub
@@ -1676,6 +1746,7 @@ impl Game<'_> {
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
/// sounds are heard at the guest.
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
self.guest_vehicle_tick(host, conn);
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
@@ -1730,6 +1801,13 @@ impl Game<'_> {
break;
}
}
// A ServerPlayer travels on the server too
// (MoveSimulationType.AUTHORITATIVE_SIDE_AND_SERVER), so every tick
// on a ladder or vine LivingEntity.handleOnClimbable resets its fall
// distance: climbing down doesn't hurt where it lands (#1255).
if w.climbable().unwrap_or(false) {
w.player.fall_distance = 0.0;
}
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
+15 -1
View File
@@ -89,8 +89,17 @@ fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
impl Game<'_> {
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
/// host in its boat or minecart.
pub(super) fn lan_riding(&self, host_entity: Option<i32>, riding: &mut Riding) {
pub(super) fn lan_riding(
&self,
host_entity: Option<i32>,
guests: &[(u32, i32)],
riding: &mut Riding,
) {
riding.clear();
// The server's players in their boats and minecarts.
for &(vehicle, guest) in guests {
riding.add(vehicle as i32, guest);
}
for m in self.mobs.iter().filter(|m| !m.removed) {
if let Some(vehicle) = m.extra.mount {
riding.add(vehicle as i32, m.id as i32);
@@ -264,6 +273,7 @@ impl Game<'_> {
self.tridents.iter_mut().for_each(|t| give(&mut t.id));
self.rockets.iter_mut().for_each(|r| give(&mut r.id));
self.crystals.iter_mut().for_each(|c| give(&mut c.id));
self.bolts.iter_mut().for_each(|b| give(&mut b.id));
}
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
@@ -275,6 +285,10 @@ impl Game<'_> {
None => 0,
};
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
// LightningBolt: each client draws and flashes its own.
for b in self.bolts.iter().filter(|b| b.id != 0) {
out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false));
}
for t in self.tnts.iter().filter(|t| t.id != 0) {
let mut e = state(t.id, "minecraft:tnt", at(t.position), 0.0, t.on_ground);
e.velocity = at(t.velocity);
@@ -0,0 +1,222 @@
//! The horse inventory for a server's players (`ServerPlayer.openHorseInventory`):
//! shift + use on a tamed horse, donkey, mule, camel or llama, or the
//! inventory key while riding one (`ServerboundPlayerCommandPacket`
//! OPEN_INVENTORY), opens `HorseInventoryMenu` over the mount with
//! `ClientboundMountScreenOpenPacket`. Clicks run on the menu engine
//! (`Kind::Mount`) with the mount's saddle, body item and chest copied in and
//! written back, and the slots go to the client after each change, as
//! `broadcastChanges` does.
use super::{Game, lan::LanHost, lan_crafting::input};
use horizon_client::{
items::ItemStack,
menu::{Kind, Menu},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open mount menu.
pub(super) struct GuestMount {
window: i32,
mob: u32,
columns: usize,
state: i32,
/// The menu's slots and the carried stack the client was last sent.
sent: Option<(Vec<Option<ItemStack>>, Option<ItemStack>)>,
}
impl Game<'_> {
/// `ServerPlayer.openHorseInventory` for mount `mob`: the open menu
/// closes, the client gets the mount screen and the menu's slots.
pub(super) fn lan_open_mount(&mut self, host: &mut LanHost, conn: u32, mob: u32) {
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
self.lan_mount_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
return;
};
host.play.entry(conn).or_default().mount_window = Some(GuestMount {
window,
mob,
columns,
state: 0,
sent: None,
});
self.lan_send_mount(host, conn);
}
/// `HorseInventoryMenu` over mount `index`, with the player's inventory
/// loaded by the caller.
fn mount_engine_menu(&self, index: usize, columns: usize) -> Menu {
let env = self.menu_env();
let mut menu = Menu::new(Kind::Mount(columns));
menu.mount = Some(self.mobs[index].kind.id.to_string());
let (saddle, body, items) = self.mount_contents(index);
menu.set_slot(&env, 0, saddle);
menu.set_slot(&env, 1, body.flatten());
for (k, item) in items.into_iter().take(columns * 3).enumerate() {
menu.set_slot(&env, 2 + k, item);
}
menu
}
/// The menu's slots (the mount's, then the inventory and the hotbar)
/// and the carried stack, sent when they changed since the last time.
fn lan_send_mount(&mut self, host: &mut LanHost, conn: u32) {
let Some(gm) = host.play.get(&conn).and_then(|p| p.mount_window.as_ref()) else {
return;
};
let (mob, columns) = (gm.mob, gm.columns);
let Some(index) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
return;
};
let (saddle, body, items) = self.mount_contents(index);
let mut slots = vec![saddle, body.flatten()];
slots.extend(items.into_iter().take(columns * 3));
slots.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone()));
let carried = g.carried.clone();
let now = Some((slots, carried));
let Some(gm) = host
.play
.get_mut(&conn)
.and_then(|p| p.mount_window.as_mut())
else {
return;
};
if gm.sent == now {
return;
}
gm.state = (gm.state + 1) & 0x7FFF;
let (window, state) = (gm.window, gm.state);
let (slots, carried) = now.clone().unwrap_or_default();
gm.sent = now;
let content = C::ContainerSetContent(play::ContainerSetContent {
container: window,
state,
items: slots.iter().map(|s| host.hub.stack(s)).collect(),
carried: host.hub.stack(&carried),
});
host.hub.send_to(conn, &content);
}
/// A click on the guest's mount window; false for other windows.
pub(super) fn lan_mount_click(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::ContainerClick,
) -> bool {
let Some((mob, columns)) = host
.play
.get(&conn)
.and_then(|pl| pl.mount_window.as_ref())
.filter(|m| m.window == p.container)
.map(|m| (m.mob, m.columns))
else {
return false;
};
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& !host.hub.guests[gi].spectator()
&& let Some(index) = self.mobs.iter().position(|m| m.id == mob)
{
let mut menu = self.mount_engine_menu(index, columns);
self.guest_menu_op(host, gi, &mut menu, |menu, env| {
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
});
self.lan_mount_store(index, columns, &menu);
}
// The client is told what the server has, whatever it predicted.
if let Some(gm) = host
.play
.get_mut(&conn)
.and_then(|pl| pl.mount_window.as_mut())
{
gm.sent = None;
}
self.lan_send_mount(host, conn);
true
}
/// The engine menu's mount slots back on mount `index`.
fn lan_store_slots(menu: &Menu, columns: usize) -> Vec<Option<ItemStack>> {
(0..columns * 3)
.map(|k| menu.slot(2 + k).cloned())
.collect()
}
fn lan_mount_store(&mut self, index: usize, columns: usize, menu: &Menu) {
let (_, body, _) = self.mount_contents(index);
let body = body.map(|_| menu.slot(1).cloned());
let items = Self::lan_store_slots(menu, columns);
self.mount_store(index, menu.slot(0).cloned(), body, items);
}
/// `AbstractContainerMenu.removed` of the guest's mount menu (`window`;
/// None closes it whichever it is): the carried stack goes back to the
/// inventory. False when no such window is open.
pub(super) fn lan_mount_closed(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
) -> bool {
let closed = host
.play
.get_mut(&conn)
.and_then(|p| {
p.mount_window
.take_if(|m| window.is_none_or(|w| m.window == w))
})
.is_some();
if closed
&& let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& let Some(carried) = host.hub.guests[gi].carried.take()
{
self.lan_place_back(host, gi, vec![carried]);
}
closed
}
/// Each tick: a mount menu whose mount died or is out of reach closes
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
/// interaction range plus 4 of the eyes), and the others follow the
/// mount's and the inventory's slots.
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
let open: Vec<(u32, u32, i32)> = host
.play
.iter()
.filter_map(|(c, p)| p.mount_window.as_ref().map(|m| (*c, m.mob, m.window)))
.collect();
for (conn, mob, window) in open {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
m.alive() && d < 7.0 * 7.0
});
if valid {
self.lan_send_mount(host, conn);
} else {
self.lan_mount_closed(host, conn, Some(window));
host.hub.send_to(
conn,
&C::ContainerClose(play::ContainerClose { container: window }),
);
}
}
}
}
@@ -0,0 +1,308 @@
//! Boats and minecarts for a server's players: LAN guests, players on a
//! dedicated server, and single player's window on its server thread.
//!
//! A player aims at a vehicle, item frame, armor stand or painting as at a
//! mob (`ServerGamePacketListenerImpl.handleInteract`), and the game's own
//! code for it runs as that player (`AbstractBoat.interact` seats it,
//! `VehicleEntity.hurtServer` breaks the vehicle). A boat is driven by its
//! rider's client (`handleMoveVehicle`, `handlePaddleBoat`); a minecart moves
//! here with the rider's input (`ServerPlayer.getLastClientMoveIntent`).
//! Sneaking gets out (`Player.rideTick`: `wantsToStopRiding`), and the client
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, boats::SEAT, lan::LanHost};
use horizon_client::collisions::SceneCollisions;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
text::translatable,
};
/// `PlayerInput` flags, as the server player's client last sent them.
pub(super) fn input_keys(flags: u8) -> Keys {
Keys {
forward: flags & 1 != 0,
backward: flags & 2 != 0,
left: flags & 4 != 0,
right: flags & 8 != 0,
jump: flags & 16 != 0,
shift: flags & 32 != 0,
sprint: flags & 64 != 0,
..Keys::default()
}
}
fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
let (lo, hi) = (b.min_position(), b.max_position());
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
}
impl Game<'_> {
/// The box of a boat, minecart, armor stand, item frame or painting, and
/// the point an interact packet's location is measured from (the
/// client's `remote_object_hit` uses the same points).
pub(super) fn object_target(&self, id: i32) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
let id = u32::try_from(id).ok()?;
let at = |v: Vec3| [v.x, v.y, v.z];
let centre = |p: [i32; 3]| p.map(|v| f64::from(v) + 0.5);
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
let (lo, hi) = corners(b.bounds());
return Some((lo, hi, at(b.position)));
}
if let Some(c) = self.carts.iter().find(|c| c.id == id) {
let (lo, hi) = corners(c.bounds());
return Some((lo, hi, at(c.position)));
}
if let Some(a) = self.armor_stands.iter().find(|a| a.id == id) {
let (lo, hi) = corners(a.bounds());
return Some((lo, hi, at(a.position)));
}
if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
let (lo, hi) = f.bounds();
return Some((lo, hi, centre(f.pos)));
}
let p = self.paintings.iter().find(|p| p.id == id)?;
let (lo, hi) = p.bounds();
Some((lo, hi, centre(p.pos)))
}
/// The vehicles the server's players ride: they move with their riders,
/// not in `tick_boats` and `tick_carts`.
pub(super) fn guest_vehicles(&self) -> Vec<u32> {
self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub.guests.iter().filter_map(|g| g.vehicle).collect()
})
}
/// Whether a server player other than the one acting rides `vehicle`
/// (`Entity.canAddPassenger`: a boat or minecart seats one player here).
pub(super) fn ridden_by_guest(&self, vehicle: u32) -> bool {
self.lan.as_ref().is_some_and(|h| {
h.hub
.guests
.iter()
.any(|g| Some(g.conn) != self.lan_acting && g.vehicle == Some(vehicle))
})
}
/// `Player.rideTick` for a server player, before its moves this tick:
/// it stays in its seat, steers its minecart, or gets out.
pub(super) fn guest_vehicle_tick(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let g = &host.hub.guests[gi];
let Some(vehicle) = g.vehicle else {
return;
};
let (keys, dead) = (input_keys(g.input), g.dead || g.health <= 0.0);
let boat = self.boats.iter().position(|b| b.id == vehicle);
let cart = self.carts.iter().any(|c| c.id == vehicle);
let mount = self.mobs.iter().any(|m| m.id == vehicle);
// The vehicle is gone (broken, in another level) or the rider died:
// its client drops it with the entity or the death screen.
if dead || (boat.is_none() && !cart && !mount) {
let g = &mut host.hub.guests[gi];
(g.vehicle, g.vehicle_good) = (None, None);
return;
}
if let Some(i) = boat {
// AbstractBoat.tick on the server for a boat its rider drives:
// only the hurt animation and the damage wear off here.
let b = &mut self.boats[i];
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
}
// Getting out goes through leave_guest, which tells the client and
// teleports it (ServerPlayer.dismountTo).
let seat = self.as_guest(host, conn, |game| {
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
if keys.shift {
game.dismount(i);
return None;
}
let p = game.boats[i].position;
return Some([p.x, p.y + SEAT, p.z]);
}
// The minecart moves with the rider's input, and lets it out when
// it sneaks or the cart breaks. An animal takes the rider's input
// for its own tick (`travelRidden`), or lets it off.
if !game.tick_cart_riding(keys) && game.tick_mount_riding(keys) {
game.riding?;
game.position_rider();
}
game.riding?;
let p = game.player.as_ref()?.position;
Some([p.x, p.y, p.z])
});
if let Some(Some(at)) = seat {
let g = &mut host.hub.guests[gi];
g.previous_position = g.position;
g.position = at;
}
}
/// After an interaction: a player that got into a vehicle sits in it,
/// and its last good vehicle position is where the vehicle is.
pub(super) fn guest_boarded(&mut self, host: &mut LanHost, conn: u32, before: Option<u32>) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let Some(vehicle) = host.hub.guests[gi].vehicle else {
return;
};
if before == Some(vehicle) {
return;
}
// The host in the window rides it already.
if self.riding == Some(vehicle) {
host.hub.guests[gi].vehicle = before;
return;
}
let at = self
.boats
.iter()
.find(|b| b.id == vehicle)
.map(|b| b.position)
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == vehicle)
.map(|c| c.position)
});
if let Some(p) = at {
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([p.x, p.y, p.z]);
g.previous_position = g.position;
g.position = [p.x, p.y + SEAT, p.z];
g.on_ground = false;
return;
}
// An animal: the rider sits where `positionRider` puts it.
let seat = self.as_guest(host, conn, |game| {
game.position_rider();
game.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z])
});
if let Some(Some(at)) = seat {
let g = &mut host.hub.guests[gi];
g.previous_position = g.position;
g.position = at;
g.on_ground = false;
}
}
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat its rider
/// drives goes where the client says, unless it moved too quickly or
/// wrongly; then it stays, and the client is told where it is.
pub(super) fn guest_move_vehicle(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::MoveVehicleRequest,
) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let [x, y, z] = p.position;
if !(x.is_finite() && y.is_finite() && z.is_finite())
|| !p.yaw.is_finite()
|| !p.pitch.is_finite()
{
host.hub.kick(
conn,
translatable("multiplayer.disconnect.invalid_vehicle_movement", &[]),
);
return;
}
let g = &host.hub.guests[gi];
if g.awaiting_teleport.is_some() || !g.loaded {
return;
}
let owner = host.hub.singleplayer_owner == Some(g.uuid);
let (vehicle, good) = (g.vehicle, g.vehicle_good);
let back = |host: &mut LanHost, position: Vec3, yaw: f32| {
let packet = play::MoveVehicle {
position: [position.x, position.y, position.z],
yaw,
pitch: 0.0,
};
host.hub.send_to(conn, &C::MoveVehicle(packet));
};
// Only a boat is driven by its rider (getControllingPassenger); a
// minecart that is elsewhere on the client is put back.
let Some(i) = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)) else {
if let Some(c) = vehicle.and_then(|v| self.carts.iter().find(|c| c.id == v))
&& c.position.distance_squared(Vec3::new(x, y, z)) > 1.0
{
back(host, c.position, c.yaw);
}
return;
};
let b = &self.boats[i];
let old = b.position;
let good = good.map_or(old, |g| Vec3::new(g[0], g[1], g[2]));
let target = Vec3::new(
x.clamp(-3.0E7, 3.0E7),
y.clamp(-2.0E7, 2.0E7),
z.clamp(-3.0E7, 3.0E7),
);
// Moved too quickly: more than 10 blocks past its own motion.
let moved = target.distance_squared(old) - b.velocity.length_squared();
if moved > 100.0 && !owner {
log::warn!("boat {} moved too quickly! {moved}", b.id);
back(host, old, b.yaw);
return;
}
// vehicle.move(PLAYER, target - lastGood) with the boat's collisions:
// where it can't follow the client (a quarter block off, the height
// aside), it moved wrongly.
let bounds = b.bounds();
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let wanted = target.subtract(good);
let resolved = scene
.resolve(bounds, wanted, 0.0, b.on_ground)
.unwrap_or(wanted);
let reached = old.add_vector(resolved);
let wrongly = (target.x - reached.x).powi(2) + (target.z - reached.z).powi(2) > 0.0625;
let free_before = scene.unobstructed(bounds).unwrap_or(true);
// isEntityCollidingWithAnythingNew: a box that was free is not there.
let moved_box = bounds.move_by(target.subtract(old));
let free_after = scene
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
.unwrap_or(true);
if free_before && (wrongly || !free_after) {
log::warn!("boat {} moved wrongly", b.id);
back(host, old, b.yaw);
return;
}
let b = &mut self.boats[i];
b.previous = old;
b.velocity = target.subtract(old);
b.position = target;
b.previous_yaw = b.yaw;
b.yaw = p.yaw;
b.on_ground = p.on_ground;
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
g.previous_position = g.position;
g.position = [target.x, target.y + SEAT, target.z];
}
/// `handlePaddleBoat`: the paddles of the boat the player drives.
pub(super) fn guest_paddle_boat(&mut self, host: &LanHost, conn: u32, paddles: [bool; 2]) {
let vehicle = host
.hub
.guests
.iter()
.find(|g| g.conn == conn)
.and_then(|g| g.vehicle);
if let Some(b) = self.boats.iter_mut().find(|b| Some(b.id) == vehicle) {
b.paddling = paddles;
}
}
}
+37 -75
View File
@@ -5,17 +5,13 @@
//! witches, mooshroom colours, turtles, burnt items), lightning rods, the sky
//! flash, the sounds and the branching additive bolt.
use super::Game;
use horizon_client::{
mesher::Vertex,
mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species},
};
use horizon_core::{
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
use horizon_core::{position::BlockPos, random::RandomSource};
pub(super) struct Bolt {
at: [f64; 3],
/// The entity id a server's players know it by (0 until it has one).
pub id: u32,
pub at: [f64; 3],
life: i32,
flashes: i32,
seed: i64,
@@ -137,7 +133,9 @@ impl Game<'_> {
let r = &mut self.world.random;
let seed = r.next_long();
let flashes = r.next_int_bound(3).unwrap_or(0) + 1;
self.lightning_seen[0] += 1;
self.bolts.push(Bolt {
id: 0,
at,
life: 2,
flashes,
@@ -146,6 +144,24 @@ impl Game<'_> {
converted: Vec::new(),
});
}
/// `ClientLevel.addEntity` for a server's lightning bolt: on a client it
/// only flashes, sounds and draws (`LightningBolt.tick` off the server).
pub(super) fn spawn_remote_bolt(&mut self, id: u32, at: [f64; 3]) {
self.bolts.retain(|b| b.id != id);
let r = &mut self.world.random;
let seed = r.next_long();
let flashes = r.next_int_bound(3).unwrap_or(0) + 1;
self.lightning_seen[0] += 1;
self.bolts.push(Bolt {
id,
at,
life: 2,
flashes,
seed,
visual_only: true,
converted: Vec::new(),
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) {
if self.ui.difficulty < 2 {
@@ -222,6 +238,9 @@ impl Game<'_> {
}
}
if b.life >= 0 {
if self.flash == 0 {
self.lightning_seen[1] += 1;
}
self.flash = 2;
if !b.visual_only {
self.thunder_hit(b);
@@ -349,71 +368,14 @@ impl Game<'_> {
|| e.stack.item.contains("netherite")
});
}
/// `LightningBoltRenderer`: 4 layers of a trunk and two branches, 8
/// segments of 16 blocks, from the bolt's seed.
pub(super) fn bolt_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
let white = self.gui.atlas.font.white();
let uv = [white.u0, white.v0];
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
for b in &self.bolts {
let o = (glam::DVec3::from_array(b.at) - camera).as_vec3();
let mut random = LegacyRandom::new(b.seed);
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
let (mut x_off, mut z_off) = (0f32, 0f32);
for i in (0..8).rev() {
xs[i] = x_off;
zs[i] = z_off;
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
}
for layer in 0..4 {
let mut random = LegacyRandom::new(b.seed);
for branch in 0..3 {
let trunk = branch == 0;
let start = 7 - branch;
let end = if trunk { 0 } else { start - 3 + 1 };
let mut sx = xs[start] - x_off;
let mut sz = zs[start] - z_off;
for segment in (end..=start).rev() {
let (ex, ez) = (sx, sz);
let spread = if trunk { 11 } else { 31 };
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
let mut top = 0.1 + layer as f32 * 0.2;
let mut bottom = top;
if trunk {
top *= segment as f32 * 0.1 + 1.0;
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
}
for (rx, rz, lx, lz) in [
(false, false, true, false),
(true, false, true, true),
(true, true, false, true),
(false, true, false, false),
] {
let s = |p: bool, r: f32| if p { r } else { -r };
let y0 = segment as f32 * 16.0;
let corners = [
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
];
for c in corners {
out.push(Vertex {
normal: [0; 4],
pos: (o + glam::Vec3::from_array(c)).to_array(),
uv,
layer: 2,
color,
light: 240 | 240 << 8,
});
}
}
}
}
}
}
/// Simulation-order render inputs; seeded geometry lives in the renderer.
pub(super) fn bolt_instances(&self) -> Vec<horizon_client::gfx::lightning::Bolt> {
self.bolts
.iter()
.map(|b| horizon_client::gfx::lightning::Bolt {
at: b.at,
seed: b.seed,
})
.collect()
}
}
+2 -1
View File
@@ -93,7 +93,8 @@ impl Game<'_> {
return false;
}
if tamed && sneaking {
// The horse inventory screen is not available yet.
// AbstractHorse.openCustomInventoryScreen.
self.open_mount_menu(index);
return true;
}
let m = &self.mobs[index];
+164
View File
@@ -327,6 +327,46 @@ impl Containers<'_> {
}
}
/// The block columns any item entity's box reaches, so that a hopper with
/// no item over it skips the scan of every item. Kept for one block-entity
/// pass, in which items do not move: removing one leaves a superset, still
/// right, and adding one (always pushed at the end, under a new id) or
/// removing the last rebuilds the columns.
#[derive(Default)]
pub(super) struct ItemColumns {
/// The item count and last item id when last asked, if built.
seen: Option<(usize, Option<u32>)>,
cells: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
}
impl ItemColumns {
/// Whether an item's box may reach into column (x, z).
fn may_reach(
&mut self,
items: &[horizon_client::entities::ItemEntity],
x: i32,
z: i32,
) -> bool {
let (len, last) = (items.len(), items.last().map(|i| i.id));
let stale = self
.seen
.is_none_or(|(seen_len, seen_last)| len > seen_len || last != seen_last);
self.seen = Some((len, last));
if stale {
self.cells.clear();
for item in items {
let b = item.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
for cx in lo.x.floor() as i32..=hi.x.floor() as i32 {
for cz in lo.z.floor() as i32..=hi.z.floor() as i32 {
self.cells.insert((cx, cz));
}
}
}
}
self.cells.contains(&(x, z))
}
}
fn bone_meal() -> ItemStack {
ItemStack {
item: "minecraft:bone_meal".into(),
@@ -648,6 +688,11 @@ impl Game<'_> {
f64::from(pos.2) + 1.0,
],
);
if let Some(columns) = self.item_columns.as_mut()
&& !columns.may_reach(&self.dropped, pos.0, pos.2)
{
return false;
}
let mut i = 0;
while i < self.dropped.len() {
let b = self.dropped[i].bounds();
@@ -880,3 +925,122 @@ impl Game<'_> {
true
}
}
#[cfg(test)]
mod item_column_tests {
use super::ItemColumns;
use horizon_client::{entities::ItemEntity, items::ItemStack};
use horizon_core::geometry::Vec3;
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
let at = Vec3::new(x, y, z);
ItemEntity {
health: 5,
fire_ticks: 0,
id,
stack: ItemStack::new("minecraft:stone", 1),
thrower: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
}
}
/// `hopper_suck`'s test: some item box meets the hopper's SUCK_AABB.
fn scan(items: &[ItemEntity], (x, y, z): (i32, i32, i32)) -> bool {
let (min, max) = (
Vec3::new(f64::from(x), f64::from(y) + 11.0 / 16.0, f64::from(z)),
Vec3::new(f64::from(x) + 1.0, f64::from(y) + 2.0, f64::from(z) + 1.0),
);
items.iter().any(|i| {
let b = i.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
lo.x < max.x
&& hi.x > min.x
&& lo.y < max.y
&& hi.y > min.y
&& lo.z < max.z
&& hi.z > min.z
})
}
/// One tick of empty hoppers looking for items: each scans every item
/// (before) or asks the columns first (after, build included).
#[test]
#[ignore]
fn hopper_suck_cost() {
for (hoppers, count) in [(500i32, 200u32), (2000, 500), (2000, 2000)] {
let mut seed = 99u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
let items: Vec<ItemEntity> = (0..count)
.map(|id| item(id, next(160.0), 64.0, next(160.0)))
.collect();
let at: Vec<(i32, i32, i32)> = (0..hoppers)
.map(|_| (next(160.0) as i32, 63, next(160.0) as i32))
.collect();
let t = std::time::Instant::now();
let a: Vec<bool> = at.iter().map(|&h| scan(&items, h)).collect();
let before = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let mut columns = ItemColumns::default();
let b: Vec<bool> = at
.iter()
.map(|&h| columns.may_reach(&items, h.0, h.2) && scan(&items, h))
.collect();
let after = t.elapsed().as_secs_f64() * 1e3;
assert_eq!(a, b);
println!("{hoppers} hoppers, {count} items: {before:.3} ms -> {after:.3} ms");
}
}
#[test]
fn no_hopper_with_an_item_over_it_is_skipped() {
let mut seed = 0x1234_5678_9abc_def1u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
let mut items: Vec<ItemEntity> = Vec::new();
let mut columns = ItemColumns::default();
let mut next_id = 0;
let mut skipped = 0;
for round in 0..3000 {
// Items come and go as hoppers take them and crafters drop them.
match round % 5 {
0 | 1 => {
next_id += 1;
// On edges too: a quarter of them sit on a block line.
let snap = |v: f64, edge: bool| if edge { v.floor() + 0.125 } else { v };
let edge = round % 4 == 0;
items.push(item(
next_id,
snap(next(24.0) - 12.0, edge),
64.0,
snap(next(24.0) - 12.0, edge),
));
}
2 if !items.is_empty() => {
let i = next(items.len() as f64) as usize;
items.remove(i);
}
_ => {}
}
let hopper = (next(26.0) as i32 - 13, 63, next(26.0) as i32 - 13);
let reach = columns.may_reach(&items, hopper.0, hopper.2);
let found = scan(&items, hopper);
assert!(
reach || !found,
"round {round}: skipped a hopper with an item over it"
);
skipped += usize::from(!reach);
}
assert!(skipped > 1000, "skipped only {skipped}");
}
}
+31
View File
@@ -275,6 +275,37 @@ impl Game<'_> {
}
self.maps.data.get_mut(&id)
}
/// `ClientboundMapItemDataPacket.handle`: the server's colours for a map
/// this client does not own (lobby map art, a framed map on a wall).
pub(super) fn remote_map_item_data(
&mut self,
p: &horizon_protocol::packets::play::MapItemData,
) {
let dimension = self.dimension_id();
let map = self
.maps
.data
.entry(p.map_id)
.or_insert_with(|| MapData::fresh(0.0, 0.0, p.scale.max(0) as u8, dimension));
map.locked = p.locked;
let Some(patch) = &p.patch else {
map.dirty = false;
return;
};
for x in 0..patch.width {
for y in 0..patch.height {
let Some(&color) = patch.colors.get((x + y * patch.width) as usize) else {
continue;
};
let (px, py) = (patch.start_x + x, patch.start_y + y);
if (0..128).contains(&px) && (0..128).contains(&py) {
map.update_color(px, py, color);
}
}
}
// The server owns a remote map: only the texture has to change.
map.dirty = false;
}
fn next_map_id(&mut self) -> i32 {
let last = *self.maps.last_id.get_or_insert_with(|| {
horizon_client::save::read_dat(&self.world_root.join("data/minecraft/maps/last_id.dat"))
+4 -1
View File
@@ -775,10 +775,13 @@ impl Game<'_> {
self.push_carts();
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
// A server player's minecart moves with its rider (lan_vehicles.rs).
let ridden = self.guest_vehicles();
let mut i = 0;
while i < self.carts.len() {
let p = self.carts[i].position;
if Some(self.carts[i].id) == self.riding
|| ridden.contains(&self.carts[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
i += 1;
@@ -986,7 +989,7 @@ impl Game<'_> {
let held = self.held().cloned();
match self.carts[i].kind {
CartKind::Rideable => {
if sneaking || self.riding.is_some() {
if sneaking || self.riding.is_some() || self.ridden_by_guest(self.carts[i].id) {
return false;
}
self.riding = Some(self.carts[i].id);
+177 -29
View File
@@ -1,12 +1,15 @@
//! The horse inventory (`HorseInventoryMenu`, `HorseInventoryScreen`): the
//! saddle and body armour slots, the 3x5 chest of donkeys and mules and the
//! mount drawn following the cursor. It opens with E while riding a tamed
//! mount or with shift + use on a tamed one.
//! saddle and body armour slots (a llama's carpet), the chest of donkeys,
//! mules and llamas and the mount drawn following the cursor. It opens with
//! E while riding a tamed mount or with shift + use on a tamed one. On a
//! server the screen shows the server's menu (`ClientboundMountScreenOpenPacket`)
//! and its clicks go out as container clicks; a server's players get theirs
//! from game/lan_mount_menu.rs.
use super::{Game, Screen};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
mobs::{Mob, Species, equine},
mobs::{Mob, Species, equine, llama},
};
/// Menu slot ids: the saddle, the body armour and the chest from 710.
@@ -17,11 +20,43 @@ pub(super) const CHEST: usize = 710;
const CHEST_SIZE: usize = 15;
const COLUMNS: usize = 5;
/// `AbstractHorse.getInventoryColumns`: five for a donkey's or mule's chest,
/// a llama's strength for its chest, else none.
pub(super) fn mount_columns(m: &Mob) -> usize {
let species = m.species();
if llama::is_llama(species) {
llama::inventory_columns(m).clamp(0, 5) as usize
} else if m.extra.chested && matches!(species, Species::Donkey | Species::Mule) {
COLUMNS
} else {
0
}
}
/// Whether the mount has a horse inventory (`AbstractHorse`).
pub(super) fn has_mount_menu(species: Species) -> bool {
equine::is_equine(species) || llama::is_llama(species)
}
/// The saddle slot is active (`canUseSlot(SADDLE)` and `CAN_EQUIP_SADDLE`):
/// not on llamas.
fn shows_saddle(species: Species) -> bool {
!llama::is_llama(species)
}
/// The body slot is active: horse armour, or a llama's carpet.
fn shows_body(species: Species) -> bool {
can_wear_horse_armor(species) || llama::is_llama(species)
}
/// The open mount menu: copies of the mount's slots, written back on every change.
#[derive(Default)]
pub(super) struct MountMenu {
open: Option<Open>,
}
impl MountMenu {
/// The mount whose menu is open.
pub(super) fn opened_mob(&self) -> Option<u32> {
self.open.as_ref().map(|o| o.mob)
}
}
struct Open {
mob: u32,
species: Species,
@@ -40,6 +75,9 @@ fn can_wear_horse_armor(species: Species) -> bool {
}
/// Whether `item` fits the body slot of the species (`isEquippableInSlot`).
fn body_armor_fits(species: Species, item: &str) -> bool {
if llama::is_llama(species) {
return item.ends_with("_carpet");
}
can_wear_horse_armor(species) && item.ends_with("_horse_armor")
}
@@ -55,18 +93,16 @@ impl Game<'_> {
pub(super) fn open_mount_menu(&mut self, index: usize) {
let m = &self.mobs[index];
let species = m.species();
if !equine::is_equine(species) {
if !has_mount_menu(species) {
return;
}
let chested = m.extra.chested && matches!(species, Species::Donkey | Species::Mule);
let columns = if chested { COLUMNS } else { 0 };
let mut items = m.extra.items.clone();
items.resize(CHEST_SIZE, None);
let show_armor = can_wear_horse_armor(species);
let show_armor = shows_body(species);
self.mount_menu.open = Some(Open {
mob: m.id,
species,
columns,
columns: mount_columns(m),
saddle: m.saddle_item(),
armor: m
.extra
@@ -75,10 +111,40 @@ impl Game<'_> {
.filter(|_| show_armor)
.map(|i| ItemStack::new(i, 1)),
items,
show_saddle: true,
show_saddle: shows_saddle(species),
show_armor,
});
self.carried = None;
self.show_mount_menu();
}
/// `ClientPacketListener.handleMountScreenOpen`: a server's horse
/// inventory over mount `entity`, its slots to come in the menu's content.
pub(super) fn open_remote_mount_menu(&mut self, entity: u32, columns: usize) -> bool {
let Some(species) = self
.mobs
.iter()
.find(|m| m.id == entity)
.map(Mob::species)
.filter(|s| has_mount_menu(*s))
else {
return false;
};
self.mount_menu.open = Some(Open {
mob: entity,
species,
columns: columns.min(COLUMNS),
saddle: None,
armor: None,
items: vec![None; CHEST_SIZE],
show_saddle: shows_saddle(species),
show_armor: shows_body(species),
});
self.show_mount_menu();
true
}
fn show_mount_menu(&mut self) {
if self.remote.is_none() && self.lan_acting.is_none() {
self.carried = None;
}
self.station = None;
self.container = None;
self.craft_size = 2;
@@ -94,15 +160,39 @@ impl Game<'_> {
return false;
};
let m = &self.mobs[index];
if !equine::is_equine(m.species()) || !m.extra.tame || !m.alive() {
if !has_mount_menu(m.species()) || !m.extra.tame || !m.alive() {
return false;
}
self.open_mount_menu(index);
true
}
/// Riding a mount that has a horse inventory (`HasCustomInventoryScreen`).
pub(super) fn riding_mount_with_menu(&self) -> bool {
self.riding
.and_then(|id| self.mount_mob_index(id))
.is_some_and(|i| has_mount_menu(self.mobs[i].species()))
}
/// A server closed its window: the screen's copy goes.
pub(super) fn mount_menu_reset(&mut self) {
self.mount_menu.open = None;
}
/// The entity type of the open mount menu's mount (`minecraft:horse`).
pub(super) fn mount_menu_entity(&self) -> Option<String> {
let open = self.mount_menu.open.as_ref()?;
let m = self.mobs.iter().find(|m| m.id == open.mob)?;
Some(m.kind.id.to_string())
}
/// The chest columns of the open mount menu (the server's window's).
pub(super) fn mount_menu_columns(&self) -> Option<usize> {
self.mount_menu.open.as_ref().map(|o| o.columns)
}
/// The menu ends when the mount dies or is more than 4 blocks away
/// (`AbstractMountInventoryMenu.stillValid`).
pub(super) fn tick_mount_menu(&mut self) {
// A server's menu ends when the server closes it.
if self.remote.is_some() {
return;
}
let Some(open) = self.mount_menu.open.as_ref() else {
return;
};
@@ -126,8 +216,12 @@ impl Game<'_> {
self.mount_sync();
self.mount_menu.open = None;
}
/// Writes the menu slots to the mount, with the equip sounds.
/// Writes the menu slots to the mount, with the equip sounds. A
/// server's menu is the server's to write.
fn mount_sync(&mut self) {
if self.remote.is_some() {
return;
}
let Some(open) = self.mount_menu.open.as_ref() else {
return;
};
@@ -135,25 +229,66 @@ impl Game<'_> {
return;
};
let (saddle, armor) = (open.saddle.clone(), open.armor.clone());
let body = open.show_armor.then_some(armor);
let items = open.items.clone();
let m = &mut self.mobs[index];
self.mount_store(index, saddle, body, items);
}
/// The slots of mount `index` as its menu shows them: the saddle, the
/// body item (None when the slot isn't active), the chest.
pub(super) fn mount_contents(
&self,
index: usize,
) -> (
Option<ItemStack>,
Option<Option<ItemStack>>,
Vec<Option<ItemStack>>,
) {
let m = &self.mobs[index];
let species = m.species();
let body =
shows_body(species).then(|| m.extra.head.as_deref().map(|i| ItemStack::new(i, 1)));
let mut items = m.extra.items.clone();
items.resize(mount_columns(m) * 3, None);
(m.saddle_item(), body, items)
}
/// The menu's slots back on mount `index` (`body` None: not its slot),
/// with the equip sound of what was put on (`Equippable.equipSound`).
pub(super) fn mount_store(
&mut self,
index: usize,
saddle: Option<ItemStack>,
body: Option<Option<ItemStack>>,
items: Vec<Option<ItemStack>>,
) {
let m = &self.mobs[index];
let at = m.center();
let sound = if saddle.is_some() && !m.extra.saddled {
Some("minecraft:entity.horse.saddle")
} else if armor.is_some() && m.extra.head.is_none() {
Some("minecraft:entity.horse.armor")
let put_on = if saddle.is_some() && !m.extra.saddled {
saddle.as_ref()
} else {
None
body.as_ref()
.and_then(Option::as_ref)
.filter(|_| m.extra.head.is_none())
};
let sound = put_on.map(|s| {
self.res
.items()
.get(&s.item)
.and_then(|i| i.equip_sound.clone())
.unwrap_or_else(|| "minecraft:item.armor.equip_generic".into())
});
let m = &mut self.mobs[index];
m.set_saddle(saddle);
if open.show_armor {
m.extra.head = armor.map(|a| a.item);
if let Some(body) = body {
m.extra.head = body.map(|a| a.item);
}
let mut items = items;
let keep = m.extra.items.len().max(CHEST_SIZE);
items.resize(keep, None);
m.extra.items = items;
let (x, z) = (at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4);
self.world.modified.insert((x, z));
if let Some(sound) = sound {
self.audio.play(sound, Some(at), 0.5, 1.0);
self.audio.play(&sound, Some(at), 0.5, 1.0);
}
}
pub(super) fn mount_slots(&self) -> Option<Vec<usize>> {
@@ -214,7 +349,8 @@ impl Game<'_> {
}
/// A click in the mount menu before the generic handling.
pub(super) fn mount_click(&mut self, right: bool) -> bool {
if !self.mount_active() {
// A server's menu: the click goes out like any container click.
if !self.mount_active() || self.remote.is_some() {
return false;
}
let Some(r) = self.renderer.as_ref() else {
@@ -314,12 +450,12 @@ impl Game<'_> {
if open.show_armor {
batch.sprite(atlas, "gui/sprites/container/slot", ox + 7.0, oy + 35.0);
if open.armor.is_none() {
batch.sprite(
atlas,
"gui/sprites/container/slot/horse_armor",
ox + 8.0,
oy + 36.0,
);
let sprite = if llama::is_llama(open.species) {
"gui/sprites/container/slot/llama_armor"
} else {
"gui/sprites/container/slot/horse_armor"
};
batch.sprite(atlas, sprite, ox + 8.0, oy + 36.0);
}
}
let grey = rgba(64, 64, 64, 255);
@@ -448,4 +584,16 @@ mod tests {
"minecraft:iron_chestplate"
));
}
#[test]
fn llamas_wear_carpets_and_no_saddle() {
assert!(body_armor_fits(Species::Llama, "minecraft:red_carpet"));
assert!(!body_armor_fits(
Species::Llama,
"minecraft:iron_horse_armor"
));
assert!(shows_body(Species::TraderLlama));
assert!(!shows_saddle(Species::Llama));
assert!(shows_saddle(Species::Donkey) && !shows_body(Species::Donkey));
}
}
+29 -19
View File
@@ -107,6 +107,10 @@ impl Game<'_> {
let held = self.inventory[self.selected].clone();
let m = &self.mobs[index];
let species = m.species();
// A server player rides it already (`isVehicle`: no seat, no feeding).
if self.ridden_by_guest(m.id) {
return false;
}
if matches!(species, Species::Pig | Species::Strider) {
return self.interact_steerable(index, item);
}
@@ -246,24 +250,9 @@ impl Game<'_> {
self.consume_held();
true
}
/// The rider's input for this tick; returns false when not on a mount.
pub(super) fn tick_mount_riding(&mut self, keys: Keys) -> bool {
let Some(i) = self.mount_index() else {
return false;
};
if keys.shift || !self.mobs[i].alive() {
self.dismount_mount();
return true;
}
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
// LocalPlayer.aiStep: the riding jump charges while space is held.
let held = self.held().map(|s| s.item.clone());
let steer = match self.mobs[i].species() {
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
_ => false,
};
/// `LocalPlayer.aiStep`'s riding jump: the bar charges while jump is
/// held; on release, the jump's strength (0 when there is none).
pub(super) fn charge_mount_jump(&mut self, i: usize, keys: Keys) -> f32 {
// PlayerRideableJumping: horses, camels and nautili (the dash).
let saddled = self.mobs[i].extra.saddled
&& (equine::is_equine(self.mobs[i].species())
@@ -299,6 +288,27 @@ impl Game<'_> {
}
st.was_jumping = keys.jump;
}
jump
}
/// The rider's input for this tick; returns false when not on a mount.
pub(super) fn tick_mount_riding(&mut self, keys: Keys) -> bool {
let Some(i) = self.mount_index() else {
return false;
};
if keys.shift || !self.mobs[i].alive() {
self.dismount_mount();
return true;
}
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
// LocalPlayer.aiStep: the riding jump charges while space is held.
let held = self.held().map(|s| s.item.clone());
let steer = match self.mobs[i].species() {
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
_ => false,
};
let jump = self.charge_mount_jump(i, keys);
let m = &mut self.mobs[i];
if jump > 0.0 {
// onPlayerJump: rear while jumping (horses).
@@ -324,7 +334,7 @@ impl Game<'_> {
pitch: self.pitch,
jump: if jump > 0.0 { jump } else { pending },
steer,
sprint: self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
sprint: keys.sprint || self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
jumping: keys.jump,
});
true
+68 -3
View File
@@ -124,7 +124,7 @@ impl Game<'_> {
team,
viewer,
self.spectator(),
m.has_effect("minecraft:invisibility"),
m.invisible || m.has_effect("minecraft:invisibility"),
);
// Entity.isDiscrete: a crouching player.
let discrete = player && m.extra.crouching;
@@ -144,9 +144,16 @@ impl Game<'_> {
continue;
}
// getDisplayName: the name in the team's prefix, suffix and colour.
let member = if player {
Text::literal(&name)
} else {
m.custom_name_text
.clone()
.unwrap_or_else(|| Text::literal(&name))
};
let shown = match team {
Some(t) => t.formatted_name(Text::literal(&name)),
None => Text::literal(&name),
Some(t) => t.formatted_name(member),
None => member,
};
let mut lines = Vec::new();
// EntityRenderer.extractNameTags: the score shows within the
@@ -171,6 +178,64 @@ impl Game<'_> {
discrete,
});
}
// Armor stands name themselves: an invisible one with a visible name
// is a hologram (its model is skipped, its name tag is not).
for a in &self.armor_stands {
let Some(name) = &a.custom_name else {
continue;
};
if !a.name_visible {
continue;
}
let height = match (a.marker, a.small) {
(true, _) => 0.0,
(false, true) => 0.9875,
(false, false) => 1.975,
};
let distance_sqr =
glam::DVec3::new(a.position.x, a.position.y, a.position.z).distance_squared(camera);
if distance_sqr >= 64.0 * 64.0 {
continue;
}
let team = self.mob_team(a.id);
let invisible = sb::invisible_to(team, viewer, self.spectator(), false);
let view = sb::NameView {
discrete: false,
distance_sqr,
invisible_to_viewer: invisible,
hud_hidden: self.ui.hide_gui,
is_camera: false,
is_vehicle: false,
};
if !sb::name_shown(team, viewer, view) {
continue;
}
let shown = match team {
Some(t) => t.formatted_name(name.clone()),
None => name.clone(),
};
let mut lines = Vec::new();
let mut lift = 0.0;
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
if distance_sqr < 10.0 * 10.0
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
{
lines.push((score, 0.0));
lift = f64::from(9.0f32 * 1.15 * 0.025);
}
lines.push((shown, lift));
let l = self.world.light(
a.position.x.floor() as i32,
(a.position.y + height).floor() as i32,
a.position.z.floor() as i32,
);
tags.push(NameTag {
at: glam::DVec3::new(a.position.x, a.position.y + height + 0.5, a.position.z),
lines,
light: l,
discrete: false,
});
}
tags
}
/// `SubmitNodeCollection.submitNameTag`: a discrete name is drawn once,
@@ -9,6 +9,7 @@ use horizon_client::{
mobs::{
Mob, MobWorld, Species,
data::{self, MobData},
seen_grid::SeenGrid,
spawn_rules::{Dimension, SpawnLevel},
spawn_selection::{
self, Category, Counted, Heightmap, NaturalLevel, SpawnCost, SpawnPlayer, SpawnRequest,
@@ -75,8 +76,72 @@ pub(super) struct GameLevel<'g, 'a> {
pub(super) pending: std::cell::RefCell<Vec<Aabb>>,
/// The types and boxes of what a spawner created this tick.
pub(super) pending_ids: std::cell::RefCell<Vec<(String, Aabb)>>,
/// The game's mobs by box, built once a pass has asked more than
/// [`SCANNED_QUERIES`] box queries (the mobs do not change while it
/// holds the game); the first ones scan, as the build costs a few scans.
pub(super) boxes: std::cell::OnceCell<MobBoxes>,
pub(super) box_queries: std::cell::Cell<u32>,
}
const SCANNED_QUERIES: u32 = 8;
/// A mob not removed: its index in `Game::mobs`, box, alive, and whether
/// it is a living shulker.
type MobBox = (usize, Aabb, bool, bool);
fn mob_box(i: usize, m: &Mob) -> MobBox {
let alive = m.alive();
(
i,
m.bounds(),
alive,
alive && m.species() == Species::Shulker,
)
}
/// The boxes of the game's mobs not removed, and a grid over them.
pub(super) struct MobBoxes {
entries: Vec<MobBox>,
grid: SeenGrid,
}
impl MobBoxes {
fn new(mobs: &[Mob]) -> Self {
let entries: Vec<_> = mobs
.iter()
.enumerate()
.filter(|(_, m)| !m.removed)
.map(|(i, m)| mob_box(i, m))
.collect();
let grid = SeenGrid::new(entries.iter().map(|e| e.1));
Self { entries, grid }
}
/// The entries whose boxes meet `area` (and possibly a few more).
fn near(&self, area: Aabb) -> impl Iterator<Item = MobBox> + '_ {
let mut near = Vec::new();
let indexed = self.grid.candidates(area, &mut near);
let all = (!indexed).then(|| self.entries.iter());
near.into_iter()
.map(|i| self.entries[i as usize])
.chain(all.into_iter().flatten().copied())
}
}
impl GameLevel<'_, '_> {
/// The mobs not removed whose boxes may meet `area` (callers test them).
fn mobs_near(&self, area: Aabb) -> Box<dyn Iterator<Item = MobBox> + '_> {
let asked = self.box_queries.get() + 1;
self.box_queries.set(asked);
if asked <= SCANNED_QUERIES && self.boxes.get().is_none() {
return Box::new(
self.game
.mobs
.iter()
.enumerate()
.filter(|(_, m)| !m.removed)
.map(|(i, m)| mob_box(i, m)),
);
}
Box::new(
self.boxes
.get_or_init(|| MobBoxes::new(&self.game.mobs))
.near(area),
)
}
fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
Some(self.game.res.biomes().get(chunk.biome(x, y, z)))
@@ -224,12 +289,9 @@ impl NaturalLevel for GameLevel<'_, '_> {
// getEntityCollisions(null, box): living shulkers are solid.
let grown = bounds.inflate(Vec3::new(1e-7, 1e-7, 1e-7));
no_collision_empty(&mut scene, bounds)
&& !self.game.mobs.iter().any(|m| {
m.species() == Species::Shulker
&& m.alive()
&& !m.removed
&& m.bounds().intersects(grown)
})
&& !self
.mobs_near(grown)
.any(|(_, b, _, shulker)| shulker && b.intersects(grown))
&& !self
.pending_ids
.borrow()
@@ -244,10 +306,8 @@ impl NaturalLevel for GameLevel<'_, '_> {
!player
&& !self.pending.borrow().iter().any(|b| b.intersects(bounds))
&& !self
.game
.mobs
.iter()
.any(|m| m.alive() && !m.removed && m.bounds().intersects(bounds))
.mobs_near(bounds)
.any(|(_, b, alive, _)| alive && b.intersects(bounds))
}
fn spawn_mobs(&self) -> bool {
self.game.ui.rules.bool("spawn_mobs")
@@ -289,10 +349,8 @@ impl horizon_client::mobs::spawner::SpawnerLevel for GameLevel<'_, '_> {
}
fn count_entities(&self, id: &str, bounds: Aabb) -> usize {
let mobs = self
.game
.mobs
.iter()
.filter(|m| !m.removed && m.kind.id == id && m.bounds().intersects(bounds))
.mobs_near(bounds)
.filter(|&(i, b, ..)| self.game.mobs[i].kind.id == id && b.intersects(bounds))
.count();
let pending = self
.pending_ids
@@ -311,6 +369,8 @@ impl<'a> Game<'a> {
players: self.level_players(),
pending: Default::default(),
pending_ids: Default::default(),
boxes: Default::default(),
box_queries: Default::default(),
}
}
/// Creates a mob the spawner accepted, finalized the game's way; the
+7 -39
View File
@@ -113,7 +113,7 @@ impl Game<'_> {
}
/// Where focus can land: buttons drawn last frame, every registered
/// target (sliders, fields, rows, Horizon-style widgets), and the slots
/// target (sliders, fields, rows, keyboard keys), and the slots
/// and tabs of inventories (GUI pixels), on screen.
fn pad_targets(&self) -> Vec<Target> {
let mut targets = self.pad_all_targets();
@@ -137,7 +137,7 @@ impl Game<'_> {
})
.collect();
for t in widgets::last_targets() {
// A Horizon button is both a labelled button and a target.
// A button can be both a labelled button and a target.
if let Some(same) = targets.iter_mut().find(|o| o.rect == t.rect) {
same.kind = t.kind;
} else {
@@ -1026,45 +1026,13 @@ impl Game<'_> {
}
}
/// The focus highlight. Classic screens look as vanilla's keyboard focus
/// does: buttons, sliders and slots already draw their highlighted
/// sprite under the (hidden) pointer, and list rows and fields get
/// vanilla's white one-pixel outline (`AbstractSelectionList`'s focused
/// selection, `EditBox`'s highlighted border). Horizon-style screens get
/// the design system's focus colour: a two-dp accent ring.
/// The focus highlight, as vanilla's keyboard focus looks: buttons,
/// sliders and slots already draw their highlighted sprite under the
/// (hidden) pointer, and list rows and fields get vanilla's white
/// one-pixel outline (`AbstractSelectionList`'s focused selection,
/// `EditBox`'s highlighted border).
fn draw_pad_focus(&self, batch: &mut HudBatch, focus: Target) {
let [x, y, w, h] = focus.rect;
// The options' sub-pages keep the classic layout under either style.
let horizon = horizon_client::ui_theme::horizon()
&& if self.screen == Screen::Options {
self.ui.options.page() == Page::Main
} else {
self.title.is_some() || self.screen == Screen::Paused
};
if horizon {
use horizon_client::ui_theme::{self as theme, Ui, color};
let mut ui = Ui::new(&self.gui.atlas, batch, self.cursor);
let k = ui.batch.scale / ui.density;
let (x, y, w, h) = (x * k, y * k, w * k, h * k);
let radius = match focus.kind {
Kind::Button | Kind::Tab(_) | Kind::Slider { .. } => (h + 6.0) / 2.0,
Kind::Field => theme::RADIUS + 3.0,
_ => theme::RADIUS_PLATE + 3.0,
};
for inset in [0.0, 1.0] {
let o = 3.0 - inset;
ui.border(
x - o,
y - o,
w + 2.0 * o,
h + 2.0 * o,
radius - inset,
color::ACCENT,
);
}
ui.finish();
return;
}
if matches!(focus.kind, Kind::Row | Kind::Field) {
let atlas = &self.gui.atlas;
let white = rgba(255, 255, 255, 255);
+13 -1
View File
@@ -34,6 +34,9 @@ impl Game<'_> {
if !self.ui.rules.bool("mob_griefing") {
return;
}
// Whether each item is piglin-loved, kept until an item is added or
// removed (added ones are pushed at the end under a new id).
let mut loved: (Option<(usize, Option<u32>)>, Vec<bool>) = (None, Vec::new());
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Piglin
@@ -46,11 +49,20 @@ impl Game<'_> {
continue;
}
let pos = m.position;
let key = (self.dropped.len(), self.dropped.last().map(|e| e.id));
if loved.0 != Some(key) {
loved.0 = Some(key);
loved.1 = self
.dropped
.iter()
.map(|e| self.piglin_loved(&e.stack.item))
.collect();
}
let best = self
.dropped
.iter()
.enumerate()
.filter(|(_, e)| self.piglin_loved(&e.stack.item))
.filter(|&(j, _)| loved.1[j])
.map(|(j, e)| (j, e.position.distance_squared(pos)))
.filter(|(_, d)| *d <= 81.0)
.min_by(|a, b| a.1.total_cmp(&b.1));
+548 -126
View File
@@ -7,7 +7,7 @@
//! client predicts its own movement; the server stays authoritative for
//! everything else.
use super::{Dim, Game, Mining, Screen};
use horizon_client::first_person::Hand;
use horizon_client::first_person::{Hand, SwingAnimation};
use horizon_client::{
Result,
chunk::{BIOME_STRATEGY, Chunk, Light, MIN_Y, SECTIONS, Section, block_strategy},
@@ -93,7 +93,7 @@ pub(super) struct Remote {
pub(super) registries: Arc<Dynamic>,
joined: bool,
status: String,
entity_id: i32,
pub(super) entity_id: i32,
dimension: DimensionType,
biome_map: Vec<u16>,
biome_bits: u8,
@@ -140,6 +140,8 @@ pub(super) struct Remote {
tab_players: HashMap<u128, horizon_client::tab_list::TabPlayer>,
/// Each listed profile's unpacked `textures` property.
skin_textures: HashMap<u128, horizon_client::skins::Textures>,
/// The skin texture each profile's players show, once its download is ready.
skins: HashMap<u128, String>,
listed: std::collections::HashSet<u128>,
/// `ClientboundLoginPacket.onlineMode`: the player list shows heads.
online_mode: bool,
@@ -181,6 +183,9 @@ const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1);
/// whatever their cost: the packets after a slow machine's chunks never
/// wait more than a tick.
const BACKLOG_LIMIT: Duration = Duration::from_millis(50);
/// The open menu name of a server's horse inventory, then its chest's
/// columns (the packet names no `MenuType`).
const MOUNT_MENU: &str = "horizon:mount";
fn face_direction(face: [i32; 3]) -> u8 {
match face {
@@ -194,6 +199,30 @@ fn face_direction(face: [i32; 3]) -> u8 {
}
}
/// A `minecraft:profile` component as the item data JSON the model reads.
fn profile_tag(p: &horizon_protocol::item::Profile) -> Tag {
let properties = p
.properties
.iter()
.map(|(name, value, signature)| {
let mut entries: Vec<(&'static str, Tag)> = vec![
("name", Tag::String(name.as_str().into())),
("value", Tag::String(value.as_str().into())),
];
if let Some(signature) = signature {
entries.push(("signature", Tag::String(signature.as_str().into())));
}
Tag::compound(entries)
})
.collect::<Vec<_>>();
let mut entries: Vec<(&'static str, Tag)> = Vec::new();
if let Some(name) = &p.name {
entries.push(("name", Tag::String(name.as_str().into())));
}
entries.push(("properties", Tag::List(properties)));
Tag::compound(entries)
}
/// A protocol number format as the scoreboard's.
fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFormat {
use horizon_client::scoreboard::{NumberFormat, tag_to_json};
@@ -306,6 +335,11 @@ impl Game<'_> {
progress: 0.0,
total: 0,
};
// The connected client never opens a server world (single player also
// joins here), so load the registries its argument parsers and local
// suggestions need before receiving the server's command tree.
self.ui.registries = Default::default();
self.build_registries();
self.ui.registries.permission = 0;
self.ui.chat.clear();
self.screen = Screen::Playing;
@@ -355,6 +389,7 @@ impl Game<'_> {
players: HashMap::new(),
tab_players: HashMap::new(),
skin_textures: HashMap::new(),
skins: HashMap::new(),
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
@@ -515,66 +550,118 @@ impl Game<'_> {
let mut out = ItemStack {
item: self.item_name(s.item),
count: s.count.max(0) as u32,
damage: s.damage().max(0) as u32,
damage: 0,
extra: None,
};
use horizon_protocol::item::{Component, component};
if let Some(Component::Int(rgb)) = s.component(component::DYED_COLOR) {
out.data_mut().dyed_color = Some(*rgb);
}
if let Some(Component::Int(id)) = s.component(component::MAP_ID) {
out.data_mut().map_id = Some(*id);
}
if let Some(Component::Int(cost)) = s.component(component::REPAIR_COST) {
out.data_mut().repair_cost = (*cost).max(0) as u32;
}
if let Some(Component::Int(mode)) = s.component(component::MAP_POST_PROCESSING) {
out.data_mut().map_post_processing = Some((*mode).clamp(0, 1) as u8);
}
if let Some(Component::WritableBook(pages)) = s.component(component::WRITABLE_BOOK_CONTENT)
{
out.data_mut().writable_pages = pages.clone();
}
if let Some(Component::WrittenBook(b)) = s.component(component::WRITTEN_BOOK_CONTENT) {
let lang = self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default();
out.data_mut().written = Some(horizon_client::item_data::WrittenBook {
title: b.title.clone(),
author: b.author.clone(),
generation: b.generation.clamp(0, 3) as u8,
pages: b.pages.iter().map(|p| plain(p, &lang)).collect(),
});
}
if let Some(Component::Text(name)) = s.component(component::CUSTOM_NAME) {
let lang = self
.remote
.as_ref()
.map(|r| r.lang.clone())
.unwrap_or_default();
out.data_mut().custom_name = Some(plain(name, &lang));
}
// The enchantments by the server's registry ids.
let levels = |list: &[(i32, i32)]| -> Vec<(Arc<str>, u32)> {
let names = self.remote.as_ref().map(|r| &r.registries);
list.iter()
.filter_map(|(id, level)| {
let name = names?.name("minecraft:enchantment", *id)?;
Some((Arc::from(name), (*level).max(0) as u32))
})
.collect()
};
if let Some(Component::Enchantments(list)) = s.component(component::ENCHANTMENTS) {
out.data_mut().enchantments = levels(list);
}
if let Some(Component::Enchantments(list)) = s.component(component::STORED_ENCHANTMENTS) {
out.data_mut().stored_enchantments = levels(list);
// A vanilla `components` compound, then the same decoder the saved
// and command item NBT use, so tooltips and models see every field.
let components = self.protocol_components(s);
let (damage, data) = horizon_client::item_data::decode_components(components.as_ref());
out.damage = damage;
if let Some(data) = data {
*out.data_mut() = *data;
}
out.tidy();
Some(out)
}
/// The item's patched components as vanilla's `components` compound.
fn protocol_components(&self, s: &Stack) -> Option<Tag> {
use horizon_protocol::item::{Component, component};
let r = self.remote.as_ref()?;
let name = |id: i32| r.builtin.get("minecraft:data_component_type")?.name(id);
let mut entries: Vec<(&str, Tag)> = Vec::new();
for (kind, c) in &s.added {
let Some(key) = name(*kind) else {
continue;
};
let value = match c {
Component::Int(v) if *kind == component::RARITY => {
let rarity = ["common", "uncommon", "rare", "epic"];
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
}
Component::Int(v) => Tag::Int(*v),
Component::Unit => Tag::Compound(Default::default()),
Component::Bool(v) => Tag::Byte(i8::from(*v)),
Component::Text(t) | Component::Nbt(t) => t.clone(),
Component::Ident(v) => Tag::String(v.as_str().into()),
Component::Lore(lines) => Tag::List(lines.clone()),
Component::WritableBook(pages) => Tag::compound([(
"pages",
Tag::List(
pages
.iter()
.map(|p| Tag::String(p.as_str().into()))
.collect(),
),
)]),
Component::WrittenBook(b) => Tag::compound([
("title", Tag::String(b.title.as_str().into())),
("author", Tag::String(b.author.as_str().into())),
("generation", Tag::Int(b.generation)),
("pages", Tag::List(b.pages.clone())),
("resolved", Tag::Byte(i8::from(b.resolved))),
]),
Component::Enchantments(list) => {
// Enchantments are a synced (data-driven) registry: the
// ids index the server's RegistryData, not the builtin
// report, so a datapack enchantment names correctly.
let levels = list.iter().filter_map(|(id, level)| {
let name = r.registries.name("minecraft:enchantment", *id)?;
Some((name, Tag::Int(*level)))
});
Tag::compound(levels)
}
Component::AttackRange(v) => Tag::compound([
("min_reach", Tag::Float(v[0].to_bits())),
("max_reach", Tag::Float(v[1].to_bits())),
("min_attack_momentum", Tag::Float(v[2].to_bits())),
("max_attack_momentum", Tag::Float(v[3].to_bits())),
("hitbox_margin", Tag::Float(v[4].to_bits())),
("mob_factor", Tag::Float(v[5].to_bits())),
]),
Component::Profile(p) => profile_tag(p),
Component::CustomModelData {
floats,
flags,
strings,
colors,
} => Tag::compound([
(
"floats",
Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()),
),
(
"flags",
Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()),
),
(
"strings",
Tag::List(
strings
.iter()
.map(|s| Tag::String(s.as_str().into()))
.collect(),
),
),
(
"colors",
Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()),
),
]),
Component::TooltipDisplay { hide, hidden } => {
let hidden = hidden
.iter()
.filter_map(|id| Some(Tag::String(name(*id)?.into())));
Tag::compound([
("hide_tooltip", Tag::Byte(i8::from(*hide))),
("hidden_components", Tag::List(hidden.collect())),
])
}
};
entries.push((key, value));
}
Some(Tag::compound(entries))
}
fn to_network(&self, s: &Option<ItemStack>) -> Option<Stack> {
let s = s.as_ref()?;
let mut stack = Stack::new(self.item_id(&s.item), s.count as i32);
@@ -1283,6 +1370,10 @@ impl Game<'_> {
self.turn_remote_projectile(id, rotation);
}
fn remove_entity(&mut self, id: i32) {
// The vehicle the player rode is gone (Entity.removeVehicle).
if self.riding == Some(id as u32) {
self.riding = None;
}
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
@@ -1352,6 +1443,19 @@ impl Game<'_> {
// CrafterMenu: the grid, the inventory, then the result (the
// client's own).
"minecraft:crafter_3x3" => slots(9),
// HorseInventoryMenu: the saddle, the body armour, the chest,
// then the inventory.
m if m.starts_with(MOUNT_MENU) => {
let n = m[MOUNT_MENU.len()..].parse::<usize>().ok()? * 3;
match s {
0 => Some(super::mount_menu::SADDLE),
1 => Some(super::mount_menu::ARMOR),
_ if s < 2 + n => Some(super::mount_menu::CHEST + s - 2),
_ if s < 2 + n + 27 => Some(9 + s - 2 - n),
_ if s < 2 + n + 36 => Some(s - 2 - n - 27),
_ => None,
}
}
// A workstation: its inputs, its result, then the inventory.
m => {
let kind = Self::remote_station_kind(m)?;
@@ -1669,6 +1773,8 @@ impl Game<'_> {
];
self.move_entity(p.id, position, Some((p.yaw, p.pitch)), p.on_ground);
}
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
C::RotateHead(p) => {
if let Some(r) = self.remote.as_mut() {
@@ -1677,6 +1783,29 @@ impl Game<'_> {
}
}
C::SetEntityData(p) => {
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
// these instead of saved NBT.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0,
(2, Value::OptText(name)) => {
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
m.custom_name =
text.as_ref().map(horizon_client::text::Text::plain);
m.custom_name_text = text;
}
(2, Value::Text(name)) => {
let text = horizon_client::entity_nbt::name_text(name);
m.custom_name = Some(text.plain());
m.custom_name_text = Some(text);
}
(3, Value::Bool(visible)) => m.name_visible = *visible,
_ => {}
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
@@ -2091,6 +2220,7 @@ impl Game<'_> {
}
}
C::OpenScreen(p) => self.remote_open_screen(p),
C::MountScreenOpen(p) => self.remote_open_mount(p),
C::RecipeBookAdd(p) => self.remote_recipe_book_add(p),
C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p),
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
@@ -2314,6 +2444,7 @@ impl Game<'_> {
.then(|| Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5));
}
C::ServerData(p) => self.remote_server_data(p.icon.as_deref()),
C::MapItemData(p) => self.remote_map_item_data(&p),
// handleTickingState / handleTickingStep: the level's TickRateManager.
C::TickingState(p) => {
self.clock.set_rate(p.rate);
@@ -2459,33 +2590,20 @@ impl Game<'_> {
pub(super) fn remote_tab_players(
&mut self,
) -> Option<(Vec<horizon_client::tab_list::TabPlayer>, bool, Vec<u128>)> {
let r = self.remote.as_ref()?;
let mut players: Vec<_> = r
.listed
.iter()
.filter_map(|u| r.tab_players.get(u).cloned())
.collect();
self.update_remote_skins();
let mut players: Vec<_> = {
let r = self.remote.as_ref()?;
r.listed
.iter()
.filter_map(|u| r.tab_players.get(u).cloned())
.collect()
};
for p in &mut players {
let Some(textures) = r.skin_textures.get(&p.uuid) else {
continue;
};
// PlayerInfo.createSkinLookup: only the local player's own skin
// may be unsigned.
let downloads = &mut self.skin_downloads;
let skin = horizon_client::skins::lookup(textures, p.uuid != r.profile, |kind, url| {
downloads.state(kind, url)
});
if let Some(skin) = skin {
p.skin = skin;
}
}
for (location, rgba) in self.skin_downloads.take_ready() {
self.store_skin(location, rgba);
}
for p in &mut players {
if p.skin.starts_with("skins/") && !self.show_skin(&p.skin) {
p.skin = horizon_client::tab_list::default_skin(p.uuid).to_owned();
}
p.skin = self
.remote
.as_ref()
.and_then(|r| r.skins.get(&p.uuid).cloned())
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned());
}
let r = self.remote.as_ref()?;
let upside_down = players
@@ -2498,9 +2616,68 @@ impl Game<'_> {
.collect();
Some((players, r.online_mode, upside_down))
}
/// `PlayerInfo.getSkin` for every player entity, including NPCs that are
/// never in the tab list: resolve each profile's textures and give the
/// level's player models their skin.
fn update_remote_skins(&mut self) {
let Some(r) = self.remote.as_ref() else {
return;
};
let local = r.profile;
let entries: Vec<(u128, horizon_client::skins::Textures)> = r
.skin_textures
.iter()
.map(|(uuid, textures)| (*uuid, textures.clone()))
.collect();
let mut resolved: Vec<(u128, String)> = Vec::new();
for (uuid, textures) in entries {
// PlayerInfo.createSkinLookup: only the local player's own skin
// may be unsigned.
let skin = {
let mut downloads = self.skin_downloads.borrow_mut();
horizon_client::skins::lookup(&textures, uuid != local, |kind, url| {
downloads.state(kind, url)
})
};
if let Some(skin) = skin {
resolved.push((uuid, skin));
}
}
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.show_skin(skin));
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
r.level_players
.iter()
.map(|(id, uuid)| (*id as u32, *uuid))
.collect::<Vec<_>>(),
r.skins.clone(),
r.skins.get(&r.profile).cloned(),
)
}) else {
return;
};
for (id, uuid) in level {
let Some(skin) = skins.get(&uuid) else {
continue;
};
if let Some(mob) = self
.mobs
.iter_mut()
.find(|m| m.id == id && &*m.kind.id == "minecraft:player")
{
mob.skin = Some(skin.clone());
}
}
if let Some(skin) = own
&& let Some(mob) = self.player_model.as_mut()
{
mob.skin = Some(skin);
}
}
/// Puts a downloaded skin in an atlas skin slot: a free one, else the
/// least recently shown.
fn store_skin(&mut self, location: String, rgba: Vec<u8>) {
pub(super) fn store_skin(&mut self, location: String, rgba: Vec<u8>) {
use horizon_client::hud::SKIN_SLOTS;
let slot = if self.skin_slots.len() < SKIN_SLOTS {
self.skin_slots.len()
@@ -2778,9 +2955,9 @@ impl Game<'_> {
self.capture_loaded_world();
}
let stick = self.pad_move();
let Some(player) = self.player.as_mut() else {
if self.player.is_none() {
return Ok(());
};
}
let playing = self.screen == Screen::Playing;
let held = |k| playing && self.keys.contains(&k);
let keys = Keys {
@@ -2801,7 +2978,14 @@ impl Game<'_> {
| u8::from(keys.jump) << 4
| u8::from(keys.shift) << 5
| u8::from(keys.sprint) << 6;
self.previous_position = player.position;
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
let Some(player) = self.player.as_mut() else {
return Ok(());
};
if !riding {
self.previous_position = player.position;
}
let before = (
player.ground.on_ground,
[player.velocity.x, player.velocity.y, player.velocity.z],
@@ -2818,7 +3002,7 @@ impl Game<'_> {
f64::from(head[2]) + 0.5,
);
player.velocity = Vec3::ZERO;
} else if !dead {
} else if !dead && !riding {
player.velocity = player.velocity.add_vector(pushed);
MotionWorld {
entities: &ghast_collisions,
@@ -3104,6 +3288,9 @@ impl Game<'_> {
};
self.tick_remote_objects(&mut step);
self.tick_remote_projectiles(&mut step);
// LightningBolt.tick on a client, and the sky flash fading.
self.flash = self.flash.saturating_sub(1);
self.tick_bolts();
}
lerps.retain(|_, l| l.steps > 0);
if let Some(r) = self.remote.as_mut() {
@@ -3165,6 +3352,8 @@ impl Game<'_> {
p.sprinting,
);
let (yaw, pitch, selected) = (self.yaw, self.pitch, self.selected);
let riding = self.riding.is_some();
let vehicle = self.remote_vehicle_packets();
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3187,9 +3376,18 @@ impl Game<'_> {
data: 0,
}));
}
if let Some(packet) = r
.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
if riding {
// LocalPlayer.tick: a passenger sends its look, then the vehicle
// it controls.
out.push(S::MovePlayerRot(play::MovePlayerRot {
yaw,
pitch,
flags: u8::from(on_ground) | u8::from(collision) << 1,
}));
out.extend(vehicle);
} else if let Some(packet) =
r.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
{
out.push(packet);
}
@@ -3309,10 +3507,8 @@ impl Game<'_> {
sequence: seq,
}));
if progress >= 1.0 {
// startDestroyBlock: instant survival breaks have no destroy delay.
self.predict_break(hit.pos, state, seq);
if let Some(r) = self.remote.as_mut() {
r.destroy_delay = 5;
}
return;
}
if let Some(r) = self.remote.as_mut() {
@@ -3422,6 +3618,21 @@ impl Game<'_> {
fn remote_use_entity(&mut self) -> bool {
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
// A boat, minecart, item frame, armor stand or painting nearer than
// any mob: the server uses it (AbstractBoat.interact boards it).
if let Some(hit) = self.remote_object_hit()
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
self.send(S::Interact(play::Interact {
id: hit.id,
hand: 0,
location: hit.location,
secondary: sneaking,
}));
self.swing_arm();
return true;
}
if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
@@ -3489,7 +3700,15 @@ impl Game<'_> {
self.predict_use(&changes, seq);
}
if held_block {
self.with_hand(hand, Game::swing_arm);
if reacts
|| held
.as_ref()
.is_some_and(|stack| self.remote_can_place(&hit, stack))
{
self.with_hand(hand, Game::swing_arm);
}
// BlockItem.place returns FAIL when placement is rejected:
// stop this click without trying the item or the other hand.
return true;
}
return reacts;
@@ -3547,7 +3766,11 @@ impl Game<'_> {
/// container, and the blocks it changed. Vanilla's client runs it on its
/// own level first; this runs it on a copy, and `predict_use` keeps the
/// changes it predicts.
fn remote_block_reacts(&self, hit: &super::Hit, held: Option<&ItemStack>) -> (bool, DryUse) {
fn remote_block_reacts(
&mut self,
hit: &super::Hit,
held: Option<&ItemStack>,
) -> (bool, DryUse) {
// Player.isSecondaryUseActive with something in either hand.
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
@@ -3562,26 +3785,53 @@ impl Game<'_> {
[-1, 0, 0] => Direction::West,
_ => Direction::East,
};
let mut level = DryLevel {
world: &self.world,
changed: HashMap::new(),
random: horizon_core::random::LegacyRandom::new(0),
sounds: Vec::new(),
let player = self.use_context();
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
let reacts = b
.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
player,
held.map(|s| &*s.item),
)
.consumes_action();
(reacts, (level.changed.into_iter().collect(), level.sounds))
})
}
/// Run BlockItem.place on a disposable level before predicting its swing.
/// The real level supplies collision shapes and building-blocking entities;
/// block writes, random draws, scheduled ticks and events stay in the preview.
fn remote_can_place(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
if !self.may_build() {
return false;
}
let ctx = horizon_world::level::PlaceContext {
clicked_pos: horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face: horizon_core::geometry::Direction::ALL[face_direction(hit.face) as usize],
hit: Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
inside: hit.face == [0; 3],
yaw: self.yaw,
pitch: self.pitch,
sneaking: self.keys.contains(&KeyCode::ShiftLeft),
item: stack.item.to_string(),
can_use_game_master_blocks: self.can_use_game_master_blocks(),
};
let reacts = self
.res
.behaviours()
.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
self.use_context(),
held.map(|s| &*s.item),
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
matches!(
b.place_block(
&mut level,
&mut horizon_world::level::Ticks::default(),
&ctx
),
horizon_world::level::PlaceOutcome::Placed { .. }
)
.consumes_action();
(reacts, (level.changed.into_iter().collect(), level.sounds))
})
}
/// MultiPlayerGameMode.useItem for `hand`, if it holds something: whether
@@ -3762,6 +4012,14 @@ impl Game<'_> {
id: self.creatures.projectiles[i].id as i32,
}));
self.reset_attack_strength();
} else if let Some(hit) = self.remote_object_hit()
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
&& self.before_block(hit.distance)
{
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
// a painting broken: the server decides.
self.send(S::Attack(play::Attack { id: hit.id }));
self.reset_attack_strength();
} else if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
@@ -3836,23 +4094,38 @@ impl Game<'_> {
Ok(true)
}
KeyCode::KeyQ => {
if self.spectator() {
return Ok(true);
}
let all = self.keys.contains(&KeyCode::ControlLeft);
// MultiPlayerGameMode.dropItem: predict removal before sending
// the action, then swing only if a nonempty stack was removed.
let dropped = horizon_client::items::take_held_drop(
&mut self.inventory[self.selected],
all,
false,
);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
// Inventory.removeFromSelected (prediction).
let slot = &mut self.inventory[self.selected];
if let Some(s) = slot {
if all || s.count <= 1 {
*slot = None;
} else {
s.count -= 1;
}
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
self.swing_arm();
Ok(true)
}
// MultiPlayerGameMode.isServerControlledInventory: riding a
// horse or llama, the server opens its inventory
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
Ok(true)
}
KeyCode::KeyE => {
@@ -4099,6 +4372,7 @@ impl Game<'_> {
r.menu = None;
r.menu_title = None;
}
self.mount_menu_reset();
// The client's menu has no level access: what it holds is the server's.
self.station = None;
self.craft_size = 2;
@@ -4283,6 +4557,21 @@ impl Game<'_> {
self.screen = Screen::Inventory;
self.capture(false);
}
/// `ClientPacketListener.handleMountScreenOpen`: a horse's or llama's
/// inventory; for another entity the window is closed again.
fn remote_open_mount(&mut self, p: play::MountScreenOpen) {
let columns = usize::try_from(p.columns).unwrap_or(0).min(5);
if !self.open_remote_mount_menu(p.entity as u32, columns) {
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
return;
}
if let Some(r) = self.remote.as_mut() {
r.menu = Some((p.container, format!("{MOUNT_MENU}{columns}")));
r.menu_title = None;
}
}
/// The type of the menu a server opened (`MenuType`'s id).
pub(super) fn remote_menu_type(&self) -> Option<&str> {
Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str())
@@ -4529,7 +4818,7 @@ pub(super) fn input_ordinal(input: Input) -> i32 {
/// The client's world for a guess at what a click does: reads go to the
/// world, writes stay here and are dropped.
struct DryLevel<'w> {
world: &'w horizon_client::world::World,
base: &'w dyn horizon_world::level::LevelAccess,
changed: HashMap<horizon_core::position::BlockPos, BlockStateId>,
random: horizon_core::random::LegacyRandom,
/// The sounds the use plays (a door's creak).
@@ -4559,6 +4848,17 @@ pub(super) fn hand_operated(name: &str) -> bool {
|| name == "lever"
}
impl<'w> DryLevel<'w> {
fn new(base: &'w dyn horizon_world::level::LevelAccess) -> Self {
Self {
base,
changed: HashMap::new(),
random: horizon_core::random::LegacyRandom::new(0),
sounds: Vec::new(),
}
}
}
impl horizon_world::level::LevelAccess for DryLevel<'_> {
fn emit(&mut self, event: horizon_world::level::LevelEvent) {
if let horizon_world::level::LevelEvent::Sound {
@@ -4575,7 +4875,7 @@ impl horizon_world::level::LevelAccess for DryLevel<'_> {
self.changed
.get(&pos)
.copied()
.unwrap_or_else(|| self.world.block(pos.x, pos.y, pos.z))
.unwrap_or_else(|| self.base.block_state(pos))
}
fn set_raw(
&mut self,
@@ -4587,13 +4887,31 @@ impl horizon_world::level::LevelAccess for DryLevel<'_> {
Some(previous)
}
fn is_loaded(&self, pos: horizon_core::position::BlockPos) -> bool {
self.world.loaded(pos.x, pos.z)
self.base.is_loaded(pos)
}
fn min_y(&self) -> i32 {
self.base.min_y()
}
fn max_y(&self) -> i32 {
self.base.max_y()
}
fn block_light(&self, pos: horizon_core::position::BlockPos) -> i32 {
self.base.block_light(pos)
}
fn sky_light(&self, pos: horizon_core::position::BlockPos) -> i32 {
self.base.sky_light(pos)
}
fn sky_darken(&self) -> i32 {
self.base.sky_darken()
}
fn is_unobstructed(&self, pos: horizon_core::position::BlockPos, state: BlockStateId) -> bool {
self.base.is_unobstructed(pos, state)
}
fn random(&mut self) -> &mut horizon_core::random::LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
self.world.game_time
self.base.game_time()
}
}
@@ -4709,6 +5027,110 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
mod tests {
use super::Game;
#[test]
fn placement_preview_checks_obstruction_without_changing_the_level() {
use super::DryLevel;
use horizon_core::{position::BlockPos, random::LegacyRandom};
use horizon_world::{
level::{
Behaviours, LevelAccess, PlaceContext, PlaceOutcome, Ticks, data::load_registry,
},
registry::BlockStateId,
};
struct Level {
air: BlockStateId,
stone: BlockStateId,
obstructed: bool,
loaded: bool,
ceiling: i32,
}
impl LevelAccess for Level {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
if pos == BlockPos::new(0, 0, 0) {
self.stone
} else {
self.air
}
}
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
panic!("preview must not write to the live level");
}
fn is_loaded(&self, _: BlockPos) -> bool {
self.loaded
}
fn max_y(&self) -> i32 {
self.ceiling
}
fn is_unobstructed(&self, pos: BlockPos, _: BlockStateId) -> bool {
assert_eq!(pos, BlockPos::new(0, 1, 0));
!self.obstructed
}
fn random(&mut self) -> &mut LegacyRandom {
panic!("preview must not advance the live level's random source");
}
fn game_time(&self) -> i64 {
0
}
}
// As with the LAN tests, prepared game resources are optional locally.
let resources = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !resources.join("block-behaviours.json").exists() {
return;
}
let registry = load_registry(&resources).unwrap();
let behaviours = Behaviours::load(&registry, &resources).unwrap();
let mut base = Level {
air: registry.default_state("minecraft:air").unwrap(),
stone: registry.default_state("minecraft:stone").unwrap(),
obstructed: false,
loaded: true,
ceiling: 320,
};
for item in [
"minecraft:stone",
"minecraft:oak_slab",
"minecraft:oak_door",
// GrowingPlantHeadBlock.getStateForPlacement draws a random age.
"minecraft:twisting_vines",
] {
let ctx = PlaceContext {
clicked_pos: BlockPos::new(0, 0, 0),
face: horizon_core::geometry::Direction::Up,
hit: horizon_core::geometry::Vec3::new(0.5, 1.0, 0.5),
inside: false,
yaw: 0.0,
pitch: 45.0,
sneaking: false,
item: item.into(),
can_use_game_master_blocks: false,
};
for (obstructed, loaded, ceiling) in [
(false, true, 320),
(true, true, 320),
(false, false, 320),
(false, true, 1),
] {
base.obstructed = obstructed;
base.loaded = loaded;
base.ceiling = ceiling;
let valid = !obstructed && loaded && ceiling > 1;
let mut preview = DryLevel::new(&base);
let outcome = behaviours.place_block(&mut preview, &mut Ticks::default(), &ctx);
assert_eq!(
matches!(outcome, PlaceOutcome::Placed { .. }),
valid,
"{item}, obstructed={obstructed}, loaded={loaded}, ceiling={ceiling}"
);
assert_eq!(preview.changed.is_empty(), !valid);
assert_eq!(base.block_state(BlockPos::new(0, 1, 0)), base.air);
}
}
}
#[test]
fn every_inventory_menu_slot_is_a_game_slot_and_45_is_the_off_hand() {
// InventoryMenu: result, 2x2 grid, armour, 27 + 9 inventory slots, then the off hand.
@@ -52,6 +52,8 @@ impl Game<'_> {
invisible: false,
check_interval: 0,
});
} else if kind == "minecraft:lightning_bolt" {
self.spawn_remote_bolt(id, position);
} else if kind == "minecraft:painting" {
// The variant comes with the entity data; until then the
// registry's first one (VariantUtils.getAny).
@@ -123,6 +125,14 @@ impl Game<'_> {
} else if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
match (index, value) {
(0, Value::Byte(flags)) => a.invisible = flags & 0x20 != 0,
// Entity.DATA_CUSTOM_NAME and DATA_CUSTOM_NAME_VISIBLE.
(2, Value::OptText(name)) => {
a.custom_name = name.as_ref().map(horizon_client::entity_nbt::name_text);
}
(2, Value::Text(name)) => {
a.custom_name = Some(horizon_client::entity_nbt::name_text(name));
}
(3, Value::Bool(visible)) => a.name_visible = *visible,
(9, Value::Float(health)) => a.health = *health,
// ArmorStand.DATA_CLIENT_FLAGS.
(15, Value::Byte(flags)) => {
@@ -180,7 +190,12 @@ impl Game<'_> {
/// A move's rotation and ground state for an object.
pub(super) fn turn_remote_object(&mut self, id: i32, yaw: Option<f32>, on_ground: bool) {
let id = id as u32;
// The boat the player drives turns here (isLocalInstanceAuthoritative).
let driven = self.riding;
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
if Some(id) == driven {
return;
}
if let Some(yaw) = yaw {
b.yaw = yaw;
}
@@ -205,7 +220,12 @@ impl Game<'_> {
c.previous_yaw = c.yaw;
c.position = step(c.id, c.position);
}
// The boat the player drives moves here, not where the server says.
let driven = self.riding;
for b in &mut self.boats {
if Some(b.id) == driven {
continue;
}
b.previous = b.position;
b.previous_yaw = b.yaw;
b.position = step(b.id, b.position);
@@ -266,5 +286,6 @@ impl Game<'_> {
self.armor_stands.retain(|a| a.id != id);
self.item_frames.retain(|f| f.id != id);
self.paintings.retain(|p| p.id != id);
self.bolts.retain(|b| b.id != id);
}
}
@@ -0,0 +1,189 @@
//! Boats, minecarts, item frames, armor stands and paintings for a server's
//! client: single player on its server thread, a LAN guest, a player on a
//! dedicated server.
//!
//! The client aims at them like at mobs (`Minecraft.hitResult`, then
//! `ServerboundInteractPacket`), and rides the vehicle the server seats it in
//! (`ClientPacketListener.handleSetEntityPassengersPacket`). A boat is
//! controlled by its rider's client: it ticks here and its moves go to the
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
//! the server with the rider's input, and the rider sits in it.
use super::{Game, Keys, view_vector};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::{self, Serverbound as S};
/// An object under the crosshair: its entity id, how far along the view it
/// was hit, and where (`EntityHitResult.getLocation` minus the entity's
/// position, the interact packet's location).
pub(super) struct ObjectHit {
pub id: i32,
pub distance: f64,
pub location: [f64; 3],
}
impl Game<'_> {
/// The nearest boat, minecart, item frame, armor stand or painting the
/// view hits within reach.
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let at = |d: f64| {
[
eye[0] + dir[0] * d,
eye[1] + dir[1] * d,
eye[2] + dir[2] * d,
]
};
let block_centre = |p: [i32; 3]| {
[
f64::from(p[0]) + 0.5,
f64::from(p[1]) + 0.5,
f64::from(p[2]) + 0.5,
]
};
let mut hits: Vec<(i32, f64, [f64; 3])> = Vec::new();
if let Some((i, d)) = self.painting_hit() {
let p = &self.paintings[i];
hits.push((p.id as i32, d, block_centre(p.pos)));
}
if let Some((i, d)) = self.frame_hit() {
let f = &self.item_frames[i];
hits.push((f.id as i32, d, block_centre(f.pos)));
}
if let Some((i, d, _)) = self.armor_stand_hit() {
let a = &self.armor_stands[i];
let p = a.position;
hits.push((a.id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d)) = self.boat_hit() {
let p = self.boats[i].position;
hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d)) = self.cart_hit() {
let p = self.carts[i].position;
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
}
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
let point = at(distance);
Some(ObjectHit {
id,
distance,
location: [
point[0] - origin[0],
point[1] - origin[1],
point[2] - origin[2],
],
})
}
/// `ClientPacketListener.handleSetEntityPassengersPacket`: who rides
/// `vehicle` now. The local player gets in or out; mobs and other
/// players sit where the server keeps them.
pub(super) fn remote_set_passengers(&mut self, vehicle: i32, passengers: &[i32]) {
let Some(me) = self.remote.as_ref().map(|r| r.entity_id) else {
return;
};
let vehicle_id = vehicle as u32;
for m in &mut self.mobs {
if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) {
m.extra.mount = None;
} else if passengers.contains(&(m.id as i32)) {
m.extra.mount = Some(vehicle_id);
}
}
if passengers.contains(&me) {
if self.riding != Some(vehicle_id) {
self.riding = Some(vehicle_id);
if let Some(p) = self.player.as_mut() {
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
}
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
}
}
/// The local player's tick in its vehicle, in place of its own movement:
/// false when it rides nothing.
pub(super) fn remote_ride(&mut self, keys: Keys) -> bool {
let Some(id) = self.riding else {
return false;
};
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
// An animal moves on the server with the rider's input
// (PlayerInput); here the jump bar charges for the HUD and the
// rider sits where `positionRider` puts it.
self.charge_mount_jump(i, keys);
self.position_rider();
return true;
}
let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) {
// AbstractBoat.tick on the controlling client: steer and move.
let mut b = self.boats[i].clone();
b.input = [keys.left, keys.right, keys.forward, keys.backward];
let b = self.tick_boat(b, true);
// positionRider: the seat turns with the boat; clampRotation.
self.yaw += b.delta_rotation;
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
self.yaw += delta.clamp(-105.0, 105.0) - delta;
let seat = b.position;
self.boats[i] = b;
Some(seat)
} else {
// A minecart: the server moves it, the rider sits in it.
self.carts.iter().find(|c| c.id == id).map(|c| c.position)
};
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
if let Some(v) = seat {
p.position = Vec3::new(v.x, v.y + super::boats::SEAT, v.z);
}
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
true
}
/// `LocalPlayer.tick` for a passenger: the boat it controls goes to the
/// server every tick with its paddles (the rider's own position does not).
pub(super) fn remote_vehicle_packets(&self) -> Vec<S> {
let Some(b) = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
else {
return Vec::new();
};
vec![
S::MoveVehicle(play::MoveVehicleRequest {
position: [b.position.x, b.position.y, b.position.z],
yaw: b.yaw,
pitch: 0.0,
on_ground: b.on_ground,
}),
S::PaddleBoat(play::PaddleBoat {
left: b.paddling[0],
right: b.paddling[1],
}),
]
}
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat the
/// player drives back.
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
let Some(b) = self
.riding
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
else {
return;
};
let at = Vec3::new(position[0], position[1], position[2]);
b.previous = at;
b.position = at;
b.yaw = yaw;
b.previous_yaw = yaw;
b.velocity = Vec3::ZERO;
}
}
@@ -0,0 +1,123 @@
//! Worlds and the multiplayer server list from the control socket: what the Select World, Edit
//! World and Multiplayer screens do, by world folder. Changing a world needs the title screen
//! (no world open), as those screens do; deleting one needs `--confirm`.
use super::Game;
use horizon_client::{Result, title};
use serde_json::{Value, json};
use std::{path::PathBuf, sync::mpsc::Sender, time::UNIX_EPOCH};
pub(super) const HELP: &[&str] = &[
"worlds the saved worlds, newest first: folder, name, mode, last played, version",
"world-rename FOLDER NAME rename a world (Edit World; its folder stays)",
"world-backup FOLDER zip a world into backups/ (Edit World > Make Backup)",
"world-delete FOLDER --confirm delete a world for good (Select World > Delete)",
"servers the multiplayer server list (servers.dat): name and address",
];
fn saves() -> Result<PathBuf> {
Ok(std::env::current_dir()?.join("saves"))
}
impl Game<'_> {
/// Some(reply) for these commands (Some(None): the reply comes later), None for others.
pub(super) fn saves_control(
&mut self,
words: &[&str],
line: &str,
reply: &Sender<String>,
) -> Result<Option<Option<Value>>> {
let folder = || {
words
.get(1)
.copied()
.ok_or_else(|| format!("{} FOLDER: the folder from worlds", words[0]))
};
let on_title = || {
if self.title.is_some() {
Ok(())
} else {
Err(format!(
"{} needs the title screen: save and leave the world first (pause, then Save and Quit to Title)",
words[0]
))
}
};
let value = match words[0] {
"worlds" => {
let worlds: Vec<Value> = title::list_worlds(&saves()?)
.iter()
.map(|w| {
json!({
"folder": w.folder,
"name": w.name,
"mode": if w.creative { "creative" } else { "survival" },
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
"commands": w.commands,
"version": w.version,
})
})
.collect();
json!({ "worlds": worlds })
}
"world-rename" => {
on_title()?;
let folder = folder()?;
let name = line
.trim_start()
.strip_prefix("world-rename")
.and_then(|rest| rest.trim_start().strip_prefix(folder))
.map(str::trim)
.filter(|n| !n.is_empty())
.ok_or("world-rename FOLDER NAME: missing the new name")?;
title::world_management::rename(&saves()?, folder, name)?;
if let Some(title) = self.title.as_mut() {
title.worlds_changed();
}
json!({ "ok": true, "folder": folder, "name": name })
}
"world-delete" => {
on_title()?;
let folder = folder()?;
if !words.contains(&"--confirm") {
return Err(format!(
"world-delete deletes {folder} for good: ask the player, then add --confirm"
)
.into());
}
title::world_management::delete(&saves()?, folder)?;
if let Some(title) = self.title.as_mut() {
title.worlds_changed();
}
json!({ "ok": true, "deleted": folder })
}
"world-backup" => {
on_title()?;
let folder = folder()?.to_owned();
let saves = saves()?;
let backups = title::world_management::backups_path(&saves);
// A big world takes a while to zip: off the game thread, as Make Backup does.
let reply = reply.clone();
std::thread::spawn(move || {
let answer = match title::world_management::backup(&saves, &folder) {
Ok(bytes) => {
json!({ "ok": true, "folder": folder, "backups": backups, "bytes": bytes })
}
Err(e) => json!({ "error": format!("backup of {folder} failed: {e}") }),
};
let _ = reply.send(answer.to_string());
});
return Ok(Some(None));
}
"servers" => {
let file = horizon_client::multiplayer::servers_file(&saves()?);
let servers: Vec<Value> = horizon_protocol::servers::load(&file)
.iter()
.map(|s| json!({ "name": s.name, "address": s.ip }))
.collect();
json!({ "servers": servers })
}
_ => return Ok(None),
};
Ok(Some(Some(value)))
}
}
+8 -5
View File
@@ -431,20 +431,18 @@ impl Game<'_> {
self.score.board.players_team(self.acting_player_name())
}
/// The teams of the player and of the mobs on one (empty without teams).
pub(super) fn mob_teams(&self) -> horizon_client::mobs::Teams {
pub(super) fn mob_teams(&self) -> horizon_client::mobs::Teams<'_> {
let board = &self.score.board;
if board.teams.is_empty() {
return Default::default();
}
horizon_client::mobs::Teams {
player: self.player_team().cloned(),
player: self.player_team(),
mobs: self
.persist
.uuids
.iter()
.filter_map(|(id, uuid)| {
Some((*id, board.players_team(&uuid_text(*uuid))?.clone()))
})
.filter_map(|(id, uuid)| Some((*id, board.players_team(&uuid_text(*uuid))?)))
.collect(),
}
}
@@ -599,6 +597,11 @@ impl Game<'_> {
/// objective.
pub(super) fn draw_tab_list(&mut self, batch: &mut HudBatch) {
use horizon_client::tab_list::{self, TabInputs};
// Player heads and the player list both draw from the same skin slots.
let ready: Vec<_> = self.skin_downloads.borrow_mut().take_ready();
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
let key = self.screen == super::Screen::Playing
&& self.keys.contains(&winit::keyboard::KeyCode::Tab);
let (players, online_mode, upside_down) = match self.remote_tab_players() {
+97 -68
View File
@@ -7,6 +7,28 @@ use horizon_client::{
ui,
};
impl Screen {
/// Screen.extractBackground for non-isInGameUi screens, and
/// PauseScreen.extractBackground only when showPauseMenu is true.
pub(super) fn blurs_background(self, pause_menu: bool) -> bool {
match self {
Self::Paused => pause_menu,
Self::Options
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::Friends => true,
Self::Playing
| Self::GameModeSwitcher
| Self::Chat
| Self::Creative
| Self::Inventory
| Self::Dead
| Self::CommandBlock => false,
}
}
}
/// PauseScreen buttons: (x, y, width, label key, action id, active).
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
@@ -43,6 +65,9 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
}
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the others stay inactive.
const FRIENDS_ICON: usize = 2;
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
let y = ((h - 166.0) * 0.25).floor() + 98.0;
@@ -69,41 +94,8 @@ impl Game<'_> {
pub(super) fn external_server(&self) -> bool {
self.remote.is_some() && self.integrated.is_none()
}
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
pub(super) fn horizon_pause(&self) -> bool {
self.screen == Screen::Paused
&& self.pause_menu
&& self.title.is_none()
&& horizon_client::ui_theme::horizon()
}
fn pause_labels(&self) -> (String, &'static str) {
if let Some(server) = &self.integrated {
return (server.world_name.clone(), "Save and quit");
}
if self.external_server() {
("Multiplayer".to_owned(), "Disconnect")
} else {
(self.level.name.clone(), "Save and quit")
}
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
if horizon_client::ui_theme::horizon() {
let opened = *self.pause_since.get_or_insert_with(std::time::Instant::now);
let (world, quit) = self.pause_labels();
self.tick_friends();
let friends = self.friends_view();
let mut ui = horizon_client::ui_theme::Ui::new(&self.gui.atlas, batch, self.cursor);
horizon_client::front::pause::draw(
&mut ui,
&world,
quit,
opened,
&friends,
self.external_server(),
);
ui.finish();
return;
}
let friends = self.friends_view().shown;
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -131,12 +123,41 @@ impl Game<'_> {
hover,
);
}
for (x, y, sprite) in pause_icons(w, h) {
ui::button(batch, atlas, x, y, 20.0, 20.0, &Text::empty(), false, false);
let mut tooltip = None;
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
let active = i == FRIENDS_ICON && friends;
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(
horizon_client::friends_screen::TITLE,
[x, y, 20.0, 20.0],
active,
);
}
ui::button(
batch,
atlas,
x,
y,
20.0,
20.0,
&Text::empty(),
active,
active && hover,
);
// SpriteIconButton.CenteredIcon: the 15x15 sprite at
// x + width / 2 - 15 / 2 in integer pixels, untinted even
// when the button is inactive.
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if i == FRIENDS_ICON && hover {
tooltip = Some(Text::literal(horizon_client::friends_screen::TITLE));
}
}
// SpriteIconButton.withTootip: the label as a tooltip.
if let Some(tip) = tooltip {
ui::tooltip(batch, atlas, &[tip], self.cursor);
}
}
pub(super) fn pause_click(&mut self) -> Result<()> {
@@ -148,29 +169,16 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let hit = if horizon_client::ui_theme::horizon() {
let size = self
.renderer
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let mut b = HudBatch::new(size);
let (world, quit) = self.pause_labels();
horizon_client::front::pause::click(
&self.gui.atlas,
&mut b,
self.cursor.0,
self.cursor.1,
&world,
quit,
&self.friends_view(),
self.external_server(),
)
} else {
pause_buttons(w, h, self.external_server())
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
};
let friends = pause_icons(w, h)[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server())
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
.or_else(|| {
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
match hit {
Some(0) => {
self.screen = Screen::Playing;
@@ -180,17 +188,11 @@ impl Game<'_> {
Some(3) => self.open_stats(),
Some(4) => self.open_advancements(true),
Some(9) => self.open_world_options(),
Some(10) => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
}
}
Some(11) => self.friends_copy(),
Some(12) => self.friends_stop(),
Some(10) => self.screen = Screen::Friends,
Some(2) => self.close_world()?,
_ => {}
}
if hit.is_some() && !horizon_client::ui_theme::horizon() {
if hit.is_some() {
self.audio.play_button_click();
}
Ok(())
@@ -334,7 +336,34 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::pause_buttons;
use super::{Screen, pause_buttons};
#[test]
fn menu_blur_matches_vanilla_screen_backgrounds() {
for screen in [
Screen::Options,
Screen::Stats,
Screen::Advancements,
Screen::WorldOptions,
] {
assert!(screen.blurs_background(false));
assert!(screen.blurs_background(true));
}
assert!(Screen::Paused.blurs_background(true));
assert!(!Screen::Paused.blurs_background(false));
for screen in [
Screen::Playing,
Screen::GameModeSwitcher,
Screen::Chat,
Screen::Creative,
Screen::Inventory,
Screen::Dead,
Screen::CommandBlock,
] {
assert!(!screen.blurs_background(true));
assert!(!screen.blurs_background(false));
}
}
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
+6 -1
View File
@@ -71,6 +71,8 @@ pub(super) struct SculkState {
/// The sculk block entities in the order they were added.
index: Index,
pub known: HashSet<(i32, i32)>,
/// The chunk map's membership revision `known` was taken at.
pub known_revision: Option<u64>,
// Keyed by mob ids the server gives out: the fast hasher is safe.
steps: FxHashMap<Who, Steps>,
damaged: FxHashMap<u32, i64>,
@@ -336,7 +338,10 @@ impl Game<'_> {
/// the block writes recorded since the last tick.
pub(super) fn sculk_sync_index(&mut self) {
// Chunks that arrived or left (every half second is plenty).
if self.sculk.ticks.is_multiple_of(10) {
// Nothing to do while no chunk came or went.
let revision = self.world.chunks.membership_revision();
if self.sculk.ticks.is_multiple_of(10) && self.sculk.known_revision != Some(revision) {
self.sculk.known_revision = Some(revision);
let loaded: HashSet<(i32, i32)> = self.world.chunks.keys().copied().collect();
let arrived: Vec<(i32, i32)> = loaded.difference(&self.sculk.known).copied().collect();
for key in arrived {
@@ -433,6 +433,7 @@ impl Game<'_> {
});
self.level_events.clear();
self.sculk.known = self.world.chunks.keys().copied().collect();
self.sculk.known_revision = Some(self.world.chunks.membership_revision());
self.sculk_sync_index();
let mut names: HashMap<u32, String> = HashMap::new();
let mut sources: Vec<u32> = Vec::new();
+18 -60
View File
@@ -73,11 +73,6 @@ impl StartupMenu {
&self.root,
);
title.last_server = settings.file.string("lastServer");
if horizon_client::ui_theme::horizon() {
title
.opening
.set(Some(horizon_client::front::Opening::new()));
}
self.title = Some(title);
}
}
@@ -152,9 +147,6 @@ impl StartupMenu {
fn title_key(&mut self, event: &KeyEvent) {
let shortcut = self.shortcut(event);
let Some(title) = &mut self.title else { return };
if title.skip_opening() {
return;
}
if let Some(shortcut) = shortcut {
if title.has_caret_field() {
title.field_shortcut(shortcut, &mut self.clipboard);
@@ -422,14 +414,6 @@ impl Loading {
event_loop: &ActiveEventLoop,
event: WindowEvent,
) -> Result<()> {
if let WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} = &event
{
horizon_client::ui_theme::set_pressed(*state == ElementState::Pressed);
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& let PhysicalKey::Code(key) = event.physical_key
{
@@ -455,7 +439,6 @@ impl Loading {
WindowEvent::Focused(false) => {
self.menu.modifiers = ModifiersState::empty();
self.menu.physical_keys.clear();
horizon_client::ui_theme::set_pressed(false);
self.menu.dragging = false;
if let Some(title) = &mut self.menu.title {
title.release();
@@ -508,41 +491,23 @@ impl Loading {
button: MouseButton::Left,
..
} => {
let mut batch = HudBatch::new(self.menu_size());
let batch = HudBatch::new(self.menu_size());
if let (Some(title), Some(gui)) = (&mut self.menu.title, &self.gui) {
if state == ElementState::Released {
title.release();
title.release_field();
} else {
let action = if horizon_client::ui_theme::horizon() {
let action = title.click_horizon(
&gui.atlas,
&mut batch,
self.menu.cursor.0,
self.menu.cursor.1,
);
// Horizon's deeper world-management pages use classic fields.
title.press_field(
&gui.atlas,
self.menu.cursor,
(batch.width, batch.height),
self.menu.modifiers.shift_key(),
);
action
} else {
let (action, _) = title.click(
self.menu.cursor.0,
self.menu.cursor.1,
(batch.width, batch.height),
);
title.press_field(
&gui.atlas,
self.menu.cursor,
(batch.width, batch.height),
self.menu.modifiers.shift_key(),
);
action
};
let (action, _) = title.click(
self.menu.cursor.0,
self.menu.cursor.1,
(batch.width, batch.height),
);
title.press_field(
&gui.atlas,
self.menu.cursor,
(batch.width, batch.height),
self.menu.modifiers.shift_key(),
);
self.menu_action(event_loop, action);
}
}
@@ -617,18 +582,12 @@ impl Loading {
} else if let Some(title) = &mut self.menu.title {
title.poll_backup();
let _ = title.update_toast(self.settings.notification_display_time);
if horizon_client::ui_theme::horizon() {
let mut ui = horizon_client::ui_theme::Ui::new(&gui.atlas, batch, self.menu.cursor);
title.draw_horizon(&mut ui, 0.0);
ui.finish();
} else {
title.draw(
&gui.atlas,
batch,
self.menu.cursor,
self.settings.file.bool("hideSplashTexts"),
);
}
title.draw(
&gui.atlas,
batch,
self.menu.cursor,
self.settings.file.bool("hideSplashTexts"),
);
}
}
@@ -730,7 +689,6 @@ mod tests {
// Navigate and enter a world name without a registry, biomes, pack
// tables or a Game. Replacing fallback artwork preserves this state.
let title = loading.menu.title.as_mut().unwrap();
title.opening.set(None);
title.mode = horizon_client::title::Mode::Create {
name: String::new(),
seed: "42".into(),
@@ -194,7 +194,9 @@ fn carry_over(from: &Mob, to: &mut Mob) {
to.fire_ticks = from.fire_ticks;
to.persistent = from.persistent;
to.custom_name = from.custom_name.clone();
to.custom_name_text = from.custom_name_text.clone();
to.name_visible = from.name_visible;
to.invisible = from.invisible;
to.extra.pick_up_loot = from.extra.pick_up_loot;
to.extra.main_hand = from.extra.main_hand.clone();
to.extra.off_hand = from.extra.off_hand.clone();
+111 -41
View File
@@ -33,6 +33,7 @@ fn entity_shader() -> String {
}
mod entity_batch;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::WATER_MASK_OVERLAY;
@@ -61,8 +62,10 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
mod arena;
mod cull;
mod end_sky;
mod flat;
mod java_stages;
pub mod lightning;
mod lod;
mod occlusion;
mod pack_stages;
@@ -163,8 +166,10 @@ pub struct Frame<'a> {
pub title: bool,
/// Dimensions without a skybox (the Nether) clear to the fog colour.
pub skybox: bool,
/// The End: clear to black and draw `celestial` (the End sky box) behind the world.
/// The End: clear to black and draw the resident sky box behind the world.
pub end_sky: bool,
/// End sky atlas bounds (u0, v0, u1, v1); absent when the sprite is missing.
pub end_sky_sprite: Option<[f32; 4]>,
/// Environmental fog start/end (Nether 10..96), if any.
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
@@ -189,16 +194,20 @@ pub struct Frame<'a> {
pub nether_lighting: bool,
/// Camera-relative entity geometry textured from the shared atlas.
pub entities: &'a [Vertex],
pub weather: &'a [crate::weather::Vertex],
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
pub dynamic_lights: &'a [[f32; 4]],
/// Cloud mesh inputs; geometry and GPU storage are retained by the renderer.
pub clouds: Option<crate::clouds::Input<'a>>,
/// Additive translucent geometry after the entities (lightning, dragon rays).
/// Retained lightning bolts, drawn before the other additive entities.
pub lightning: lightning::Input<'a>,
/// Additive translucent geometry after lightning (dragon rays).
pub additive: &'a [Vertex],
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
pub see_through: &'a [Vertex],
/// Camera-relative sun and moon, or the End sky box (drawn behind
/// everything, additive).
/// Camera-relative sun and moon, or the animated End flash (additive,
/// behind everything and after the resident End sky box).
pub celestial: &'a [Vertex],
/// The stars, drawn from their static buffer after the sun and moon:
/// the sky's rotation (camera-relative world from star space) and the
@@ -230,8 +239,6 @@ pub struct Frame<'a> {
pub night_vision: f32,
/// Torch flicker added to the block light factor of 1.4.
pub block_flicker: f32,
/// Blur and grade the frame under a Horizon-style menu (`post`).
pub post: Option<crate::post::PostGrade>,
/// `Screen.extractBlurredBackground`: blur the frame by this radius
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
pub menu_blur: Option<f32>,
@@ -324,6 +331,7 @@ pub struct Renderer {
crosshair_group: wgpu::BindGroup,
crosshair_globals: wgpu::Buffer,
entity_pipeline: wgpu::RenderPipeline,
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
water_mask_pipeline: wgpu::RenderPipeline,
@@ -334,9 +342,12 @@ pub struct Renderer {
cloud_uniform: wgpu::Buffer,
cloud_group: wgpu::BindGroup,
additive_pipeline: wgpu::RenderPipeline,
lightning_pipeline: wgpu::RenderPipeline,
lightning: lightning::Renderer,
see_through_pipeline: wgpu::RenderPipeline,
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
panorama: panorama::Panorama,
/// `RenderPipelines.WORLD_BORDER` and its buffers.
@@ -437,8 +448,6 @@ pub struct Renderer {
/// The selected shader pack's stages (Horizon setting; none by default).
pack: Option<Pack>,
pub hud: HudRenderer,
/// The menus' backdrop blur, made on first use.
post: Option<crate::post::Post>,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
pub info: wgpu::AdapterInfo,
@@ -685,13 +694,15 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
info.backend,
info.device_type
);
crate::telemetry::set_gpu(
&info.name,
format!("{} {}", info.driver, info.driver_info)
crate::telemetry::set_gpu(crate::telemetry::Gpu {
name: info.name.clone(),
vendor: horizon_telemetry::system::gpu_vendor(&info.name, info.vendor).to_owned(),
kind: crate::telemetry::gpu_kind(&format!("{:?}", info.device_type)).to_owned(),
driver: format!("{} {}", info.driver, info.driver_info)
.trim()
.to_owned(),
format!("{:?}", info.backend),
);
backend: format!("{:?}", info.backend),
});
crate::crash::set_detail(
"Graphics Adapter",
format!(
@@ -1445,6 +1456,12 @@ impl Renderer {
),
],
});
let weather = weather::Weather::new(
&device,
config.format.remove_srgb_suffix(),
&entity_bindings,
&entity_shader,
);
let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Atlas"),
..Default::default()
@@ -1664,8 +1681,17 @@ impl Renderer {
alpha_blend,
false,
);
let lightning_attributes = wgpu::vertex_attr_array![6=>Float32x3];
let lightning_vertex = [
entity_vertex[0].clone(),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &lightning_attributes,
}),
];
// BlendFunction.LIGHTNING: src alpha, one; no depth writes.
let additive_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
let additive_descriptor = wgpu::RenderPipelineDescriptor {
label: Some("Additive entities"),
layout: Some(&entity_layout),
vertex: wgpu::VertexState {
@@ -1709,6 +1735,16 @@ impl Renderer {
}),
multiview_mask: None,
cache: None,
};
let additive_pipeline = device.create_render_pipeline(&additive_descriptor);
let lightning_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Lightning"),
vertex: wgpu::VertexState {
entry_point: Some("vs_lightning"),
buffers: &lightning_vertex,
..additive_descriptor.vertex.clone()
},
..additive_descriptor
});
let celestial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Celestial bodies"),
@@ -2101,6 +2137,7 @@ impl Renderer {
crosshair_group,
crosshair_globals,
entity_pipeline,
weather,
crumbling_pipeline,
water_mask_pipeline,
cloud_pipeline,
@@ -2110,9 +2147,12 @@ impl Renderer {
cloud_uniform,
cloud_group,
additive_pipeline,
lightning_pipeline,
lightning: lightning::Renderer::new(),
see_through_pipeline,
celestial_pipeline,
celestial_buffer,
end_sky: end_sky::EndSky::default(),
stars,
panorama,
border_pipeline,
@@ -2181,7 +2221,6 @@ impl Renderer {
world_post,
pack: None,
hud,
post: None,
menu_blur: None,
info,
suspended: size.width == 0 || size.height == 0,
@@ -3229,6 +3268,13 @@ impl Renderer {
bytemuck::cast_slice(&all),
);
}
self.weather.prepare(
&self.device,
&mut encoder,
&mut self.uploads,
frame.weather,
frame.weather_time,
);
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
let lines = outline_vertices(&frame.outline[..outline_edges], camera);
@@ -3246,6 +3292,7 @@ impl Renderer {
frame.hand,
frame.see_through,
);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads =
entity_quads + crumbling_quads + translucent_crumbling_quads + water_mask_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
@@ -3279,6 +3326,13 @@ impl Renderer {
} else {
0
};
self.lightning.prepare(
&self.device,
&mut self.uploads,
&mut encoder,
&frame.lightning,
camera,
);
let all_entities = &self.entity_batch.combined;
let hand_quads = (frame.hand.len() / 4) as u32;
let additive_quads = (frame.additive.len() / 4) as u32;
@@ -3310,6 +3364,9 @@ impl Renderer {
);
}
let celestial_vertices = frame.celestial.len().min(2048 * 4);
if frame.end_sky && !frame.title {
self.end_sky.prepare(&self.device, frame.end_sky_sprite);
}
if celestial_vertices > 0 {
self.uploads.buffer(
&mut encoder,
@@ -3318,8 +3375,14 @@ impl Renderer {
bytemuck::cast_slice(&frame.celestial[..celestial_vertices]),
);
}
let max_quads =
(world_quads + additive_quads + hand_quads + see_through_quads).max(cloud_quads);
let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads)
.max(cloud_quads)
.max(if frame.lightning.bolts.is_empty() {
0
} else {
lightning::QUADS
})
.max(weather_quads);
if max_quads > self.index_quads {
self.index_quads = max_quads.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
@@ -3510,12 +3573,17 @@ impl Renderer {
if frame.title {
self.panorama.draw(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
} else if celestial_vertices > 0 {
} else if celestial_vertices > 0 || frame.end_sky {
pass.set_pipeline(&self.celestial_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
if frame.end_sky {
self.end_sky.draw(&mut pass);
}
if celestial_vertices > 0 {
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.stars.is_some() {
@@ -3602,6 +3670,8 @@ impl Renderer {
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
self.weather.draw(&mut pass, &self.entity_group);
pass.set_bind_group(0, &self.group, &[]);
if crumbling_quads > 0 {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -3622,6 +3692,12 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.lightning.bolts.is_empty() {
pass.set_pipeline(&self.lightning_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.lightning.draw(&mut pass, &frame.lightning);
pass.set_bind_group(0, &self.group, &[]);
}
if additive_quads > 0 {
pass.set_pipeline(&self.additive_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -3841,9 +3917,19 @@ impl Renderer {
let first = debug_depth + debug_top;
pass.draw(0..4, first..first + crosshair);
}
if let Some(radius) = frame.menu_blur
&& self.config.usage.contains(wgpu::TextureUsages::COPY_SRC)
{
let menu_blur = frame
.menu_blur
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
self.hud.draw_range(
&mut encoder,
&view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
0..blur_at,
);
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
self.menu_blur
@@ -3858,29 +3944,13 @@ impl Renderer {
radius,
);
}
if let Some(grade) = &frame.post
&& self.config.usage.contains(wgpu::TextureUsages::COPY_SRC)
{
let format = self.config.format;
let device = &self.device;
self.post
.get_or_insert_with(|| crate::post::Post::new(device, format))
.apply(
&self.device,
&self.queue,
&mut encoder,
&target_texture,
&view_target,
[self.config.width, self.config.height],
grade,
);
}
self.hud.draw(
self.hud.draw_range(
&mut encoder,
&view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
blur_at..frame.hud.vertices.len() as u32,
);
if culling && let Some(cull) = &mut self.cull {
cull.end(&self.device, &mut encoder);
+84
View File
@@ -0,0 +1,84 @@
//! The End sky's six tiled faces, retained for the lifetime of the renderer's atlas.
use crate::mesher::Vertex;
use wgpu::util::DeviceExt;
const VERTICES: u32 = 6 * 16 * 16 * 4;
#[derive(Default)]
pub(super) struct EndSky {
mesh: Option<([f32; 4], wgpu::Buffer)>,
}
impl EndSky {
/// Called only when the End sky is enabled. Rebuild on atlas UV changes;
/// resource reload and device recreation also replace the owning renderer.
pub(super) fn prepare(&mut self, device: &wgpu::Device, uv: Option<[f32; 4]>) {
let Some(uv) = uv else {
self.mesh = None;
return;
};
if self.mesh.as_ref().is_some_and(|(old, _)| *old == uv) {
return;
}
let vertices = vertices(uv);
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("End sky vertices"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
self.mesh = Some((uv, buffer));
}
/// The celestial pipeline, atlas bind group and quad indices are already bound.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
if let Some((_, buffer)) = &self.mesh {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw_indexed(0..VERTICES / 4 * 6, 0, 0..1);
}
}
}
/// SkyRenderer.buildEndSky: six faces of end_sky.png tiled 16 times,
/// tinted 0x282828. The atlas does not wrap, so each face is 16×16 quads.
fn vertices(uv: [f32; 4]) -> Vec<Vertex> {
let mut out = Vec::with_capacity(VERTICES as usize);
use std::f32::consts::FRAC_PI_2;
let faces = [
glam::Mat4::IDENTITY,
glam::Mat4::from_rotation_x(FRAC_PI_2),
glam::Mat4::from_rotation_x(-FRAC_PI_2),
glam::Mat4::from_rotation_x(std::f32::consts::PI),
glam::Mat4::from_rotation_z(FRAC_PI_2),
glam::Mat4::from_rotation_z(-FRAC_PI_2),
];
let step = 200.0 / 16.0;
for pose in faces {
for a in 0..16 {
for b in 0..16 {
let (x0, z0) = (-100.0 + step * a as f32, -100.0 + step * b as f32);
let (x1, z1) = (x0 + step, z0 + step);
let corners = [
(x0, z0, [uv[0], uv[1]]),
(x0, z1, [uv[0], uv[3]]),
(x1, z1, [uv[2], uv[3]]),
(x1, z0, [uv[2], uv[1]]),
];
for (x, z, uv) in corners {
let p = pose.transform_point3(glam::Vec3::new(x, -100.0, z));
out.push(Vertex {
normal: [0; 4],
pos: p.to_array(),
uv,
layer: 2,
color: 0xFF28_2828,
light: 0,
});
}
}
}
}
out
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,330 @@
use super::*;
use crate::gfx::{Globals, Renderer};
use bytemuck::Zeroable;
use glam::Mat4;
#[test]
fn geometry_matches_pre_residency_vertex_snapshots() {
use sha2::{Digest, Sha256};
// Captured from game/end.rs at 34c3c1cf for full, tiled and non-binary UVs.
// Quantize positions only, avoiding platform sin/cos rounding differences;
// quad order, UVs, tint, material and light retain their exact byte values.
for (uv, expected) in [
(
[0.0, 0.0, 1.0, 1.0],
"f7ee23457fc557b33ecf09c9afaf858f3d8907e108abf06055f627523f733536",
),
(
[0.125, 0.25, 0.375, 0.5],
"f74c4b8d23ec2b7c11b5752821d0733851bf11358f1b26f9448226ff62a6a318",
),
(
[0.13, 0.27, 0.41, 0.59],
"f0aaa39c5b22168bdc7c6c329acb4d3598a2feb61ff7b6f2850b644682749b39",
),
] {
let mut mesh = vertices(uv);
assert_eq!(mesh.len(), VERTICES as usize);
for vertex in &mut mesh {
vertex.pos = vertex.pos.map(|x| {
let n = (x * 1024.0).round() / 1024.0;
if n == 0.0 { 0.0 } else { n }
});
}
assert_eq!(
format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh))),
expected
);
}
}
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()?;
eprintln!("End sky test adapter: {:?}", adapter.get_info());
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
#[test]
fn resident_buffer_survives_frames_and_refreshes_with_the_atlas() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut sky = EndSky::default();
let uv = [0.125, 0.25, 0.375, 0.5];
sky.prepare(&device, Some(uv));
let first = sky.mesh.as_ref().unwrap().1.clone();
assert_eq!(
first.size(),
u64::from(VERTICES) * size_of::<Vertex>() as u64
);
// Immutable: steady-state frames cannot upload into this buffer.
assert_eq!(first.usage(), wgpu::BufferUsages::VERTEX);
for _ in 0..600 {
sky.prepare(&device, Some(uv));
assert_eq!(sky.mesh.as_ref().unwrap().1, first);
}
let moved = [0.5, 0.5, 0.75, 0.75];
sky.prepare(&device, Some(moved));
assert_ne!(sky.mesh.as_ref().unwrap().1, first);
assert_eq!(sky.mesh.as_ref().unwrap().0, moved);
sky.prepare(&device, None);
assert!(sky.mesh.is_none());
sky.prepare(&device, Some(uv));
assert_ne!(sky.mesh.as_ref().unwrap().1, first);
// A resource reload/device recreation starts a new renderer-owned cache,
// even if the replacement atlas happens to use exactly the same UVs.
let mut replacement = EndSky::default();
replacement.prepare(&device, Some(uv));
assert_ne!(
replacement.mesh.as_ref().unwrap().1,
sky.mesh.as_ref().unwrap().1
);
}
/// The actual entity shader and celestial blend, comparing the old combined
/// vertex stream with a resident sky draw followed by a separate flash draw.
#[test]
fn separate_draws_match_combined_celestial_pixels() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("End sky differential shader"),
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];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("End sky differential pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
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: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let texture = |pixels: &[u8], width, height| {
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,
pixels,
)
};
// Asymmetric, translucent texels expose UV/winding and blend-order errors.
let pixels: Vec<u8> = (0..16 * 16)
.flat_map(|i| {
[
(i * 17) as u8,
(i * 31) as u8,
(i * 7) as u8,
(80 + i % 176) as u8,
]
})
.collect();
let atlas = texture(&pixels, 16, 16).create_view(&Default::default());
let lightmap = texture(&[255; 4], 1, 1).create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: size_of::<Globals>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&atlas),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&lightmap),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let indices = Renderer::quad_indices(&device, VERTICES / 4 + 1);
let mut resident = EndSky::default();
let uv = [0.125, 0.25, 0.375, 0.5];
resident.prepare(&device, Some(uv));
let first = resident.mesh.as_ref().unwrap().1.clone();
let render = |sky: &EndSky, flash: &[Vertex], combined: bool| {
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 64,
height: 64,
depth_or_array_layers: 1,
},
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 view = target.create_view(&Default::default());
let mut vertices = if combined { vertices(uv) } else { Vec::new() };
vertices.extend_from_slice(flash);
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 64 * 64 * 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
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);
if !combined {
sky.draw(&mut pass);
}
if !vertices.is_empty() {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw_indexed(0..vertices.len() as u32 / 4 * 6, 0, 0..1);
}
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(64),
},
},
target.size(),
);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
readback.slice(..).get_mapped_range().unwrap().to_vec()
};
let mut cases = 0;
let mut visible_flash_cases = 0;
for yaw in [0.0_f32, 45.0, 90.0, 180.0, 270.0] {
for pitch in [-85.0_f32, -30.0, 0.0, 45.0, 85.0] {
let mut globals = Globals::zeroed();
globals.view_proj = (Mat4::perspective_rh(90_f32.to_radians(), 1.0, 0.05, 1024.0)
* Mat4::from_rotation_x(pitch.to_radians())
* Mat4::from_rotation_y(yaw.to_radians()))
.to_cols_array_2d();
queue.write_buffer(&uniform, 0, bytemuck::bytes_of(&globals));
let no_flash = render(&resident, &[], true);
for level in [0_u32, 1, 127, 255] {
let flash: Vec<_> = if level == 0 {
Vec::new()
} else {
// Same flash size/height, with an arbitrary EndFlashState pose.
let pose = Mat4::from_rotation_y(1.3) * Mat4::from_rotation_x(-1.6);
[(-60.0, -60.0), (60.0, -60.0), (60.0, 60.0), (-60.0, 60.0)]
.map(|(x, z)| Vertex {
pos: pose.transform_point3(glam::vec3(x, 100.0, z)).to_array(),
uv: [0.75 + x / 480.0, 0.75 + z / 480.0],
layer: 2,
color: level * 0x01010101,
light: 0,
normal: [0; 4],
})
.to_vec()
};
resident.prepare(&device, Some(uv));
let expected = render(&resident, &flash, true);
let actual = render(&resident, &flash, false);
visible_flash_cases += usize::from(expected != no_flash);
assert_eq!(actual, expected, "yaw={yaw} pitch={pitch} flash={level}");
assert!(
actual.as_chunks::<4>().0.iter().any(|p| p[..3] != [0; 3]),
"empty frame"
);
assert_eq!(resident.mesh.as_ref().unwrap().1, first);
cases += 1;
}
}
}
assert!(
visible_flash_cases > 0,
"the flash must affect the comparison"
);
eprintln!("{cases} combined/resident framebuffer cases matched exactly");
}
+211
View File
@@ -0,0 +1,211 @@
//! Seeded, bolt-local lightning meshes retained until their last active bolt disappears.
use crate::{gfx::upload::Uploads, mesher::Vertex};
use glam::DVec3;
use horizon_core::random::{LegacyRandom, RandomSource};
use std::collections::HashMap;
use wgpu::util::DeviceExt;
/// A bolt's render state. Simulation lifetime and flashes stay in the game.
#[derive(Clone, Copy)]
pub struct Bolt {
pub at: [f64; 3],
pub seed: i64,
}
#[derive(Default)]
pub struct Input<'a> {
pub bolts: &'a [Bolt],
/// Current atlas white texel; a changed rect invalidates retained vertices.
pub uv: [f32; 2],
}
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
pub(super) const QUADS: u32 = 4 * 14 * 4;
/// `LightningBoltRenderer.submit`: keep random draws, layers and quad order unchanged.
fn geometry(seed: i64, uv: [f32; 2]) -> Vec<Vertex> {
let mut out = Vec::with_capacity(QUADS as usize * 4);
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
let mut random = LegacyRandom::new(seed);
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
let (mut x_off, mut z_off) = (0f32, 0f32);
for i in (0..8).rev() {
xs[i] = x_off;
zs[i] = z_off;
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
}
for layer in 0..4 {
let mut random = LegacyRandom::new(seed);
for branch in 0..3 {
let trunk = branch == 0;
let start = 7 - branch;
let end = if trunk { 0 } else { start - 3 + 1 };
let mut sx = xs[start] - x_off;
let mut sz = zs[start] - z_off;
for segment in (end..=start).rev() {
let (ex, ez) = (sx, sz);
let spread = if trunk { 11 } else { 31 };
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
let mut top = 0.1 + layer as f32 * 0.2;
let mut bottom = top;
if trunk {
top *= segment as f32 * 0.1 + 1.0;
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
}
for (rx, rz, lx, lz) in [
(false, false, true, false),
(true, false, true, true),
(true, true, false, true),
(false, true, false, false),
] {
let s = |p: bool, r: f32| if p { r } else { -r };
let y0 = segment as f32 * 16.0;
let corners = [
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
];
for c in corners {
out.push(Vertex {
normal: [0; 4],
pos: c,
uv,
layer: 2,
color,
light: 240 | 240 << 8,
});
}
}
}
}
}
out
}
struct Cached<T> {
mesh: T,
used: bool,
}
/// Shared seeds use the same mesh; storage is bounded by active unique seeds,
/// including after seed changes, removals, dimension switches and disconnects.
struct Cache<T> {
uv: Option<[f32; 2]>,
meshes: HashMap<i64, Cached<T>>,
}
impl<T> Cache<T> {
fn new() -> Self {
Self {
uv: None,
meshes: HashMap::new(),
}
}
fn prepare(&mut self, input: &Input<'_>, mut build: impl FnMut(i64, [f32; 2]) -> T) {
if self.uv != Some(input.uv) {
self.meshes.clear();
self.uv = Some(input.uv);
}
for cached in self.meshes.values_mut() {
cached.used = false;
}
for bolt in input.bolts {
self.meshes
.entry(bolt.seed)
.or_insert_with(|| Cached {
mesh: build(bolt.seed, input.uv),
used: true,
})
.used = true;
}
self.meshes.retain(|_, cached| cached.used);
// HashMap::retain keeps its allocation. Release historical peak capacity
// as well as meshes once the storm (or connection) is gone.
if self.meshes.is_empty() {
self.meshes = HashMap::new();
} else if self.meshes.capacity() > self.meshes.len() * 4 {
self.meshes.shrink_to_fit();
}
}
}
fn offset(bolt: &Bolt, camera: DVec3) -> [f32; 3] {
(DVec3::from_array(bolt.at) - camera).as_vec3().to_array()
}
pub(super) struct Renderer {
cache: Cache<wgpu::Buffer>,
instances: Option<wgpu::Buffer>,
capacity: usize,
}
impl Renderer {
pub(super) fn new() -> Self {
Self {
cache: Cache::new(),
instances: None,
capacity: 0,
}
}
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
input: &Input<'_>,
camera: DVec3,
) {
self.cache.prepare(input, |seed, uv| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Lightning vertices"),
contents: bytemuck::cast_slice(&geometry(seed, uv)),
usage: wgpu::BufferUsages::VERTEX,
})
});
if input.bolts.is_empty() {
self.instances = None;
self.capacity = 0;
return;
}
if input.bolts.len() > self.capacity || input.bolts.len() < self.capacity / 4 {
self.capacity = input.bolts.len().next_power_of_two();
self.instances = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Lightning offsets"),
size: (self.capacity * std::mem::size_of::<[f32; 3]>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
let offsets: Vec<_> = input
.bolts
.iter()
.map(|bolt| offset(bolt, camera))
.collect();
uploads.buffer(
encoder,
self.instances.as_ref().unwrap(),
0,
bytemuck::cast_slice(&offsets),
);
}
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, input: &Input<'_>) {
let Some(instances) = &self.instances else {
return;
};
pass.set_vertex_buffer(1, instances.slice(..));
// Follow simulation order, including repeated seeds; HashMap order must
// never change additive accumulation or the order before dragon rays.
for (i, bolt) in input.bolts.iter().enumerate() {
pass.set_vertex_buffer(0, self.cache.meshes[&bolt.seed].mesh.slice(..));
pass.draw_indexed(0..QUADS * 6, 0, i as u32..i as u32 + 1);
}
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,495 @@
use super::*;
use std::cell::Cell;
// Frozen pre-cache generator: compare every Vertex byte, including primitive
// order and the original camera-relative rounding, without game resources.
fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec<Vertex> {
let mut out = Vec::new();
// ARGB 0.45, 0.45, 0.5 at 0.3 alpha, packed RGBA.
let color = (76u32 << 24) | (127 << 16) | (114 << 8) | 114;
let b = Bolt { seed, at };
{
let o = (glam::DVec3::from_array(b.at) - camera).as_vec3();
let mut random = LegacyRandom::new(b.seed);
let (mut xs, mut zs) = ([0f32; 8], [0f32; 8]);
let (mut x_off, mut z_off) = (0f32, 0f32);
for i in (0..8).rev() {
xs[i] = x_off;
zs[i] = z_off;
x_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
z_off += (random.next_int_bound(11).unwrap_or(5) - 5) as f32;
}
for layer in 0..4 {
let mut random = LegacyRandom::new(b.seed);
for branch in 0..3 {
let trunk = branch == 0;
let start = 7 - branch;
let end = if trunk { 0 } else { start - 3 + 1 };
let mut sx = xs[start] - x_off;
let mut sz = zs[start] - z_off;
for segment in (end..=start).rev() {
let (ex, ez) = (sx, sz);
let spread = if trunk { 11 } else { 31 };
sx += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
sz += (random.next_int_bound(spread).unwrap_or(0) - spread / 2) as f32;
let mut top = 0.1 + layer as f32 * 0.2;
let mut bottom = top;
if trunk {
top *= segment as f32 * 0.1 + 1.0;
bottom *= (segment as f32 - 1.0) * 0.1 + 1.0;
}
for (rx, rz, lx, lz) in [
(false, false, true, false),
(true, false, true, true),
(true, true, false, true),
(false, true, false, false),
] {
let s = |p: bool, r: f32| if p { r } else { -r };
let y0 = segment as f32 * 16.0;
let corners = [
[sx + s(rx, bottom), y0, sz + s(rz, bottom)],
[ex + s(rx, top), y0 + 16.0, ez + s(rz, top)],
[ex + s(lx, top), y0 + 16.0, ez + s(lz, top)],
[sx + s(lx, bottom), y0, sz + s(lz, bottom)],
];
for c in corners {
out.push(Vertex {
normal: [0; 4],
pos: (o + glam::Vec3::from_array(c)).to_array(),
uv,
layer: 2,
color,
light: 240 | 240 << 8,
});
}
}
}
}
}
}
out
}
fn seeds() -> Vec<i64> {
let mut seeds = vec![0, 1, -1, i64::MIN, i64::MAX, 0x123456789abcdef];
let mut random = LegacyRandom::new(42);
seeds.extend((0..256).map(|_| random.next_long()));
seeds
}
/// Pinned 26.3 LightningBoltRenderer.lambda$submit$0, captured with an
/// identity PoseStack and a VertexConsumer recording big-endian xyz floats.
#[test]
fn geometry_matches_pinned_vanilla_vertex_digest() {
use sha2::{Digest, Sha256};
let mut digest = Sha256::new();
for seed in seeds() {
for vertex in geometry(seed, [0.0; 2]) {
for value in vertex.pos {
digest.update(value.to_be_bytes());
}
}
}
assert_eq!(
format!("{:x}", digest.finalize()),
"a9ed25caa50b9aa340faa4bb417b3d533b061f0dce66514e4bb212931444dee6"
);
}
#[test]
fn retained_geometry_matches_original_generator() {
let uv = [0.8125, 0.03125];
for seed in seeds() {
let local = geometry(seed, uv);
assert_eq!(local.len(), QUADS as usize * 4);
for (at, camera) in [
([0.0; 3], DVec3::ZERO),
([12.5, 64.0, -12.5], DVec3::new(-4.25, 71.125, 32.5)),
(
[29_999_999.5, -64.0, -29_999_999.5],
DVec3::new(29_999_987.125, 67.75, -29_999_983.875),
),
] {
let offset = offset(&Bolt { seed, at }, camera);
let translated: Vec<_> = local
.iter()
.map(|v| Vertex {
pos: (glam::Vec3::from_array(offset) + glam::Vec3::from_array(v.pos))
.to_array(),
..*v
})
.collect();
let expected = reference(seed, at, camera, uv);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&translated),
bytemuck::cast_slice::<_, u8>(&expected),
"seed {seed}"
);
}
}
}
#[test]
fn cache_tracks_seed_lifecycle_movement_and_atlas_reload() {
let mut cache = Cache::new();
let builds = Cell::new(0);
let build = |seed, uv| {
builds.set(builds.get() + 1);
geometry(seed, uv)
};
let mut bolts = [
Bolt {
seed: 123,
at: [0.0; 3],
},
Bolt {
seed: 456,
at: [1.0; 3],
},
];
fn input(bolts: &[Bolt]) -> Input<'_> {
Input {
bolts,
uv: [0.25, 0.5],
}
}
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 2);
assert_eq!(cache.meshes.len(), 2);
// Any number of rendered frames or multiplayer position updates reuse meshes.
for i in 0..100 {
bolts[0].at = [i as f64; 3];
cache.prepare(&input(&bolts), build);
}
assert_eq!(builds.get(), 2);
// A flash changes the seed; retire its previous mesh immediately.
bolts[0].seed = 789;
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 3);
assert!(!cache.meshes.contains_key(&123));
// Removal retains other bolts, and repeated seeds still share geometry.
bolts[0].seed = 456;
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 3);
assert_eq!(cache.meshes.len(), 1);
// Atlas replacement must update white texel UVs without changing the seed.
cache.prepare(
&Input {
bolts: &bolts,
uv: [0.75, 0.25],
},
build,
);
assert_eq!(builds.get(), 4);
assert_eq!(cache.meshes[&456].mesh[0].uv, [0.75, 0.25]);
// Disconnect/dimension switch/expiry drops every retained resource.
cache.prepare(&Input::default(), build);
assert!(cache.meshes.is_empty());
assert_eq!(cache.meshes.capacity(), 0);
cache.prepare(&input(&bolts), build);
assert_eq!(builds.get(), 5);
}
#[test]
fn long_flash_sequence_keeps_only_active_seeds() {
let mut cache = Cache::new();
for seed in 0..10_000 {
cache.prepare(
&Input {
bolts: &[Bolt { seed, at: [0.0; 3] }],
uv: [0.0; 2],
},
|seed, _| seed,
);
assert_eq!(cache.meshes.len(), 1);
assert_eq!(cache.meshes[&seed].mesh, seed);
assert!(cache.meshes.capacity() < 16);
}
}
/// Exercise the actual entity shader and retained draw path offscreen. Run
/// explicitly, separately from other headless device tests (some GPU drivers
/// fail during concurrent device teardown). Machines without an adapter skip it.
#[test]
#[ignore = "requires GPU; run separately with --ignored --exact"]
fn retained_gpu_draw_matches_transient_additive_draw() {
use bytemuck::Zeroable;
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;
};
eprintln!("lightning differential GPU: {}", adapter.get_info().name);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Lightning differential shader"),
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];
let offsets = wgpu::vertex_attr_array![6=>Float32x3];
let vertices = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})];
let retained_vertices = [
vertices[0].clone(),
Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &offsets,
}),
];
let pipelines: Vec<_> = [false, true]
.into_iter()
.map(|retained| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Lightning differential pipeline"),
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(if retained {
"vs_lightning"
} else {
"vs_entity"
}),
compilation_options: Default::default(),
buffers: if retained {
&retained_vertices
} else {
&vertices
},
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
let globals = crate::gfx::Globals {
view_proj: (glam::Mat4::orthographic_rh(-100.0, 100.0, -10.0, 150.0, 0.1, 400.0)
* glam::Mat4::look_at_rh(
glam::Vec3::new(80.0, 30.0, 160.0),
glam::Vec3::new(0.0, 60.0, 0.0),
glam::Vec3::Y,
))
.to_cols_array_2d(),
..crate::gfx::Globals::zeroed()
};
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
let size = wgpu::Extent3d {
width: 256,
height: 256,
depth_or_array_layers: 1,
};
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let white = device
.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&[255; 4],
)
.create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let groups: Vec<_> = pipelines
.iter()
.map(|pipeline| {
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(&white),
},
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),
},
],
})
})
.collect();
let mut renderer = Renderer::new();
let mut uploads = Uploads::new(&device);
let mut bolts = [Bolt {
seed: 123,
at: [29_999_999.5, 64.0, -29_999_999.5],
}; 3];
bolts[1].seed = -456;
bolts[1].at[0] -= 32.0;
bolts[2].at[2] += 20.0;
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS * 3);
for frame in 0..4 {
let camera = DVec3::new(29_999_997.125 + frame as f64, 62.75, -29_999_998.875);
if frame == 2 {
bolts[0].seed = 789;
}
let input = Input {
bolts: &bolts[..if frame == 3 { 1 } else { 3 }],
uv: if frame == 3 { [0.75; 2] } else { [0.5; 2] },
};
let expected: Vec<_> = input
.bolts
.iter()
.flat_map(|bolt| reference(bolt.seed, bolt.at, camera, input.uv))
.collect();
let transient = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&expected),
usage: wgpu::BufferUsages::VERTEX,
});
let mut encoder = device.create_command_encoder(&Default::default());
renderer.prepare(&device, &mut uploads, &mut encoder, &input, camera);
let mut readbacks = Vec::new();
for mode in 0..2 {
let color = texture(
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = color.create_view(&Default::default());
let depth = texture(
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
)
.create_view(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&pipelines[mode]);
pass.set_bind_group(0, &groups[mode], &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
if mode == 0 {
pass.set_vertex_buffer(0, transient.slice(..));
pass.draw_indexed(0..expected.len() as u32 / 4 * 6, 0, 0..1);
} else {
renderer.draw(&mut pass, &input);
}
}
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256 * 256 * 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(1024),
rows_per_image: None,
},
},
size,
);
readbacks.push(read);
}
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
for read in &readbacks {
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels: Vec<Vec<u8>> = readbacks
.iter()
.map(|read| read.slice(..).get_mapped_range().unwrap().to_vec())
.collect();
assert!(pixels[0].iter().any(|&b| b != 0), "empty reference frame");
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
}
}
+330
View File
@@ -0,0 +1,330 @@
//! Weather's unsplit quads, using the entity atlas/lightmap resources.
use crate::weather::{Time, Vertex};
pub(super) struct Weather {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
capacity: usize,
count: u32,
}
impl Weather {
pub fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
shader: &wgpu::ShaderModule,
) -> Self {
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Weather time"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Weather layout"),
bind_group_layouts: &[Some(entities), Some(&bindings)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Weather"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_weather"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_weather"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Weather time"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Weather time"),
layout: &bindings,
entries: &[wgpu::BindGroupEntry {
binding: 1,
resource: uniform.as_entire_binding(),
}],
});
Self {
pipeline,
group,
uniform,
vertices: Self::buffer(device, 4),
capacity: 4,
count: 0,
}
}
fn buffer(device: &wgpu::Device, capacity: usize) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Weather vertices"),
size: (capacity * std::mem::size_of::<Vertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
pub fn prepare(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
vertices: &[Vertex],
time: Time,
) {
self.count = vertices.len() as u32;
if vertices.is_empty() {
return;
}
if vertices.len() > self.capacity {
self.capacity = vertices.len().next_power_of_two();
self.vertices = Self::buffer(device, self.capacity);
}
uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices));
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time));
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
// The renderer binds the shared triangle-list quad indices.
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
}
#[cfg(test)]
mod tests {
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Case {
animation: [f32; 4],
bases: [u32; 4],
time: [f32; 4],
uv: [f32; 2],
snow: u32,
_pad: u32,
}
/// Execute the production WGSL math, rather than a Rust translation of it.
/// Requires a GPU; run explicitly on renderer changes, not on headless CI.
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn weather_shader_uvs_match_legacy_bits() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
eprintln!("weather parity adapter: {:?}", adapter.get_info());
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap();
let mut cases = Vec::new();
let mut expected = Vec::new();
for i in 0..4096i32 {
let x = i.wrapping_mul(45_238_971);
let z = i.wrapping_mul(-418_711).wrapping_add(i32::MIN);
let p = Parameters::new(x, z);
let bases = p.snow_base.map(f64::to_bits);
for ticks in [-1, 0, 511, 512, 131_071, 131_072, i64::MAX] {
let partial = [0.0, 0.5, 0.999, f32::from_bits(0x3f7fffff)][i as usize % 4];
for snow in [false, true] {
let column = p.column(snow, ticks, x, -64, 320, z, 0, partial);
let uv = [1.0, if i & 1 == 0 { -16.0 } else { 80.0 }];
cases.push(Case {
animation: [
p.rain_speed,
p.rain_tick_offset,
p.snow_drift[0],
p.snow_drift[1],
],
bases: [
bases[0] as u32,
(bases[0] >> 32) as u32,
bases[1] as u32,
(bases[1] >> 32) as u32,
],
time: Time::new(ticks, partial).values,
uv,
snow: u32::from(snow),
_pad: 0,
});
expected.push([
(uv[0] + column.u_offset).to_bits(),
(uv[1] + column.v_offset).to_bits(),
]);
}
}
}
// Force halfway rounding, cancellation, zero, tiny bases and either
// sign of drift. Gaussian seeds alone rarely land on exact midpoints.
for base in [
0.0,
2.0f64.powi(-53),
0.5,
0.5 + 2.0f64.powi(-25),
0.5 + 2.0f64.powi(-25) + 2.0f64.powi(-53),
0.5 + 2.0f64.powi(-14) + 2.0f64.powi(-53),
1.0 - 2.0f64.powi(-53),
] {
for delta in [0.0f32, -0.0, -0.5, 0.5, 1024.0, -1024.0, 2.0f32.powi(-40)] {
// time=100, u scale=.01 -> delta exactly, v is unused.
let bits = base.to_bits();
cases.push(Case {
animation: [3.0, 0.0, delta, 0.0],
bases: [bits as u32, (bits >> 32) as u32, 0, 0],
time: [100.0, 0.0, 0.0, 0.0],
uv: [0.0, 0.0],
snow: 1,
_pad: 0,
});
expected.push([((base + f64::from(delta)) as f32).to_bits(), 0]);
}
}
let expected: Vec<[u32; 4]> = expected
.into_iter()
.map(|bits| {
let uv = bits.map(f32::from_bits);
let texel = [
256 + ((uv[0] - uv[0].floor()) * 16.0).floor() as u32,
512 + ((uv[1] - uv[1].floor()) * 32.0).floor() as u32,
];
[bits[0], bits[1], texel[0], texel[1]]
})
.collect();
let math = include_str!("../entity.wgsl")
.split("// WEATHER_MATH_BEGIN")
.nth(1)
.unwrap()
.split("// WEATHER_MATH_END")
.next()
.unwrap();
let source = format!(
"{math}\n{}",
r#"
struct Case { animation: vec4<f32>, bases: vec4<u32>, time: vec4<f32>, uv: vec2<f32>, snow: u32, pad: u32 }
@group(0) @binding(0) var<storage, read> cases: array<Case>;
@group(0) @binding(1) var<storage, read_write> results: array<vec4<u32>>;
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if id.x >= arrayLength(&cases) { return; }
let c = cases[id.x];
let uv = c.uv + weather_phase(c.animation, c.bases, c.snow, c.time);
let texel = weather_texel(uv, vec4(0.25, 0.5, 0.015625, 0.03125), vec2(1024u));
results[id.x] = vec4(bitcast<vec2<u32>>(uv), vec2<u32>(texel));
}"#
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Weather parity"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Weather parity"),
layout: None,
module: &shader,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&cases),
usage: wgpu::BufferUsages::STORAGE,
});
let size = (expected.len() * 16) as u64;
let output = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: input.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: output.as_entire_binding(),
},
],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups((cases.len() as u32).div_ceil(64), 1, 1);
}
encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, size);
queue.submit([encoder.finish()]);
let (tx, rx) = std::sync::mpsc::channel();
readback.map_async(wgpu::MapMode::Read, .., move |r| tx.send(r).unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
rx.recv().unwrap().unwrap();
let mapped = readback.get_mapped_range(..).unwrap();
let actual: &[[u32; 4]] = bytemuck::cast_slice(&mapped);
for (i, (actual, expected)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(
actual, expected,
"case {i}, snow={}, time={:?}, animation={:?}",
cases[i].snow, cases[i].time, cases[i].animation
);
}
eprintln!("{} GPU weather UV cases matched bit for bit", cases.len());
}
}
+3 -7
View File
@@ -204,8 +204,6 @@ pub struct Graphics {
pub resolution_scale: u32,
/// FXAA over the world.
pub fxaa: bool,
/// The settings level last shown: Advanced, or Simple.
pub advanced: bool,
/// 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.
@@ -236,7 +234,6 @@ impl Default for Graphics {
overlay: Overlay::Off,
resolution_scale: 100,
fxaa: false,
advanced: false,
battery: false,
shader_pack: String::new(),
api: GraphicsApi::Auto,
@@ -288,7 +285,6 @@ impl Graphics {
"workers" => g.workers = number(g.workers).min(64),
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
"fxaa" => g.fxaa = flag(g.fxaa),
"level" => g.advanced = value == "advanced",
"batteryMode" => g.battery = flag(g.battery),
"shaderPack" => g.shader_pack = value.to_string(),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
@@ -306,7 +302,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nlevel:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
"preset:{}\nlodDistance:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
self.preset.name(),
self.lod_distance,
bool_text(self.cave_culling),
@@ -331,7 +327,6 @@ impl Graphics {
},
self.resolution_scale,
bool_text(self.fxaa),
if self.advanced { "advanced" } else { "simple" },
bool_text(self.battery),
self.shader_pack,
self.api.name(),
@@ -425,13 +420,14 @@ mod tests {
overlay: Overlay::Graph,
resolution_scale: 75,
fxaa: true,
advanced: true,
battery: true,
shader_pack: "horizon-sample".into(),
api: GraphicsApi::Dx12,
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// The old menus' Simple / Advanced choice is ignored.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
Graphics::default()
+2 -2
View File
@@ -285,7 +285,7 @@ pub fn rows(level: Level) -> Vec<Row> {
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// Classic's submenus supplement the vanilla options on their parent screens.
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
fn extra_rows(level: Level) -> Vec<Row> {
let mut rows = rows(level);
rows.retain(|row| !matches!(row, Row::Setting(s) if matches!(s.item, Item::Vanilla(_))));
@@ -444,7 +444,7 @@ fn max_workers() -> u32 {
}
impl Item {
/// On/off settings, drawn as switches by the Horizon menus.
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
Some(match self {
+2 -2
View File
@@ -2,8 +2,8 @@
//! launcher's "N playing now". While the game runs, a thread of its own sends a random session
//! ID made here and kept only in memory, the version, the platform and whether a LAN or
//! play-with-friends world is open, every minute and at once when that last one changes.
//! It sends nothing when there is no server (`HORIZON_TELEMETRY_URL`, none by default) or
//! with `HORIZON_NO_HEARTBEAT=1` (automated runs).
//! It sends nothing when sending is turned off (`HORIZON_TELEMETRY_URL=off`) or with
//! `HORIZON_NO_HEARTBEAT=1` (automated runs).
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::Thread;
+105 -26
View File
@@ -30,8 +30,10 @@ pub struct HudAtlas {
}
/// Atlas rows reserved for glyphs rasterized at run time.
pub const GLYPH_ROWS: u32 = 512;
/// 128x128 atlas slots for map textures.
pub const MAP_SLOTS: usize = 16;
/// 128x128 atlas slots for map textures. A lobby paints its signs as walls of
/// framed maps, so the pool holds a wall; too few slots re-upload every map
/// every frame. 26.3's `MapTextureManager` keeps a texture per map instead.
pub const MAP_SLOTS: usize = 256;
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
@@ -82,11 +84,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
use crate::atlas_sources::{self, SpriteOrigin};
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let textures = atlas_sources::list_textures(root);
let load = |texture: &str, directory: &str| {
decode(&atlas_sources::texture_path(root, texture, directory)).ok()
};
let mut out: Vec<PackedImage> = Vec::new();
let mut added = std::collections::HashSet::new();
// Each new name with its origins in the atlases' order: the first that
// loads is the sprite. They load at once (a content pack's atlases list
// tens of thousands of files), and come out in the order a load of one
// after another would give.
let mut names: Vec<(String, Vec<(usize, SpriteOrigin)>)> = Vec::new();
let mut slots = std::collections::HashMap::new();
let mut order = 0;
for (atlas, sources) in atlas_sources::atlases(root) {
// The other atlases' renamed files (GUI sprites, particles...) are
// looked up by their texture paths here.
@@ -96,18 +100,33 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
continue;
}
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
if have.contains(name.as_str()) || added.contains(&name) {
if have.contains(name.as_str()) {
continue;
}
let image = match &origin {
// A vanilla file is packed under its own name already: only a
// renamed one is new. Other namespaces' files (content mods) are
// never in the walked directories.
if let SpriteOrigin::File(texture) = &origin
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
{
continue;
}
let slot = *slots.entry(name.clone()).or_insert_with(|| {
names.push((name, Vec::new()));
names.len() - 1
});
names[slot].1.push((order, origin));
order += 1;
}
}
let load = |texture: &str, directory: &str| {
decode(&atlas_sources::texture_path(root, texture, directory)).ok()
};
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
origins.iter().find_map(|(order, origin)| {
let image = match origin {
SpriteOrigin::File(texture) => {
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
// A vanilla file is packed under its own name already:
// only a renamed one is new. Other namespaces' files
// (content mods) are never in the walked directories.
if texture.starts_with("minecraft:") && path == name {
continue;
}
load(texture, "textures").map(|(w, h, p)| {
// Animated strips keep their first frame, like the packed files.
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
@@ -119,13 +138,15 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
}
generated => atlas_sources::generate(generated, &load),
};
if let Some((w, h, pixels)) = image {
added.insert(name.clone());
out.push((name, w, h, pixels));
}
}
}
out
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
})
};
let mut out: Vec<_> = crate::par::par_map(&names, first)
.into_iter()
.flatten()
.collect();
out.sort_unstable_by_key(|(order, _)| *order);
out.into_iter().map(|(_, image)| image).collect()
}
impl HudAtlas {
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
@@ -158,7 +179,7 @@ impl HudAtlas {
)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world icons and the Horizon menu text.
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
@@ -182,7 +203,7 @@ impl HudAtlas {
vec![0; 64 * 64 * 4],
));
}
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
@@ -363,6 +384,8 @@ pub struct HudBatch {
pub patches: Vec<crate::font::Patch>,
/// Vertex ranges with another blend or depth/scissor state, in order.
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
}
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -398,6 +421,7 @@ impl HudBatch {
height: (window[1] as f32 / scale as f32).ceil(),
patches: Vec::new(),
blends: Vec::new(),
blur_at: 0,
}
}
/// Draw the vertices added since `start` with the inverting blend.
@@ -450,6 +474,14 @@ impl HudBatch {
}
out
}
fn runs_in(&self, vertices: std::ops::Range<u32>) -> Vec<(std::ops::Range<u32>, Blend)> {
let mut runs = self.runs();
for (range, _) in &mut runs {
*range = range.start.max(vertices.start)..range.end.min(vertices.end);
}
runs.retain(|(range, _)| !range.is_empty());
runs
}
/// `AbstractContainerScreen.init`: center in integer GUI coordinates.
pub fn centered_origin(&self, width: f32, height: f32) -> (f32, f32) {
(
@@ -1078,7 +1110,26 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
) {
if batch.vertices.is_empty() || size.contains(&0) {
self.draw_range(
encoder,
target,
depth,
batch,
size,
0..batch.vertices.len() as u32,
);
}
/// Draw part of the prepared batch, preserving its blend/depth runs.
pub fn draw_range(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
depth: &wgpu::TextureView,
batch: &HudBatch,
size: [u32; 2],
vertices: std::ops::Range<u32>,
) {
if vertices.is_empty() || size.contains(&0) {
return;
}
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -1090,7 +1141,7 @@ impl HudRenderer {
store: wgpu::StoreOp::Store,
},
})];
let runs = batch.runs();
let runs = batch.runs_in(vertices);
let mut at = 0;
while at < runs.len() {
let clip = match runs[at].1 {
@@ -1329,6 +1380,33 @@ mod tests {
]
);
}
#[test]
fn blur_boundary_splits_blend_runs_without_losing_or_repeating_vertices() {
let mut batch = HudBatch::new([854, 480]);
for _ in 0..5 {
batch.quad([[0.0; 2]; 4], [[0.0; 2]; 4], 0);
}
batch.blends = vec![
(6..18, Blend::Invert),
(18..24, Blend::Depth([0, 0, 10, 10])),
];
for split in [0, 6, 12, 18, 24, 30] {
let before = batch.runs_in(0..split);
let after = batch.runs_in(split..30);
let actual: Vec<_> = before
.into_iter()
.chain(after)
.flat_map(|(range, blend)| range.map(move |v| (v, blend)))
.collect();
let expected: Vec<_> = batch
.runs()
.into_iter()
.flat_map(|(range, blend)| range.map(move |v| (v, blend)))
.collect();
assert_eq!(actual, expected, "blur at {split}");
}
}
#[test]
fn container_origins_use_java_integer_division() {
// Odd scaled dimensions, odd image sizes and an oversized panel.
@@ -1340,6 +1418,7 @@ mod tests {
height: height as f32,
patches: Vec::new(),
blends: Vec::new(),
blur_at: 0,
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
assert_eq!(
+24 -1
View File
@@ -302,7 +302,10 @@ fn filterable_text(t: &Tag) -> Option<String> {
fn read_text(t: &Tag) -> Option<String> {
match t {
Tag::String(s) => Some(s.to_string_lossy()),
Tag::Compound(_) => t.get("text").and_then(string),
// A component's root `text` may be empty with the visible words in
// `extra` (every legacy display name ViaVersion produces looks like
// this), so flatten the whole component instead of reading one key.
Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()),
_ => None,
}
}
@@ -860,6 +863,26 @@ mod tests {
assert_eq!(back.custom_name(), Some("Blade"));
}
#[test]
fn custom_name_reads_the_extra_spans() {
let name = Tag::compound([
(
"extra",
Tag::List(vec![
Tag::compound([("text", Tag::String("Games ".into()))]),
Tag::compound([("text", Tag::String("Right click".into()))]),
]),
),
("text", Tag::String("".into())),
]);
let components = Tag::compound([("minecraft:custom_name", name)]);
let (_, extra) = decode_components(Some(&components));
assert_eq!(
extra.and_then(|d| d.custom_name.clone()).as_deref(),
Some("Games Right click")
);
}
#[test]
fn banner_layers_and_shield_base_round_trip() {
let mut stack = ItemStack::new("minecraft:shield", 1);
+77
View File
@@ -486,10 +486,28 @@ pub fn lines(
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let mut lines = vec![name_line(stack, translate)];
if TooltipDisplay::for_stack(stack).shows("minecraft:lore") {
lines.extend(lore_lines(stack));
}
lines.extend(detail_lines(stack, info, translate));
lines
}
/// `LoreComponent.addToTooltip`: the saved component lines, as plain text.
fn lore_lines(stack: &ItemStack) -> Vec<String> {
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
entries
.iter()
.map(|entry| {
let json = crate::scoreboard::tag_to_json(entry);
let text = crate::component::to_text(&json, &mut |s| crate::text::Text::literal(s));
text.plain()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -591,6 +609,65 @@ mod tests {
assert!(container_lines(&ItemStack::new("minecraft:chest", 1), &name, &lang).is_empty());
}
#[test]
fn fireworks_use_loaded_tooltip_translations() {
let root = std::env::temp_dir().join(format!(
"horizon-firework-tooltip-language-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let dir = root.join("assets/minecraft/lang");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("en_us.json"),
serde_json::to_vec(&serde_json::json!({
"item.minecraft.firework_rocket.flight_duration": "Flight Duration: %s",
"item.minecraft.firework_rocket.multiple_stars": "%s x %s",
"item.minecraft.firework_star.shape.small_ball": "Small Ball",
"item.minecraft.firework_star.red": "Red",
"item.minecraft.firework_star.custom_color": "Custom",
"item.minecraft.firework_star.fade_to": "Fade to",
"item.minecraft.firework_star.trail": "Trail",
"item.minecraft.firework_star.flicker": "Twinkle",
}))
.unwrap(),
)
.unwrap();
let enchantments = crate::enchant::Enchantments::load(&root);
std::fs::remove_dir_all(&root).unwrap();
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
let mut rocket = ItemStack::new("minecraft:firework_rocket", 1);
assert_eq!(firework_lines(&rocket, &translate), ["Flight Duration: 1"]);
let explosion = crate::particles::FireworkExplosion {
colors: vec![0xB3312C],
fade: vec![0x123456],
trail: true,
twinkle: true,
..Default::default()
};
rocket.data_mut().fireworks = Some((3, vec![explosion.clone(), explosion.clone()]));
assert_eq!(
firework_lines(&rocket, &translate),
[
"Flight Duration: 3",
"2 x Small Ball",
" Red",
" Fade to Custom",
" Trail",
" Twinkle",
]
);
let mut star = ItemStack::new("minecraft:firework_star", 1);
star.data_mut().firework_explosion = Some(explosion);
assert_eq!(
firework_lines(&star, &translate),
["Small Ball", "Red", "Fade to Custom", "Trail", "Twinkle"]
);
}
#[test]
fn amounts_use_two_decimals_at_most() {
assert_eq!(format_amount(8.0), "8");
@@ -234,6 +234,23 @@ impl Hub {
Some(window)
}
/// ServerPlayer.openHorseInventory: the open container closes, a new
/// window id opens mount `entity`'s inventory with its chest's columns
/// (ClientboundMountScreenOpenPacket).
pub fn open_mount_window(&mut self, conn: u32, columns: i32, entity: i32) -> Option<i32> {
let i = self.guests.iter().position(|g| g.conn == conn)?;
self.close_container_at(i);
let g = &mut self.guests[i];
g.next_window = g.next_window % 100 + 1;
let window = g.next_window;
g.send(&C::MountScreenOpen(play::MountScreenOpen {
container: window,
columns,
entity,
}));
Some(window)
}
fn send_menu_content(&mut self, i: usize, mut items: Vec<Option<ItemStack>>) {
for j in 0..36 {
items.push(self.guests[i].inventory[player_slot(j)].clone());
+10
View File
@@ -71,6 +71,13 @@ pub struct Guest {
/// The permission level (0 none, 2 game masters, 4 owners).
pub permission: u8,
pub sneaking: bool,
/// The last `ServerboundPlayerInputPacket` flags (forward 1, backward 2,
/// left 4, right 8, jump 16, shift 32, sprint 64).
pub input: u8,
/// The boat or minecart the player rides (`Entity.getVehicle`).
pub vehicle: Option<u32>,
/// `ServerGamePacketListenerImpl.lastGoodX/Y/Z` of the vehicle it controls.
pub vehicle_good: Option<[f64; 3]>,
pub sprinting: bool,
pub fall_flying: bool,
pub flying: bool,
@@ -233,6 +240,9 @@ impl Guest {
game_mode: SURVIVAL,
permission: 0,
sneaking: false,
input: 0,
vehicle: None,
vehicle_good: None,
sprinting: false,
fall_flying: false,
flying: false,
+108 -14
View File
@@ -242,6 +242,30 @@ fn tracked(cx: i32, cz: i32, r: i32, x: i32, z: i32) -> bool {
dx * dx + dz * dz < i64::from(r) * i64::from(r)
}
/// The view's queue around a new centre: the queued chunks still tracked, then
/// the tracked ones neither sent nor queued, closest first (a stable sort, so
/// equally close chunks keep their order).
fn requeue(
queue: &mut Vec<(i32, i32)>,
sent: &crate::fast_hash::FxHashSet<(i32, i32)>,
(cx, cz): (i32, i32),
r: i32,
) {
queue.retain(|&(x, z)| tracked(cx, cz, r, x, z));
// A walking player changes chunk every few ticks with hundreds of chunks
// queued: a set, not a scan of the queue for each of the view's chunks.
let mut queued: crate::fast_hash::FxHashSet<(i32, i32)> = queue.iter().copied().collect();
for dz in -r - 2..=r + 2 {
for dx in -r - 2..=r + 2 {
let key = (cx + dx, cz + dz);
if tracked(cx, cz, r, key.0, key.1) && !sent.contains(&key) && queued.insert(key) {
queue.push(key);
}
}
}
queue.sort_by_key(|&(x, z)| i64::from(x - cx).pow(2) + i64::from(z - cz).pow(2));
}
/// What a level's players were last told of its clock and weather.
#[derive(Clone, Copy)]
struct LevelSync {
@@ -1295,20 +1319,7 @@ impl Hub {
g.sent_chunks.remove(&(x, z));
g.send(&C::ForgetLevelChunk(play::ForgetLevelChunk { pos: [x, z] }));
}
g.chunk_queue.retain(|&(x, z)| tracked(cx, cz, r, x, z));
for dz in -r - 2..=r + 2 {
for dx in -r - 2..=r + 2 {
let key = (cx + dx, cz + dz);
if tracked(cx, cz, r, key.0, key.1)
&& !g.sent_chunks.contains(&key)
&& !g.chunk_queue.contains(&key)
{
g.chunk_queue.push(key);
}
}
}
g.chunk_queue
.sort_by_key(|&(x, z)| i64::from(x - cx).pow(2) + i64::from(z - cz).pow(2));
requeue(&mut g.chunk_queue, &g.sent_chunks, (cx, cz), r);
}
/// PlayerChunkSender.sendNextChunks' choice: ready chunks, closest first,
@@ -1957,9 +1968,14 @@ impl Hub {
g.center = None;
}
S::PlayerInput(p) => {
self.guests[i].input = p.flags;
self.guests[i].sneaking = p.flags & 32 != 0;
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
}
// OPEN_INVENTORY is the game's: the ridden mount's inventory.
S::PlayerCommand(p) if p.action == 5 => {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
}
S::PlayerCommand(p) => match p.action {
1 => self.guests[i].sprinting = true,
2 => self.guests[i].sprinting = false,
@@ -2039,6 +2055,11 @@ impl Hub {
to[2].clamp(-3.0E7, 3.0E7),
];
let (yaw, pitch) = (wrap_degrees(yaw), wrap_degrees(pitch));
// A passenger only turns: its vehicle carries it (absSnapTo where it is).
if g.vehicle.is_some() {
(g.yaw, g.pitch) = (yaw, pitch);
return;
}
let d = [0, 1, 2].map(|k| to[k] - g.first_good[k]);
let moved = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if g.sleeping {
@@ -2485,3 +2506,76 @@ fn ticking_state(rate: f32, frozen: bool) -> C {
w.bool(frozen);
raw(ids::TICKING_STATE, w.data)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
/// `requeue` as it was, scanning the queue for each chunk of the view.
fn requeue_scan(
queue: &mut Vec<(i32, i32)>,
sent: &FxHashSet<(i32, i32)>,
(cx, cz): (i32, i32),
r: i32,
) {
queue.retain(|&(x, z)| tracked(cx, cz, r, x, z));
for dz in -r - 2..=r + 2 {
for dx in -r - 2..=r + 2 {
let key = (cx + dx, cz + dz);
if tracked(cx, cz, r, key.0, key.1) && !sent.contains(&key) && !queue.contains(&key)
{
queue.push(key);
}
}
}
queue.sort_by_key(|&(x, z)| i64::from(x - cx).pow(2) + i64::from(z - cz).pow(2));
}
/// A player walking a loop, sending a few chunks a tick: the queue
/// after every move is the one the scan built.
#[test]
fn requeue_matches_the_scan() {
for r in [2, 5, 10, 12, 32] {
let (mut a, mut b) = (Vec::new(), Vec::new());
let mut sent = FxHashSet::default();
for step in 0..60i32 {
let center = ((step % 17) - 8, (step * 3 % 11) - 5);
requeue(&mut a, &sent, center, r);
requeue_scan(&mut b, &sent, center, r);
assert_eq!(a, b, "radius {r}, step {step}");
for key in a.drain(..a.len().min(7)) {
sent.insert(key);
}
b.drain(..b.len().min(7));
sent.retain(|&(x, z)| tracked(center.0, center.1, r, x, z));
}
}
}
/// cargo test --release -p horizon-client --lib lan::hub::tests::requeue_cost -- --ignored --nocapture
#[test]
#[ignore]
fn requeue_cost() {
for r in [10, 16, 32] {
for (name, f) in [
("scan", requeue_scan as fn(&mut _, &_, _, _)),
("set", requeue as fn(&mut _, &_, _, _)),
] {
let sent = FxHashSet::default();
let mut queue = Vec::new();
let start = std::time::Instant::now();
let moves = 200;
for step in 0..moves {
// Nothing sent yet (an explorer waiting on generation).
f(&mut queue, &sent, (step, 0), r);
}
let per = start.elapsed().as_secs_f64() * 1e6 / f64::from(moves);
println!(
"view {r}: {name} {per:.1} us per chunk crossed, {} queued",
queue.len()
);
}
}
}
}
+2 -3
View File
@@ -69,7 +69,7 @@ pub mod first_person;
pub mod flatgen;
pub mod fog;
pub mod font;
pub mod front;
pub mod friends_screen;
pub mod game_mode_switcher;
pub mod gamepad;
pub mod gamerules;
@@ -127,12 +127,12 @@ pub mod nether_portal;
pub mod offline_name;
pub mod options_file;
pub mod options_fixes;
pub mod options_horizon;
pub mod options_ui;
pub mod osk;
pub mod overlay;
pub mod pack_screen;
pub mod packs;
pub mod par;
pub mod particles;
pub mod patrol;
pub mod perf_overlay;
@@ -141,7 +141,6 @@ pub mod player_sync;
pub mod poi_sections;
pub mod portal_sections;
pub mod pose;
pub mod post;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
+136 -2
View File
@@ -114,6 +114,10 @@ pub enum Kind {
Lectern,
/// `MerchantMenu`: two payment slots, the result, then the inventory.
Merchant,
/// `HorseInventoryMenu`: the saddle and body armour slots (`ArmorSlot`s
/// of the mount, whose entity type is `Menu::mount`), a chest of three
/// rows of this many columns (`getInventoryColumns`), then the inventory.
Mount(usize),
/// `CreativeModeInventoryScreen.ItemPickerMenu`: an item tab's 45
/// display slots, then the hotbar.
ItemPicker,
@@ -124,6 +128,9 @@ impl Kind {
if let Some(rows) = name.strip_prefix("chest") {
return rows.parse().ok().map(Self::Chest);
}
if let Some(columns) = name.strip_prefix("mount") {
return columns.parse().ok().map(Self::Mount);
}
Some(match name {
"inventory" => Self::Inventory,
"crafting" => Self::Crafting,
@@ -207,6 +214,9 @@ enum Role {
/// `CustomCreativeSlot`: a creative tab's display slot, which the
/// screen clicks itself; drags and PICKUP_ALL pass it by.
CreativeDisplay,
/// An `ArmorSlot` of a mount for this `EquipmentSlot` ("saddle" or
/// "body"): `isEquippableInSlot` for `Menu::mount`, one item.
MountEquip(&'static str),
}
#[derive(Clone, Copy)]
@@ -298,6 +308,9 @@ pub struct Menu {
pub enchant_button: Option<usize>,
/// `Player.mayBuild` (not adventure mode): the lectern's Take Book.
pub may_build: bool,
/// A mount menu's entity type (`minecraft:horse`): what its saddle and
/// body slots take.
pub mount: Option<String>,
}
/// `AnvilMenu`'s state besides its slots and `cost`.
@@ -560,6 +573,22 @@ impl Menu {
// The open page (LecternBlockEntity.dataAccess).
data = 1;
}
Kind::Mount(columns) => {
containers[MAIN] = vec![None; 2 + columns * 3];
for (index, slot) in ["saddle", "body"].into_iter().enumerate() {
slots.push(Slot {
container: MAIN,
index,
role: Role::MountEquip(slot),
});
}
slots.extend((2..2 + columns * 3).map(|index| Slot {
container: MAIN,
index,
role: Role::Plain,
}));
player(&mut slots);
}
Kind::ItemPicker => {
containers[MAIN] = vec![None; 45];
slots.extend(main(45, Role::CreativeDisplay));
@@ -654,6 +683,7 @@ impl Menu {
enchant_changed: false,
enchant_button: None,
may_build: true,
mount: None,
}
.with_initial_data()
}
@@ -1186,6 +1216,15 @@ impl Menu {
Some(slot == eq && allowed)
})
.unwrap_or(false),
// LivingEntity.isEquippableInSlot: the item's equippable slot,
// and the mount among its allowed entities.
Role::MountEquip(slot) => env.items.get(&stack.item).is_some_and(|i| {
i.equip_slot.as_deref() == Some(slot)
&& self
.mount
.as_deref()
.is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m)))
}),
Role::Shulker => Self::fits_inside(stack),
Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack),
Role::Output | Role::CraftResult | Role::Inert => false,
@@ -1257,7 +1296,7 @@ impl Menu {
);
o.satisfied_by(env.items, a, b) || o.satisfied_by(env.items, b, a)
}),
Role::Armor(_) => match self.slot(s) {
Role::Armor(_) | Role::MountEquip(_) => match self.slot(s) {
Some(stack) => {
self.creative || stack.enchantment_level("minecraft:binding_curse") == 0
}
@@ -1270,7 +1309,11 @@ impl Menu {
/// `Slot.getMaxStackSize(stack)`.
fn slot_max(&self, env: &Env, s: usize, stack: &ItemStack) -> u32 {
let own = match self.slots[s].role {
Role::Armor(_) | Role::Potion | Role::EnchantItem | Role::BeaconPay => 1,
Role::Armor(_)
| Role::MountEquip(_)
| Role::Potion
| Role::EnchantItem
| Role::BeaconPay => 1,
// The lectern's bookAccess holds one.
Role::Plain if self.kind == Kind::Lectern && self.slots[s].container == MAIN => 1,
Role::Fuel if Self::is_bucket(stack) => return 1,
@@ -2026,6 +2069,38 @@ impl Menu {
}
// LecternMenu.quickMoveStack moves nothing.
Kind::Lectern => return None,
Kind::Mount(columns) => {
// AbstractMountInventoryMenu.quickMoveStack: no count check
// and no onTake.
check_count = false;
let start = 2 + columns * 3;
if index < start {
if !self.move_to(env, &mut stack, start, total, true) {
return None;
}
} else if self.may_place(env, 1, &stack) && self.containers[MAIN][1].is_none() {
if !self.move_to(env, &mut stack, 1, 2, false) {
return None;
}
} else if self.may_place(env, 0, &stack) && self.containers[MAIN][0].is_none() {
if !self.move_to(env, &mut stack, 0, 1, false) {
return None;
}
} else if columns == 0 || !self.move_to(env, &mut stack, 2, start, false) {
// Between the inventory and the hotbar: the stack shrinks
// in its slot and EMPTY is returned either way.
let (main_end, hotbar_end) = (start + 27, start + 36);
if (main_end..hotbar_end).contains(&index) {
self.move_to(env, &mut stack, start, main_end, false);
} else if (start..main_end).contains(&index) {
self.move_to(env, &mut stack, main_end, hotbar_end, false);
}
let slot = self.slots[index];
self.containers[slot.container][slot.index] =
(stack.count > 0).then(|| stack.clone());
return None;
}
}
Kind::Beacon => {
// BeaconMenu.quickMoveStack.
after_take = true;
@@ -2946,6 +3021,65 @@ pub(crate) mod tests {
test(&env, &mut menu);
}
/// `HorseInventoryMenu`: a shift-click wears horse armour, then saddles,
/// then fills the chest; the body slot takes only what the mount may wear.
#[test]
fn mount_menu_slots_take_what_the_mount_wears() {
with_menu(Kind::Mount(5), |env, menu| {
menu.creative = false;
menu.mount = Some("minecraft:donkey".into());
// 2 saddle and body, 15 chest, then the inventory from 17.
assert_eq!(menu.slot_count(), 2 + 15 + 36);
let hotbar = 17 + 27;
menu.set_slot(env, hotbar, stack("iron_horse_armor", 1));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
// A donkey wears no horse armour: it goes in the chest.
assert_eq!(menu.slot(1), None);
assert_eq!(
menu.slot(2).map(|s| &*s.item),
Some("minecraft:iron_horse_armor")
);
menu.set_slot(env, hotbar, stack("saddle", 1));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
assert_eq!(menu.slot(0).map(|s| &*s.item), Some("minecraft:saddle"));
// Back out of the mount: to the inventory, from the hotbar's end.
menu.click(env, 0, 0, Input::QuickMove).unwrap();
assert_eq!(menu.slot(0), None);
assert_eq!(
menu.slot(17 + 35).map(|s| &*s.item),
Some("minecraft:saddle")
);
});
with_menu(Kind::Mount(0), |env, menu| {
menu.creative = false;
menu.mount = Some("minecraft:horse".into());
let hotbar = 2 + 27;
menu.set_slot(env, hotbar, stack("diamond_horse_armor", 1));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
assert_eq!(
menu.slot(1).map(|s| &*s.item),
Some("minecraft:diamond_horse_armor")
);
// No chest: dirt goes between the hotbar and the inventory.
menu.set_slot(env, hotbar, stack("dirt", 3));
menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap();
assert_eq!(
menu.slot(2).map(|s| (&*s.item, s.count)),
Some(("minecraft:dirt", 3))
);
assert_eq!(menu.slot(hotbar), None);
// Placing by hand: a saddle doesn't go in the body slot.
menu.carried = stack("saddle", 1);
menu.click(env, 1, 0, Input::Pickup).unwrap();
assert_eq!(
menu.slot(1).map(|s| &*s.item),
Some("minecraft:diamond_horse_armor")
);
menu.click(env, 0, 0, Input::Pickup).unwrap();
assert_eq!(menu.slot(0).map(|s| &*s.item), Some("minecraft:saddle"));
});
}
/// `onEquipItem` runs from the armour and off-hand slots' `setByPlayer`
/// when the item changes: never from `Inventory.setItem` (a swap's source).
#[test]
+574 -23
View File
@@ -807,7 +807,11 @@ pub struct Mob {
pub persistent: bool,
/// `CustomName` (plain text) and `CustomNameVisible`.
pub custom_name: Option<String>,
/// The styled `CustomName`, when it came from a component.
pub custom_name_text: Option<crate::text::Text>,
pub name_visible: bool,
/// `Entity.DATA_SHARED_FLAGS_ID` bit 0x20: the model is hidden.
pub invisible: bool,
pub random: LegacyRandom,
pub ai: Box<ai::Ai>,
pub extra: Extra,
@@ -846,6 +850,9 @@ pub struct Mob {
/// from the game), handed over with the next tick's events or drained
/// by the game.
pub outbox: Vec<MobEvent>,
/// The skin texture of a player mob (`PlayerInfo.getSkin`), which
/// replaces the type's default when its download is ready.
pub skin: Option<String>,
}
/// Events a mob tick asks the game to act on.
#[derive(Clone, Debug)]
@@ -1310,31 +1317,67 @@ impl Mob {
/// `Entity.push` on other mobs acts at once in vanilla: a mob ticking later
/// moves with it this tick. Takes the `Push` events aimed at mobs out of
/// `events[from..]` into `pending`, which the caller adds to each mob's
/// velocity before its tick (and to every mob once all have ticked).
pub fn take_mob_pushes(events: &mut Vec<MobEvent>, from: usize, pending: &mut Vec<(u32, Vec3)>) {
let mut i = from;
while i < events.len() {
/// velocity before its tick (and to every mob once all have ticked). The
/// other events keep their order.
pub fn take_mob_pushes(events: &mut Vec<MobEvent>, from: usize, pending: &mut PendingPushes) {
// One pass: a crowd's tick holds a push per neighbour, and removing each
// in place would shift the rest every time.
let mut kept = from;
for i in from..events.len() {
if let MobEvent::Push {
target: Who::Mob(id),
velocity,
} = events[i]
{
pending.push((id, Vec3::new(velocity[0], velocity[1], velocity[2])));
events.remove(i);
pending.push(id, Vec3::new(velocity[0], velocity[1], velocity[2]));
} else {
i += 1;
events.swap(kept, i);
kept += 1;
}
}
events.truncate(kept);
}
/// Pushes waiting for their mob's tick, in push order. A list while short;
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
/// per mob, as scanning the whole list for each mob grows with its square.
#[derive(Default)]
pub struct PendingPushes {
list: Vec<(u32, Vec3)>,
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
}
impl PendingPushes {
const LIST_MAX: usize = 64;
pub fn push(&mut self, id: u32, v: Vec3) {
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
self.list.push((id, v));
return;
}
// Older pushes first, so each mob's stay in order.
for (id, v) in self.list.drain(..) {
self.by_mob.entry(id).or_default().push(v);
}
self.by_mob.entry(id).or_default().push(v);
}
}
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut Vec<(u32, Vec3)>) {
pending.retain(|(id, v)| {
if *id != m.id {
return true;
}
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
let mut add = |v: Vec3| {
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
false
});
};
if !pending.list.is_empty() {
pending.list.retain(|(id, v)| {
if *id != m.id {
return true;
}
add(*v);
false
});
}
if !pending.by_mob.is_empty()
&& let Some(pushes) = pending.by_mob.remove(&m.id)
{
pushes.into_iter().for_each(add);
}
}
/// Applies the brain memories a mob's tick set on other mobs (from event
/// index `from` on), at once: vanilla sets them on the other entity during
@@ -1406,6 +1449,7 @@ pub struct PlayerView {
/// (`EntitySelector.pushableBy` on the client).
pub remote: bool,
}
#[derive(Clone)]
pub struct MobWorld<'a> {
pub world: &'a World,
pub registry: &'a horizon_world::registry::BlockRegistry,
@@ -1420,6 +1464,8 @@ pub struct MobWorld<'a> {
pub others: &'a [Seen],
/// `others` by position, when the caller keeps one (the mob ticks).
pub others_grid: Option<&'a seen_grid::SeenGrid>,
/// Where each id stands in `others`, when the caller keeps one.
pub others_index: Option<&'a crate::fast_hash::FxHashMap<u32, u32>>,
pub daylight: bool,
pub game_time: i64,
pub day_time: i64,
@@ -1438,22 +1484,23 @@ pub struct MobWorld<'a> {
pub world_seed: i64,
/// `Level.getSeaLevel` (63 in the overworld, -63 in a flat world).
pub sea_level: i32,
/// The scoreboard teams of the player and the mobs (collision rules).
pub teams: Teams,
/// The scoreboard teams of the player and the mobs (collision rules),
/// shared: the mob ticks build them once a tick, not once a mob.
pub teams: std::sync::Arc<Teams<'a>>,
/// Item data (max stack sizes), when the host has it.
pub items: Option<&'a crate::items::Items>,
}
/// `Entity.getTeam` of the player and of the mobs on a team.
#[derive(Clone, Debug, Default)]
pub struct Teams {
pub player: Option<crate::scoreboard::Team>,
pub mobs: HashMap<u32, crate::scoreboard::Team>,
pub struct Teams<'a> {
pub player: Option<&'a crate::scoreboard::Team>,
pub mobs: HashMap<u32, &'a crate::scoreboard::Team>,
}
impl Teams {
pub fn of(&self, who: Who) -> Option<&crate::scoreboard::Team> {
impl<'a> Teams<'a> {
pub fn of(&self, who: Who) -> Option<&'a crate::scoreboard::Team> {
match who {
Who::Player => self.player.as_ref(),
Who::Mob(id) => self.mobs.get(&id),
Who::Player => self.player,
Who::Mob(id) => self.mobs.get(&id).copied(),
}
}
}
@@ -1515,10 +1562,13 @@ impl Mob {
last_damage: None,
removed: false,
custom_name: None,
custom_name_text: None,
name_visible: false,
invisible: false,
anim: anim::AnimState::default(),
rider: None,
outbox: Vec::new(),
skin: None,
attrs: Attrs {
speed: kind.speed,
attack_damage: kind.attack_damage,
@@ -2139,3 +2189,504 @@ mod elytra_tests {
assert!((wings.angles[1] - 0.08726646).abs() < 0.00001);
}
}
#[cfg(test)]
mod teams_tests {
use super::*;
use crate::save::uuid_text;
use crate::scoreboard::{Scoreboard, Team};
/// A board where every other mob is on the player's team, as with a
/// datapack that colours its mobs' names.
fn board(mobs: u32) -> (Scoreboard, HashMap<u32, [i32; 4]>) {
let mut board = Scoreboard::default();
board.add_team("red");
board.add_to_team("Steve", "red");
let uuids: HashMap<u32, [i32; 4]> = (0..mobs)
.map(|id| (id, [id as i32, 7, -(id as i32), 11]))
.collect();
for (id, uuid) in &uuids {
if id % 2 == 0 {
board.add_to_team(&uuid_text(*uuid), "red");
}
}
(board, uuids)
}
/// `Game::mob_teams` as built for each mob before: owned clones of the teams.
fn owned(board: &Scoreboard, uuids: &HashMap<u32, [i32; 4]>) -> HashMap<u32, Team> {
uuids
.iter()
.filter_map(|(id, u)| Some((*id, board.players_team(&uuid_text(*u))?.clone())))
.collect()
}
fn borrowed<'a>(board: &'a Scoreboard, uuids: &HashMap<u32, [i32; 4]>) -> Teams<'a> {
Teams {
player: board.players_team("Steve"),
mobs: uuids
.iter()
.filter_map(|(id, u)| Some((*id, board.players_team(&uuid_text(*u))?)))
.collect(),
}
}
#[test]
fn borrowed_teams_answer_as_the_clones() {
let (board, uuids) = board(40);
let teams = borrowed(&board, &uuids);
let old = owned(&board, &uuids);
for id in 0..41 {
assert_eq!(teams.of(Who::Mob(id)), old.get(&id), "mob {id}");
}
assert_eq!(teams.of(Who::Player).map(|t| t.name.as_str()), Some("red"));
}
/// One tick's worth of team building: once per mob (before) against once.
#[test]
#[ignore]
fn teams_cost() {
for mobs in [100u32, 250, 500] {
let (board, uuids) = board(mobs);
let t = std::time::Instant::now();
for _ in 0..mobs {
std::hint::black_box(owned(&board, &uuids));
}
let before = t.elapsed();
let t = std::time::Instant::now();
let reps = 20;
for _ in 0..reps {
std::hint::black_box(borrowed(&board, &uuids));
}
let after = t.elapsed() / reps;
println!(
"{mobs} mobs, half on a team: per tick {:.3} ms -> {:.3} ms",
before.as_secs_f64() * 1e3,
after.as_secs_f64() * 1e3
);
}
}
}
#[cfg(test)]
mod sensor_scan_tests {
use super::*;
use horizon_core::geometry::Vec3;
use serde_json::json;
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:cow".into(),
species: Species::Cow,
width: 0.9,
height: 1.4,
eye_height: 1.3,
category: "creature".into(),
max_health: 10.0,
speed: 0.2,
flying_speed: 0.4,
attack_damage: 0.0,
attack_knockback: 0.0,
follow_range: 16.0,
step_height: 0.6,
armor: 0.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags {
animal: true,
..Default::default()
},
model: Arc::new(Part::parse("root", &json!({}))),
layers: HashMap::new(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Rabbit,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
/// `n` mobs in pens of 20 spread over a square of `side` blocks, as on a
/// server's farms; their snapshot and its grid.
fn herd(n: u32, side: f64) -> (Vec<Seen>, seen_grid::SeenGrid) {
let kind = kind();
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
let mut pen = Vec3::ZERO;
let seen: Vec<Seen> = (0..n)
.map(|id| {
if id % 20 == 0 {
pen = Vec3::new(next(side), 64.0, next(side));
}
let at = Vec3::new(pen.x + next(8.0), 64.0, pen.z + next(8.0));
Mob::new(id, kind.clone(), at, 0.0).seen(None)
})
.collect();
let grid = seen_grid::SeenGrid::new(seen.iter().map(|s| s.bounds));
(seen, grid)
}
/// `NearestLivingEntitySensor` over every mob, as the brains ran it.
fn scan(seen: &[Seen], me: &Seen, area: Aabb) -> Vec<u32> {
seen.iter()
.filter(|o| o.id != me.id && o.alive && o.bounds.intersects(area))
.map(|o| o.id)
.collect()
}
fn indexed(seen: &[Seen], grid: &seen_grid::SeenGrid, me: &Seen, area: Aabb) -> Vec<u32> {
let mut near = Vec::new();
assert!(grid.candidates(area, &mut near));
near.iter()
.map(|&i| &seen[i as usize])
.filter(|o| o.id != me.id && o.alive && o.bounds.intersects(area))
.map(|o| o.id)
.collect()
}
#[test]
fn the_grid_finds_what_the_scan_finds() {
let (seen, grid) = herd(600, 160.0);
for me in &seen {
let area = me.bounds.inflate(Vec3::new(16.0, 16.0, 16.0));
assert_eq!(indexed(&seen, &grid, me, area), scan(&seen, me, area));
}
}
/// `MobWorld::seen` once per mob (a target, a breeding partner...), by
/// scan as before and by the tick's id index.
#[test]
#[ignore]
fn lookup_cost() {
for n in [500u32, 1000, 2000] {
let (seen, _) = herd(n, 256.0);
let index: crate::fast_hash::FxHashMap<u32, u32> = seen
.iter()
.enumerate()
.map(|(i, s)| (s.id, i as u32))
.collect();
let wanted: Vec<u32> = (0..n).map(|i| (i * 7919) % n).collect();
let t = std::time::Instant::now();
let a: usize = wanted
.iter()
.filter_map(|&id| seen.iter().find(|s| s.id == id))
.map(|s| s.id as usize)
.sum();
let before = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let b: usize = wanted
.iter()
.filter_map(|&id| index.get(&id).map(|&i| &seen[i as usize]))
.map(|s| s.id as usize)
.sum();
let after = t.elapsed().as_secs_f64() * 1e3;
assert_eq!(a, b);
println!(
"{n} lookups over {n} mobs ({} B each): {before:.3} ms -> {after:.3} ms",
std::mem::size_of::<Seen>()
);
}
}
/// Natural spawning's `isUnobstructed` box test against every mob (real
/// `Mob`s), by scan and by a grid built once for the pass.
#[test]
#[ignore]
fn spawn_box_cost() {
let kind = kind();
for n in [500u32, 1000, 2000] {
let (seen, _) = herd(n, 384.0);
let mobs: Vec<Mob> = seen
.iter()
.map(|s| Mob::new(s.id, kind.clone(), s.position, 0.0))
.collect();
let mut seed = 7u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
let queries: Vec<Aabb> = (0..100)
.map(|_| {
let (x, z) = (next(384.0), next(384.0));
Aabb::new(Vec3::new(x, 64.0, z), Vec3::new(x + 0.9, 65.4, z + 0.9))
})
.collect();
let t = std::time::Instant::now();
let a: Vec<bool> = queries
.iter()
.map(|&q| {
mobs.iter()
.any(|m| m.alive() && !m.removed && m.bounds().intersects(q))
})
.collect();
let before = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let boxes: Vec<Aabb> = mobs.iter().map(|m| m.bounds()).collect();
let grid = seen_grid::SeenGrid::new(boxes.iter().copied());
let build = t.elapsed().as_secs_f64() * 1e3;
let mut near = Vec::new();
let b: Vec<bool> = queries
.iter()
.map(|&q| {
assert!(grid.candidates(q, &mut near));
near.iter().any(|&i| boxes[i as usize].intersects(q))
})
.collect();
let after = t.elapsed().as_secs_f64() * 1e3;
assert_eq!(a, b);
println!(
"{n} mobs, 100 spawn box tests: {before:.3} ms -> {after:.3} ms (grid build {build:.3} ms included)"
);
}
}
/// One tick of sensing: a twentieth of the mobs fire their sensor.
#[test]
#[ignore]
fn sensor_cost() {
for (n, side) in [(500u32, 256.0), (1000, 384.0), (2000, 512.0)] {
let (seen, grid) = herd(n, side);
let time = |f: &dyn Fn(&Seen, Aabb) -> usize| {
let t = std::time::Instant::now();
let mut found = 0;
for me in &seen {
found += f(me, me.bounds.inflate(Vec3::new(16.0, 16.0, 16.0)));
}
(t.elapsed().as_secs_f64() * 1e3 / 20.0, found)
};
let (before, a) = time(&|me, area| scan(&seen, me, area).len());
let (after, b) = time(&|me, area| indexed(&seen, &grid, me, area).len());
assert_eq!(a, b);
println!("{n} mobs: per tick {before:.3} ms -> {after:.3} ms");
}
}
}
#[cfg(test)]
mod push_tests {
use super::*;
use horizon_core::geometry::Vec3;
use serde_json::json;
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:cow".into(),
species: Species::Cow,
width: 0.9,
height: 1.4,
eye_height: 1.3,
category: "creature".into(),
max_health: 10.0,
speed: 0.2,
flying_speed: 0.4,
attack_damage: 0.0,
attack_knockback: 0.0,
follow_range: 16.0,
step_height: 0.6,
armor: 0.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags {
animal: true,
..Default::default()
},
model: Arc::new(Part::parse("root", &json!({}))),
layers: HashMap::new(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Rabbit,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
/// The pending pushes as one list, scanned for each mob, as before.
fn take_old(events: &mut Vec<MobEvent>, from: usize, pending: &mut Vec<(u32, Vec3)>) {
let mut i = from;
while i < events.len() {
if let MobEvent::Push {
target: Who::Mob(id),
velocity,
} = events[i]
{
pending.push((id, Vec3::new(velocity[0], velocity[1], velocity[2])));
events.remove(i);
} else {
i += 1;
}
}
}
fn apply_old(velocity: &mut Vec3, id: u32, pending: &mut Vec<(u32, Vec3)>) {
pending.retain(|(to, v)| {
if *to != id {
return true;
}
*velocity = Vec3::new(velocity.x + v.x, velocity.y + v.y, velocity.z + v.z);
false
});
}
fn mobs(n: u32) -> Vec<Mob> {
let kind = kind();
(0..n)
.map(|id| Mob::new(id, kind.clone(), Vec3::ZERO, 0.0))
.collect()
}
/// A crowd tick: each mob takes its pushes, then pushes every other
/// mob (and the player, and hisses now and then); then all take theirs.
fn crowd_tick(
n: u32,
tick: u32,
mut on_push: impl FnMut(&mut Vec<MobEvent>, usize),
mut on_apply: impl FnMut(u32),
) -> Vec<MobEvent> {
let mut events = Vec::new();
for i in 0..n {
on_apply(i);
let from = events.len();
for j in 0..n {
if j != i && !(i * 31 + j * 17 + tick).is_multiple_of(5) {
let f = f64::from(i * 7 + j + tick) * 0.001;
events.push(MobEvent::Push {
target: Who::Mob(j),
velocity: [f, -f * 0.5, f * 0.25],
});
}
if j.is_multiple_of(9) {
events.push(MobEvent::Hiss { mob: j });
}
}
events.push(MobEvent::Push {
target: Who::Player,
velocity: [0.1, 0.0, 0.1],
});
on_push(&mut events, from);
}
for i in 0..n {
on_apply(i);
}
events
}
#[test]
fn pushes_land_as_with_the_list() {
for n in [1u32, 3, 8, 20, 60] {
let mut new_mobs = mobs(n);
let mut old_v = vec![Vec3::ZERO; n as usize];
let mut pending = PendingPushes::default();
let mut old_pending = Vec::new();
for tick in 0..4 {
let new_events = {
let pending = std::cell::RefCell::new(&mut pending);
let mobs = std::cell::RefCell::new(&mut new_mobs);
crowd_tick(
n,
tick,
|e, from| take_mob_pushes(e, from, &mut pending.borrow_mut()),
|i| {
apply_mob_pushes(
&mut mobs.borrow_mut()[i as usize],
&mut pending.borrow_mut(),
)
},
)
};
let old_events = {
let pending = std::cell::RefCell::new(&mut old_pending);
let v = std::cell::RefCell::new(&mut old_v);
crowd_tick(
n,
tick,
|e, from| take_old(e, from, &mut pending.borrow_mut()),
|i| {
apply_old(
&mut v.borrow_mut()[i as usize],
i,
&mut pending.borrow_mut(),
)
},
)
};
assert_eq!(format!("{new_events:?}"), format!("{old_events:?}"));
for i in 0..n as usize {
let (a, b) = (new_mobs[i].velocity, old_v[i]);
assert_eq!(
(a.x.to_bits(), a.y.to_bits(), a.z.to_bits()),
(b.x.to_bits(), b.y.to_bits(), b.z.to_bits()),
"{n} mobs, tick {tick}, mob {i}"
);
}
}
}
}
/// One tick of a pen of `n` mobs pushing each other.
#[test]
#[ignore]
fn push_cost() {
for n in [10u32, 30, 100, 200] {
let mut ms = mobs(n);
let mut v = vec![Vec3::ZERO; n as usize];
let t = std::time::Instant::now();
{
let pending = std::cell::RefCell::new(Vec::new());
let v = std::cell::RefCell::new(&mut v);
crowd_tick(
n,
0,
|e, from| take_old(e, from, &mut pending.borrow_mut()),
|i| {
apply_old(
&mut v.borrow_mut()[i as usize],
i,
&mut pending.borrow_mut(),
)
},
);
}
let before = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
{
let pending = std::cell::RefCell::new(PendingPushes::default());
let ms = std::cell::RefCell::new(&mut ms);
crowd_tick(
n,
0,
|e, from| take_mob_pushes(e, from, &mut pending.borrow_mut()),
|i| {
apply_mob_pushes(
&mut ms.borrow_mut()[i as usize],
&mut pending.borrow_mut(),
)
},
);
}
let after = t.elapsed().as_secs_f64() * 1e3;
println!("{n} mobs pushing each other: {before:.3} ms -> {after:.3} ms");
}
}
}
@@ -606,7 +606,7 @@ fn sense_living(m: &mut Mob, w: &MobWorld) {
let r = m.attrs.follow_range;
let area = m.bounds().inflate(Vec3::new(r, r, r));
let mut found: Vec<(Who, f64)> = Vec::new();
for o in w.others {
for o in w.others_near(area) {
if o.id != m.id && o.alive && o.bounds.intersects(area) {
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
}
+1 -1
View File
@@ -441,7 +441,7 @@ impl Goal for PufferfishPuff {
.as_ref()
.is_some_and(|p| p.alive && !p.creative && area.intersects(p.bounds));
// TARGETING_CONDITIONS: not other fish (not_scary_for_pufferfish) and alive.
let mob = cx.w.others.iter().any(|s| {
let mob = cx.w.others_near(area).any(|s| {
s.id != m.id
&& s.alive
&& !matches!(
+5 -6
View File
@@ -391,12 +391,11 @@ impl Goal for LeaveBlock {
let target = cx.w.world.block(x, y, z);
let below = cx.w.world.block(x, y - 1, z);
let bf = cx.w.facts.get(below);
let free = !cx.w.others.iter().any(|s| {
s.bounds.intersects(Aabb::new(
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
Vec3::new(f64::from(x + 1), f64::from(y + 1), f64::from(z + 1)),
))
});
let block = Aabb::new(
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
Vec3::new(f64::from(x + 1), f64::from(y + 1), f64::from(z + 1)),
);
let free = !cx.w.others_near(block).any(|s| s.bounds.intersects(block));
if cx.w.facts.is_air(target)
&& !cx.w.facts.is_air(below)
&& &*bf.name != "minecraft:bedrock"
+14 -8
View File
@@ -338,15 +338,21 @@ pub fn ridden_step(m: &mut Mob) {
// Pig/Strider: always forward along the rider's look, at a fraction
// of the movement speed, boosted by the stick.
m.head_pitch = r.pitch * 0.5;
m.ai.xxa = 0.0;
m.ai.zza = 1.0;
// Strider.getRiddenSpeed: slower while it shivers out of the warmth.
let factor = if m.species() == Species::Pig {
0.225
} else if super::strider::suffocating(m) {
0.35
} else {
0.55
};
let boost = boost_factor(m);
m.set_speed((m.attrs.speed * factor) as f32 * boost);
// travelRidden: setSpeed(getRiddenSpeed), then travel(getRiddenInput);
// the input is set after the speed (Mob.setSpeed also sets zza).
// getAttributeValue(MOVEMENT_SPEED): with the cold strider's modifier.
m.set_speed((m.movement_speed() * factor) as f32 * boost);
m.ai.xxa = 0.0;
m.ai.zza = 1.0;
return;
}
if super::camel::is_camel(m.species()) {
@@ -366,14 +372,14 @@ pub fn ridden_step(m: &mut Mob) {
} else {
r.forward
};
m.ai.xxa = r.strafe * 0.5;
m.ai.zza = forward;
let bonus = if r.sprint && m.extra.anim_ticks == 0 {
0.1
} else {
0.0
};
m.set_speed(m.attrs.speed as f32 + bonus);
m.set_speed(m.movement_speed() as f32 + bonus);
m.ai.xxa = r.strafe * 0.5;
m.ai.zza = forward;
if r.jump > 0.0 {
let mut ev = Vec::new();
super::camel::dash(m, r.jump, &mut ev);
@@ -394,10 +400,10 @@ pub fn ridden_step(m: &mut Mob) {
} else {
r.forward
};
let speed = m.movement_speed() as f32;
m.set_speed(speed);
m.ai.xxa = r.strafe * 0.5;
m.ai.zza = forward;
let speed = m.attrs.speed as f32;
m.set_speed(speed);
if m.on_ground && r.jump > 0.0 {
// executeRidersJump
let impulse = m.extra.vector[0] as f32 * r.jump;
+12 -4
View File
@@ -1,7 +1,11 @@
//! Vanilla's `GoalSelector` (priorities, MOVE/LOOK/JUMP/TARGET control flags,
//! interruption) and the shared goals of `net.minecraft.world.entity.ai.goal`.
use super::{Mob, MobEvent, MobWorld, Seen, Species, Who, world::TargetInfo};
use horizon_core::{geometry::Vec3, jmath, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
jmath,
random::RandomSource,
};
use std::sync::Arc;
pub const MOVE: u8 = 1;
@@ -970,8 +974,7 @@ impl Goal for LookAtPlayer {
let (id, only) = (m.id, self.only);
let at = Vec3::new(m.position.x, m.eye_y(), m.position.z);
let candidates: Vec<(u32, Vec3)> =
cx.w.others
.iter()
cx.w.others_near(area)
.filter(|s| s.id != id && s.alive && s.bounds.intersects(area))
.filter(|s| only.is_none_or(|o| s.species == o))
.map(|s| (s.id, s.position))
@@ -1902,7 +1905,12 @@ impl Goal for FollowParent {
let species = m.species();
let pos = m.position;
let mut best: Option<(u32, f64)> = None;
for s in cx.w.others.iter().filter(|s| {
// Every position in range puts the box's bottom face in this area.
let area = Aabb::new(
Vec3::new(pos.x - 9.0, pos.y - 5.0, pos.z - 9.0),
Vec3::new(pos.x + 9.0, pos.y + 5.0, pos.z + 9.0),
);
for s in cx.w.others_near(area).filter(|s| {
s.alive
&& s.species == species
&& !s.baby
+4 -1
View File
@@ -68,7 +68,10 @@ impl Goal for DefendVillage {
if !b.intersects(p.bounds) || m.position.distance_squared(p.position) > 64.0 * 64.0 {
return false;
}
let bad = cx.w.others.iter().any(|s| {
// A position inside `b` puts the box's bottom face there (one block
// wider, as the box test is strict).
let near = b.inflate(Vec3::new(1.0, 1.0, 1.0));
let bad = cx.w.others_near(near).any(|s| {
s.species == Species::Villager
&& s.alive
&& s.player_rep <= -100
@@ -299,7 +299,7 @@ fn sense_living(m: &mut Mob, w: &MobWorld) {
let r = m.attrs.follow_range;
let area = m.bounds().inflate(Vec3::new(r, r, r));
let mut found: Vec<(Who, f64)> = Vec::new();
for o in w.others {
for o in w.others_near(area) {
if o.id != m.id && o.alive && o.bounds.intersects(area) {
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
}
+1 -1
View File
@@ -177,7 +177,7 @@ fn roar(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
velocity: fling(p.position),
});
}
for s in w.others.iter() {
for s in w.others_near(area) {
if s.id == m.id || !s.alive || s.species == Species::Ravager || !s.bounds.intersects(area) {
continue;
}
+4 -1
View File
@@ -104,7 +104,10 @@ pub fn body(m: &Mob) -> (String, String) {
}
};
if &*m.kind.id == "minecraft:player" {
return ("main".into(), m.kind.texture.to_string());
return (
"main".into(),
m.skin.clone().unwrap_or_else(|| m.kind.texture.clone()),
);
}
match m.species() {
S::Llama | S::TraderLlama => {
@@ -568,7 +568,7 @@ fn sense_living(m: &mut Mob, w: &MobWorld) {
let r = m.attrs.follow_range;
let area = m.bounds().inflate(Vec3::new(r, r, r));
let mut found: Vec<(Who, f64)> = Vec::new();
for o in w.others {
for o in w.others_near(area) {
if o.id != m.id && o.alive && o.bounds.intersects(area) {
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
}
@@ -898,8 +898,9 @@ fn poi_matches(m: &Mob, f: PoiFilter, p: Poi) -> bool {
_ => false,
}
}
/// Tickets taken on a POI: by this villager's memories and by others'.
fn tickets(m: &Mob, w: &MobWorld, p: [i32; 3]) -> usize {
/// Tickets taken on a POI: by this villager's memories and by others'
/// (counted once per search by [`others_tickets`]).
fn tickets(m: &Mob, others: &crate::fast_hash::FxHashMap<[i32; 3], usize>, p: [i32; 3]) -> usize {
let mut n = 0;
if let Some(mm) = mem_ref(m) {
for s in [
@@ -913,16 +914,22 @@ fn tickets(m: &Mob, w: &MobWorld, p: [i32; 3]) -> usize {
}
}
}
n + others.get(&p).copied().unwrap_or(0)
}
/// The tickets the other living villagers' POIs take, by POI.
fn others_tickets(m: &Mob, w: &MobWorld) -> crate::fast_hash::FxHashMap<[i32; 3], usize> {
let mut taken = crate::fast_hash::FxHashMap::default();
for o in w.others {
if o.id != m.id && o.alive && o.species == Species::Villager {
for q in [o.job, o.bed, o.potential_job, o.meeting] {
if q == Some(p) {
n += 1;
}
for q in [o.job, o.bed, o.potential_job, o.meeting]
.into_iter()
.flatten()
{
*taken.entry(q).or_insert(0) += 1;
}
}
}
n
taken
}
/// The POIs of chunk `(qx, qz)` from `y0` to `y1` (within the world), in
/// scan order: y, then z, then x. Only the indexed POI blocks of the sections
@@ -962,6 +969,8 @@ fn pois_in_range(
) -> Vec<(Poi, [i32; 3])> {
let c = block_of(m.position);
let mut found = Vec::new();
// Every candidate asks how taken it is: count the others' once.
let mut taken = None;
let r2 = i64::from(radius) * i64::from(radius);
let (cx, cz) = (c[0] >> 4, c[2] >> 4);
let chunks = radius.div_euclid(16) + 1;
@@ -974,7 +983,8 @@ fn pois_in_range(
|| (p[2] - c[2]).abs() > radius
|| dist_sqr(p, c) > r2
|| !poi_matches(m, f, poi)
|| tickets(m, w, p) >= poi.max_tickets()
|| tickets(m, taken.get_or_insert_with(|| others_tickets(m, w)), p)
>= poi.max_tickets()
{
return;
}
Loaded 100 of 371 files, more files were not shown because too many files have changed in this diff. Show more