Compare commits

...
Author SHA1 Message Date
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 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 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 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 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 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
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 Marie c16b41ab31 Merge pull request #1162 from ludovic111/feat/dynamic-lights
🤖 Agent: merged after integration wave 1 on the desktop (main e730dc14 + #1108 #1114 #1131 #1157 #1158 #1162 = ae482a72): fmt, clippy -D warnings, workspace tests (one port-race flake in horizon-server panel, passes on rerun), release build, tools/parity.py --changed origin/main 299/302 then the 3 rerun: save_roundtrip pass, world_reopen pass x2 (one early-count flake), dedicated_server = known flake also failing on main (fixed by #1183/#1184).
2026-10-22 12:51:34 +02:00
Ludovic Marie 46efecf9d2 Merge pull request #1158 from ludovic111/fix/shader-pack-folder
🤖 Agent: merged after integration wave 1 on the desktop (main e730dc14 + #1108 #1114 #1131 #1157 #1158 #1162 = ae482a72): fmt, clippy -D warnings, workspace tests (one port-race flake in horizon-server panel, passes on rerun), release build, tools/parity.py --changed origin/main 299/302 then the 3 rerun: save_roundtrip pass, world_reopen pass x2 (one early-count flake), dedicated_server = known flake also failing on main (fixed by #1183/#1184).
2026-10-22 12:51:30 +02:00
Ludovic Marie d029d0d28c Merge pull request #1157 from ludovic111/fix/shaderpack-compat
🤖 Agent: merged after integration wave 1 on the desktop (main e730dc14 + #1108 #1114 #1131 #1157 #1158 #1162 = ae482a72): fmt, clippy -D warnings, workspace tests (one port-race flake in horizon-server panel, passes on rerun), release build, tools/parity.py --changed origin/main 299/302 then the 3 rerun: save_roundtrip pass, world_reopen pass x2 (one early-count flake), dedicated_server = known flake also failing on main (fixed by #1183/#1184).
2026-10-22 12:51:27 +02:00
Ludovic Marie aa7f71a79b Merge pull request #1131 from ludovic111/feat/version-sources
🤖 Agent: merged after integration wave 1 on the desktop (main e730dc14 + #1108 #1114 #1131 #1157 #1158 #1162 = ae482a72): fmt, clippy -D warnings, workspace tests (one port-race flake in horizon-server panel, passes on rerun), release build, tools/parity.py --changed origin/main 299/302 then the 3 rerun: save_roundtrip pass, world_reopen pass x2 (one early-count flake), dedicated_server = known flake also failing on main (fixed by #1183/#1184).
2026-10-22 12:51:24 +02:00
Ludovic Marie 3704384c72 Merge pull request #1114 from ludovic111/fix/server-thread-default-v2
🤖 Agent: merged after integration wave 1 on the desktop (main e730dc14 + #1108 #1114 #1131 #1157 #1158 #1162 = ae482a72): fmt, clippy -D warnings, workspace tests (one port-race flake in horizon-server panel, passes on rerun), release build, tools/parity.py --changed origin/main 299/302 then the 3 rerun: save_roundtrip pass, world_reopen pass x2 (one early-count flake), dedicated_server = known flake also failing on main (fixed by #1183/#1184).
2026-10-22 12:51:20 +02:00
Ludovic Marie 772844362e Merge pull request #1108 from ludovic111/fix/remote-player-abilities
🤖 Agent: merged after integration wave 1 on the desktop (main e730dc14 + #1108 #1114 #1131 #1157 #1158 #1162 = ae482a72): fmt, clippy -D warnings, workspace tests (one port-race flake in horizon-server panel, passes on rerun), release build, tools/parity.py --changed origin/main 299/302 then the 3 rerun: save_roundtrip pass, world_reopen pass x2 (one early-count flake), dedicated_server = known flake also failing on main (fixed by #1183/#1184).
2026-10-22 12:51:17 +02:00
Ludovic MarieandClaude Opus 5.5 938a25a712 Modpacks doc: where ATM10's data mods stand, and what is missing, ranked
Pack-wide numbers after the modpacks-12 PRs (#1175, #1176, #1177, #1191):
structures that start, processors carried and dropped per mod, content,
load time and the play-through, and the gaps ranked by how much a player
notices them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:05:53 +02:00
Ludovic MarieandClaude Opus 5.5 de8cb0dfa6 LAN guests: the lectern and the cartography table (#868)
The last two workstation menus guests couldn't use, both now on the
shared menu engine, which single player runs too:

- Lectern: LecternMenu on the engine (Kind::Lectern): the page buttons
  (1, 2, 100+) and Take Book (3, mayBuild) as ContainerButtonClick, the
  page as data slot 0 clamped to the book, the book in its one slot
  (removeItem, setItem a no-op, max 1). The host opens minecraft:lectern
  for a guest, applies the menu to the block entity (powered pulse, page
  turn level event 1043, has_book), resends changes and closes it on
  stillValid. Guests drive it from the book screen.
- Cartography table: CartographyTableMenu on the engine (Kind::Cartography,
  setupResultSlot, onTake, onCraftedBy's new map ids through a Maps env),
  replacing the old station code; guests get the same through
  guest_menu_op.
- LAN item stacks carry map_post_processing and writable/written book
  contents (they were blank for guests); map_post_processing is no longer
  saved (ItemStack.CODEC never saves it).
- A guest who enchants gets the enchant_item stat.
- Click parity: VanillaMenuOracle and menu_fixture gain both menus.
- lan_e2e: new lectern and cartography checks; enchanting checks the
  stat; server_count/server_reply ignore the guest's own operator
  broadcast echo on --server-thread; the grindstone check counts levels
  as well as points.

Fixes #868

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:35:45 +02:00
Ludovic MarieandClaude Opus 5.5 41a4ce4edb Linux: the game's workers go behind its render and server threads
On Linux an unprivileged thread can't raise itself, so #1179's render
thread hint does nothing there. The dedicated server already lowered its
helper threads instead (world generation, lighting, connection writers:
10 nice levels, or the idle class). The client now does the same, so the
world-generation and meshing workers (two pools with single player's
server thread) no longer hold the render thread back while terrain loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:05:19 +02:00
Ludovic MarieandClaude Opus 5.5 0ae1ab2e70 world_spawn harness: vanilla's server creates the world on one worker
Trees of neighbouring chunks overlap on chunk corners, and the one
decorated first wins. With several worker threads vanilla decorates in a
different order each run: seed -2080727421395798566 spawned at Y 64, 66
or 72 over 10 runs. The harness retried vanilla up to 3 times and passed
if any run matched, which still failed when none of them gave 72.

With -Dmax.bg.threads=1 (Util.maxAllowedExecutorThreads) vanilla
decorates in its scheduler's order, the one Horizon follows: that seed
gave 72 in 10 of 10 runs, as Horizon does. The other seeds that varied
are fixed too (10 of 10 each: -8171837717659076940 at 0 67 0,
-5296387317982839690 at -16 73 16, -211473008665507967 at 0 66 112).
The retries go, and vanilla's one spawn is compared exactly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:05:00 +02:00
Ludovic MarieandClaude Opus 5.5 3aa36f95ab secure_chat harness: steps end on a barrier, players leave one at a time
Three races made the two sides differ with nothing wrong in Horizon:

- Steps ended on a fixed 1.5 s sleep. On a slow run a chat or its
  broadcast (vanilla queues the chat, then the broadcast as a later
  task) landed in the next step. Each step now also ends on a barrier:
  every connected client sends `random value 1..2` twice, and waits for
  the answer, which only the sender gets and which is left out of the
  transcript.
- The client closed every socket at once, and the server logs
  "<name> lost connection" in whatever order its network threads see
  the closed connections. The players still connected now leave one at
  a time: each waits until it has been told the previous one left
  (PlayerList.remove broadcasts that after logging). The harness waits
  for Carol's line before stopping the server.
- A player the server kicks (Bob, Mallory) is logged as
  "lost connection: <reason>", or as "Disconnected" when vanilla's
  network thread closes the connection before disconnect(reason) runs.
  The reason sent is still compared in the transcript, so the log line
  no longer compares it for kicked players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:04:13 +02:00
Ludovic MarieandClaude Opus 5.5 84c7f39148 Natural spawning: borrow the spawn lists, and only the nearest distance
Natural spawning was a quarter of a 40-player server thread. Every spawn
attempt cloned the position's whole spawn list (NaturalSpawner.mobsAt) to
check the picked entry is still in it, and every group cloned it again to
pick from; mobs_at now returns the biome's or the structure's list as it is
stored (the fortress list lives in a static). The nearest player's distance,
asked on every attempt, is now the minimum of the distances instead of a
search that kept the player too. Same lists, same draws, same distances.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:58:39 +02:00
Ludovic MarieandClaude Opus 5.5 388f59aff4 Game: the ping's LAN flag follows is_published, every frame
Single player on the server thread publishes inside the integrated
server, so self.lan alone missed it; and the fps block runs only when a
frame is drawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:48:02 +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
Ludovic MarieandClaude Opus 5.5 dd1525f045 dedicated_server harness: wait for what each step needs, not a fixed time
The vanilla protocol client moved on after a fixed time per step. On a
busy machine, either side's server could be slower than that: the
summoned villager was not seen yet when the trade step ran, the furnace
had not smelted the ingot when it was taken, a snowball was not thrown
yet, or the pearl had not landed. Each mode failed now and then on one
side only.

Those steps now keep the same minimum time and then also wait, for up to
30 s, until it happened: the villager is tracked, the furnace's result
slot has the ingot (plus 1 s), the snowball entity was added (plus 1 s),
the pearl teleport and the health update arrived. The respawn line also
leaves out block updates: a lava fall keeps flowing while the game runs,
and how many updates arrive before the respawn depends on timing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:41:24 +02:00
Ludovic MarieandClaude Opus 5.5 9054acfed2 Launcher: the ping notice keeps its height; status lists the ping
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:37:31 +02:00
Ludovic MarieandClaude Opus 5.5 770809908d Render thread ahead of the workers on macOS and Windows
The client asks for its render thread what it already asks for the
server thread (vanilla's MinecraftServer.spin): user-interactive QoS on
macOS, above-normal priority on Windows, on machines with more than four
processors. While terrain loads at 32 chunks, the worker pools (two with
single player's server thread), the chunk encoders and other programs
outnumber the cores. A frame that gave its core up in queue.submit then
waited 10 to 200 ms to get one back: the long frames of #678.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:29:19 +02:00
Ludovic MarieandClaude Opus 5.5 83d001460c LAN: write each tick's entity states over the last tick's
tick_lan described every tracked entity afresh each tick: a new String for
its type, new vectors for its entity data, equipment and attributes, all
freed again at the end of the tick. The states now live in the LAN host
between ticks and each tick writes over the last tick's in place
(lan::entity::States), so those allocations are kept. The states, and so
every packet built from them, are the same as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:22:54 +02:00
Ludovic MarieandClaude Opus 5.5 8f738d35ec Live player count: an anonymous "I'm playing" ping and "N playing now"
The game sends an anonymous ping once a minute while it runs (a random
session ID kept only in memory, the version, the platform, whether a LAN
or friends world is open). The telemetry server counts sessions seen in
the last 2 minutes, in memory only, with its own per-address rate limit
and a session cap, and serves rounded counts at GET /v1/live. The
launcher shows "N playing now" on the Play page when the count is
reachable, and tells players what the ping holds on the first-run
question, in Settings, and once on the Play page for players who
answered before. docs/telemetry.md and the privacy page say the same.

Like the rest of telemetry, nothing is sent until HORIZON_TELEMETRY_URL
is set or DEFAULT_ENDPOINT gets a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:18:58 +02:00
Ludovic MarieandClaude Opus 5.5 6f1f83d07c online_mode harness: one login at a time, and vanilla's two log races
Three races made the comparison of the servers' lines about the player
fail at random:
- a login's lost connection is logged at the server's next tick, and the
  next login could start before it: each login now ends once the server
  logged its lost connection;
- vanilla's authenticator disconnects a player, then logs why, while the
  server thread may already log the lost connection: each login's lines
  are compared thread by thread;
- a player the server kicks closes the connection once it reads why; when
  that comes first vanilla logs the lost connection as "Disconnected"
  (seen once in 12 desktop runs), so a login's kick reason stands for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:08:17 +02:00
Ludovic MarieandClaude Opus 5.5 743cc8e069 Ender pearls: a server always sends the thrower the teleport
ThrownEnderpearl.onHit teleports the owner to oldPosition() (where the
pearl started its last tick) with ServerPlayer.teleport, which sends the
position packet whatever the distance. A guest's turn sent a teleport only
when the player moved more than 0.01 blocks, and the pearl took the hit
point: a pearl thrown at one's feet often lands within that, and the
dedicated_server harness saw "teleported 0 time(s)" where vanilla's server
sends 1 (about one run in four on the desktop).

The pearl now moves the player to its previous position and marks the
teleport (PlayerMotion.teleported), which the end of the guest's turn
sends even when the player stays put.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:51:36 +02:00
Ludovic MarieandClaude Opus 5.5 8291c8af5b Chunk packets get 1 ms a frame; the rest waits for the next frame
A client of a server (single player's server thread, LAN, multiplayer)
handled every received packet in the frame it arrived. Crossing a chunk
border at 32 chunks brings 25 to 60 columns at once, 0.1 ms each to
decode on an M4 Pro: a 3 to 8 ms frame each time. Once a frame has spent
1 ms on chunk packets, the events from the next chunk packet on wait for
the next frame, in order; events that have waited a tick are all handled
at once, so a slow machine's backlog never holds packets longer than
that. The status's pending_chunks counts the waiting events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:09:47 +02:00
Ludovic MarieandClaude Opus 5.5 e6b92eb4ee Structures: Formations' fountain (its pieced structure) generates
Formations' PiecedStructure starts like its SimpleStructure, then lays out
the element's jigsaw children with vanilla's jigsaw. The game's jigsaw
assembler now exposes its child layout (add_children) for a start piece
placed by another structure type; horizon:formations reads horizon:pieced
(max_depth, max_distance_from_center), which carry writes for
formations:pieced.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 09:03:34 +02:00
Ludovic MarieandClaude Opus 5.5 2f3f1377a3 Structures: CTOV's mod-compat hooks and Repurposed's mineshaft sky view carried
ctov:modular_compat runs another processor list only when the mod it names
is loaded: carry writes that list in when the mod is ported along and takes
the hook out otherwise (142 copies in ATM10, all for Nature's Spirit).
Repurposed Structures' mineshaft_sky_view_processor (71 lists) becomes the
game's horizon:mineshaft_sky_view: nothing at or above the world's
OCEAN_FLOOR_WG height, except plain rails over a block that can occlude,
waterlogged where the world holds water. Placement contexts gain
can_occlude.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:46:42 +02:00
Ludovic MarieandClaude Opus 5.5 dbb1ca0907 LAN guests use crafters
A guest's use of a crafter now opens CrafterMenu (minecraft:crafter_3x3)
over the block entity. The host runs its clicks on the menu engine's
verified Kind::CrafterBlock (a disabled slot refuses items, filling one
enables it), sends the disabled slots and triggered as data slots
(CrafterBlockEntity.containerData), and handles
ServerboundContainerSlotStateChangedPacket (new in horizon-protocol) as
CrafterBlockEntity.setSlotState. The guest shows the server's crafter,
predicts its clicks and sends a slot toggle before the click as
CrafterScreen.slotClicked does.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 ca5404e52d Beacon on the menu engine; LAN guests use beacons
BeaconMenu now runs on horizon_client::menu (Kind::Beacon): the payment
slot (beacon_payment_items, one item), quickMoveStack and updateEffects
with BeaconBlockEntity.validateEffects, verified against the vanilla class
by test_menu_click_parity with a new SET_BEACON op (valid and invalid
powers for each pyramid level).

ServerboundSetBeaconPacket is now in the protocol. The LAN host opens
minecraft:beacon for guests with the block entity's levels and powers as
data slots (kept current while the menu is open), applies a guest's
powers to the block entity (power_select while the beam is up) and drops
the payment when the menu closes, as BeaconMenu.removed does. Guests show
the server's levels and powers and their Done button sends the packet.
Single player's beacon clicks go through the engine too.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 b5a92262c8 Enchanting table on the menu engine; LAN guests enchant
EnchantmentMenu now runs on horizon_client::menu: the item slot (one
item) and the lapis slot, its quickMoveStack, slotsChanged clearing the
offers without an enchantable item, and clickMenuButton's checks against
the costs, lapis and levels, verified against the vanilla class by
test_menu_click_parity (with its ten data slots and the offer buttons;
the oracle's player now has a profile for the invalid-button log line).
The offers and the enchanting itself, which need a level, stay with the
caller.

Single player routes the table's clicks, drags and double clicks through
the engine. The LAN host opens minecraft:enchantment for guests with
their own enchantment seed (saved with their player data), sends the
costs, seed and clues as data slots, and on ContainerButtonClick
enchants the item, takes the guest's level and lapis and gives a new
seed. Guests show the server's offers and send the button the client
menu accepts.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 3e868683ba Anvil and smithing table on the menu engine; LAN guests use them
AnvilMenu and SmithingMenu (ItemCombinerMenu) now run on
horizon_client::menu: their slot rules, createResult (the anvil's cost
data slot, repair count and rename, the smithing table's hasRecipeError),
mayPickup with the player's levels, the results' onTake and
quickMoveStack, verified against the vanilla classes by
test_menu_click_parity with repairs, merges, renames (a new RENAME op,
AnvilMenu.setItemName) and the player's levels. The harness found the
anvil treating only enchanted books as books (vanilla: anything with
stored enchantments), now fixed, and the grindstone's and anvil's result
slots now count for a double click's pick-all as in vanilla.

Single player routes both screens' clicks, drags and double clicks
through the engine; the anvil's wear and level events 1030/1029, and the
smithing table's 1044, play with vanilla's random pitch.

The LAN host opens minecraft:anvil and minecraft:smithing for guests,
handles ServerboundRenameItemPacket (new in horizon-protocol), charges the
guest's levels and wears the anvil; guests send the rename box as
AnvilScreen.onNameChanged does and show the server's cost. The control
socket's `type` now reaches the anvil's rename box.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 4fecf43d28 Grindstone on the menu engine; LAN guests use grindstones
GrindstoneMenu now runs on horizon_client::menu (Kind::Grindstone): its
input filter, computeResult, the result's onTake (both inputs cleared,
the experience sum handed to the caller) and quickMoveStack, verified
against the vanilla class by test_menu_click_parity with enchanted,
damaged and cursed tools and books.

Single player routes the grindstone's clicks, drags and double clicks
through the engine and rolls the orbs and level event 1042 (now playing
block.grindstone.use) where the result is taken. The LAN host opens
minecraft:grindstone for guests on the same engine and awards the orbs at
the block; guests read enchantments, stored enchantments, repair cost and
custom names from the server's stacks, and the host now sends repair
cost.

Part of #868.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 c8f02d91b7 Stonecutter on the menu engine; LAN guests use stonecutters
StonecutterMenu joins horizon_client::menu (Kind::Stonecutter): the input's
recipe list (slotsChanged / setupRecipeList), selectedRecipeIndex as its data
slot, clickMenuButton (an invalid index is taken too), the result's onTake
(one input used up, awardUsedRecipes, the take sound) and quickMoveStack with
the leftover of a result move dropped. test_menu_click_parity compares it
with vanilla's StonecutterMenu, recipe buttons included.

Single player's stonecutter screen now clicks through the engine (drags and
double clicks included), and its recipe grid calls clickMenuButton as
StonecutterScreen does. A LAN guest's stonecutter opens minecraft:stonecutter
on the host's engine, as the loom does (#868).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 cd37ea98a0 LAN guests use looms: workstation menus on the host's menu engine
A guest's use of a loom opens LoomMenu (minecraft:loom) for the guest. The
host runs the guest's menu on the shared menu engine (horizon_client::menu,
Kind::Loom, as single player does): clicks, the pattern buttons
(ServerboundContainerButtonClickPacket), the content and data slot sync after
each, the take-result sound at the loom, stillValid (the block gone or out of
reach) and clearContainer when it closes or the guest leaves. The guest opens
the loom screen for the server's menu, predicts its clicks and buttons on the
engine and sends them, runs the menu's client-side slotsChanged on the
server's slots and reads the selected pattern from the data slot.

The framework (game/lan_stations.rs, game/remote_stations.rs) takes the other
workstations one by one (#868).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:43:29 +02:00
Ludovic MarieandClaude Opus 5.5 255f1960ee Structures: Mo' Structures generate (jigsaw with its terrain check, config spacing, exclusion by spread)
Carry maps mostructures:generic to vanilla's jigsaw with horizon:start_checks
(terrain_radius 2, terrain_range = heightRange) and mostructures:type to a
random spread with the config's default spacing and separation and its sets
to avoid as horizon:exclusion_zone. The game's exclusion zone gains
"their_zones": false, which tests the avoided sets by spread and frequency
alone, as ModStructurePlacement does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:33:20 +02:00
Ludovic MarieandClaude Opus 5.5 30a2cc7c61 Wayland: pin the 8-bit surface choice with a colour-managed compositor's list (#1007)
#1007's white overlay on Hyprland is #1075's washed-out swapchain: a
Wayland compositor with colour management makes Mesa list Rgba16Float
first, and the first non-sRGB format was taken. Reproduced on a real
GPU (Mesa 26.2 ANV, Intel HD 620) under headless weston 16: the old
choice washes the frame out, main's Bgra8Unorm doesn't. This test keeps
that list, as wgpu reports it, choosing an 8-bit format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:26:52 +02: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
Ludovic MarieandClaude Opus 5.5 f8d0ef3cfe Merge main into fix/remote-player-abilities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:15:44 +02:00
Ludovic MarieandClaude Opus 5.5 16738c9f26 Launcher: say when a prepared version has no sounds (#1035)
A version prepared while the sound copy failed (Mojang's republished 26.3
asset index, #1076, fixed in #1091), or one that reused such a version's
resources when installed, stays silent and showed as plain Prepared. The
Play page now says it is prepared without its sounds and makes Prepare
again the visible fix; versions --installed and the MCP list say so too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:10:31 +02:00
Ludovic MarieandClaude Opus 5.5 e70b520e5d Render thread ahead of the workers on macOS and Windows
The client asks for its render thread what it already asks for the
server thread (vanilla's MinecraftServer.spin): user-interactive QoS on
macOS, above-normal priority on Windows, on machines with more than four
processors. While terrain loads at 32 chunks, the worker pools (two with
single player's server thread), the chunk encoders and other programs
outnumber the cores. A frame that gave its core up in queue.submit then
waited 10 to 200 ms to get one back: the long frames of #678.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:57:47 +02:00
Ludovic MarieandClaude Opus 5.5 c2b7f3a4ea Slow-frame and slow-tick lines show the CPU time of each span
HORIZON_SPIKES spans keep the thread's CPU time beside their wall time,
and the slow-frame line gives the render thread's CPU time in the frame.
A span that mostly waited (for the GPU, a lock or a core) prints both
(`submit 30.2/0.1`), so a frame that waited reads apart from one that
worked. Found the cause of #678's long frames: the render thread losing
its core in queue.submit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:55:54 +02:00
Ludovic MarieandClaude Opus 5.5 8e700aa683 Riptide: run vanilla's auto-spin attack after the launch
Releasing a Riptide trident now starts LivingEntity.startAutoSpinAttack(20,
8.0): for 20 ticks the player takes the SPIN_ATTACK pose (0.6x0.6, eye
height 0.4), hits the first living entity its swept box touches for the
spin's damage and bounces back (velocity x -0.2), and stops on a horizontal
collision. The player model spins (setupRotations: rotX(-90 - xRot),
rotY(age * -75)), SpinAttackEffectLayer draws the riptide boxes, and the
first-person hand uses ItemInHandRenderer's spin transform. LAN guests see
the host's spin through living flag bit 4.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:43:55 +02:00
Ludovic Marie e730dc1426 Merge pull request #1164 from ludovic111/fix/moving-piston-tint-pos
🤖 Agent: merged. Verify passes. This fixes main's build (E0061, where #1113 and #1115 crossed).
2026-10-22 07:38:39 +02:00
Ludovic Marie d9560d46e6 Merge remote-tracking branch 'origin/fix/moving-piston-tint-pos' into HEAD 2026-10-22 07:24:39 +02:00
Ludovic MarieandClaude Opus 5.5 89747692d2 Moving pistons: pass the block position to particle_sprite (main build fix)
#1113 (moving pistons) and #1115 (break particles, which gave particle_sprite
a position for its biome tint) each passed alone; together main no longer
compiled. A moving block now takes its tint at its own block position, as
vanilla's moving-block renderer reads the level's tint there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 07:24:24 +02:00
Ludovic Marie 26142a9c40 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 07:18:55 +02:00
Ludovic Marie ee3ac16594 Merge pull request #1113 from ludovic111/fix/moving-piston-collision
🤖 Agent: merged. Verify and CI parity pass (the earlier online_mode failure was a race and passed on rerun). Moving blocks are drawn and solid; entity pushing is in FEATURES.md LATER 4h.
2026-10-22 07:03:19 +02:00
Ludovic Marie 52c24d1ac9 Merge pull request #1160 from ludovic111/integ/b5-lan-rest
🤖 Agent: merged. Verify passes. Proof: the B5 integration of these eight heads (parity --changed 102/102, lan_e2e 46–47/48 with only order-dependent flakes), and the conflict resolution in tools/lan_e2e.py checked def by def against each branch.
2026-10-22 07:03:15 +02:00
Ludovic Marie b56b6e824e Merge pull request #1159 from ludovic111/fix/axolotl-idle-swim
🤖 Agent: merged. Verify passes; local parity --changed origin/main passes (101 harnesses; #1159's one online_mode diff reran clean and passes on main). Screenshots and tick-by-tick results in the body.
2026-10-22 06:25:18 +02:00
Ludovic Marie 0840b1f8ca Merge pull request #1156 from ludovic111/fix/sulfur-bubbles
🤖 Agent: merged. Verify passes; local parity --changed origin/main passes (101 harnesses; #1159's one online_mode diff reran clean and passes on main). Screenshots and tick-by-tick results in the body.
2026-10-22 06:25:14 +02:00
Ludovic Marie 95cee53d13 Merge remote-tracking branch 'origin/fix/guest-predicts-door-use' into integ/b5-lan-rest
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 06:17:27 +02:00
Ludovic Marie 195403a6e1 Merge remote-tracking branch 'origin/fix/lan-player-arrows-hit-players' into integ/b5-lan-rest
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 06:17:27 +02:00
Ludovic Marie 48c8975097 Merge remote-tracking branch 'origin/fix/lan-guest-kills-are-theirs' into integ/b5-lan-rest
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 06:17:27 +02:00
Ludovic Marie 705da8b248 Merge remote-tracking branch 'origin/fix/lan-guest-worn-armour' into integ/b5-lan-rest
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 06:17:27 +02:00
Ludovic Marie 214d32865c Merge remote-tracking branch 'origin/fix/explosions-every-player' into integ/b5-lan-rest
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 06:17:26 +02:00
Ludovic Marie a558b046cd Merge remote-tracking branch 'origin/fix/piglin-barter-nearest-player' into integ/b5-lan-rest
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 06:17:26 +02:00
Ludovic Marie 2159aa2205 Merge remote-tracking branch 'origin/fix/ghast-fireball-deflect' into integ/b5-lan-rest
# Conflicts:
#	tools/lan_e2e.py
2026-10-22 06:17:19 +02:00
Ludovic Marie 4002595331 Merge remote-tracking branch 'origin/fix/small-fireball-fire' into integ/b5-lan-rest 2026-10-22 06:16:35 +02:00
Ludovic MarieandClaude Opus 5.5 832f879b27 Dynamic lights: held, dropped and burning light sources (Horizon Extras)
A new Horizon Extras option, Lighting > Dynamic lights (`dynamicLights`,
off by default): a torch, lantern or other light-emitting item held by a
player or mob, dropped as an item, or any entity on fire lights the blocks,
entities and hand around it, as LambDynamicLights and OptiFine do.

Client-only and at render time: the light engine, chunk meshes and saves
never see it. Each frame the game picks the 16 sources nearest the camera
(dynamic_light::select) and passes them in Globals; terrain.wgsl and
entity.wgsl raise each vertex's block light to `level - distance` when that
is brighter. Off, the count in globals.time.z is 0, nothing is gathered and
the shaders' loop runs zero times, so no pipeline is rebuilt either way.

An item glows like its block's default state (torch 14, lantern and
glowstone 15, lit redstone torch 7); a lava bucket 15; burning entities 15.

Fixes #814

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:40:09 +02:00
Ludovic MarieandClaude Opus 5.5 f59f6e3e84 lan_e2e portals: take the portals away afterwards
The guest came home into its Nether portal's frame, with an obsidian
ceiling three blocks up, and the host onto the End portal it had set at
spawn; both stayed there for the rest of the run. Under that ceiling a
double jump rises about 1.2 blocks at most, and whether the flight check
saw it depended on timing: `flight` failed in full --server-thread runs
(portals runs only there) and passed alone. The check now clears the
frame and the End portal in the overworld, and puts the guest back where
it started, also when it fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 b7453b129b agent_control_e2e survival: wait for the log's drop or its pickup
On single player's server thread the broken log's drop and its pickup
both come from the server, a few ticks after `break` answers: the
pickup could land before the scenario looked for the drop, which it
then never found (StopIteration). Step 3 now waits up to 20 ticks for
the logs in the inventory or the drop to walk to.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 05e169f4e3 agent_control_e2e pick: wait for the server to clear the cursor
Step 2 clones a stack in the creative tab and closes the screen. The
clone lived only on the client: ItemPickerMenu's carried stack is the
inventoryMenu's, and AbstractContainerMenu.removed clears the carried
stack only for a ServerPlayer, so vanilla's client still holds it when
the survival inventory opens. With a stack in hand the middle button's
release sends CLONE (AbstractContainerScreen.mouseReleased); the server
finds the carried stack it was told about wrong and sends the empty one
(broadcastChanges). On single player's server thread that answer takes
a round trip, as in vanilla, so step 3 now waits up to 20 ticks for it.
The in-frame world puts the clone away when the menu closes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 8b75ac3cdb lan_e2e and agent_control_e2e on the server thread
From b9f3821f (fix/server-thread-default): the host window draws what
it was sent on the server thread, and agent_control_e2e waits two ticks
where the server answers a click.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 05:16:51 +02:00
Ludovic Marie 55891f9c95 Wait for the server's block in the agent's place, try hidden spots
On a client of a server (single player's server thread, a LAN guest) the
placed block comes with the server's answer: `place` waits up to a second
for it before reporting no change. agent_control_e2e tries the next free
spot when the tree's trunk above the broken log hides a spot's faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 9821bb7ef5)
2026-10-22 05:16:51 +02:00
Ludovic Marie 2f883b5cdb Keep the command harness in the frame; compare finished border chunks
- The command harness (HORIZON_COMMAND_SCRIPT) drives the world itself,
  without the event loop, so it does not start the server thread.
- `status` lists the minecarts a client of a server was sent.
- save_roundtrip: single player's server thread finishes the chunks its
  player tracks, as vanilla's integrated server does, including some that
  vanilla's server (view distance 4) left before their terrain or light.
  Those had no blocks, light or heightmaps to compare yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 2818c89f24)
2026-10-22 05:16:51 +02:00
Ludovic Marie 0468b75c43 Run single player on its server thread by default
Single player's world now runs on the integrated server thread unless
asked otherwise, as vanilla's does, so a world tick no longer holds up
a frame. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` keeps the
world in the frame as an escape hatch; `--server-thread` stays.

lan_e2e.py sets the host's mode explicitly either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 4ff23b686d)
2026-10-22 05:16:51 +02:00
Ludovic Marie 3668973807 Merge pull request #1133 from ludovic111/fix/openal-stereo-panning
🤖 Agent: merged. Verify passes; CI parity's only failure is the known dedicated_server pearl flake. Measured panning against OpenAL Soft's stereo output in the body.
2026-10-22 05:04:32 +02:00
Ludovic Marie 1ec77ad997 Merge pull request #1117 from ludovic111/fix/thrown-potion-render
🤖 Agent: merged. Verify passes. CI parity's only failure is server_player_targets, a server harness this client rendering change can't touch; it has flaked before (its log said '109 lines identical' on #1118). Screenshots in the body.
2026-10-22 05:04:29 +02:00
Ludovic Marie e6e18b8e4c Merge pull request #1154 from ludovic111/feat/mod-browser-page
🤖 Agent: merged. Verify passes (launcher only). Mod browser part 3/3: the launcher's Mods page, with screenshots in the body.
2026-10-22 05:04:25 +02:00
Ludovic Marie 6f5a9b61e7 Merge pull request #1153 from ludovic111/feat/mod-browser-install
🤖 Agent: merged. Verify passes (launcher only). Mod browser part 2/3: install, update and remove.
2026-10-22 05:04:00 +02:00
Ludovic Marie 99aaccc977 Merge pull request #1141 from ludovic111/feat/mod-browser-sources
🤖 Agent: merged. Verify and CI parity pass. Mod browser part 1/3: Modrinth source and checksum verification. The CurseForge source stays inactive until a key is configured.
2026-10-22 05:03:13 +02:00
Ludovic MarieandClaude Opus 5.5 86b58b71fa Client of a server: abilities from the server, flight sent back
A player who joined already in creative could not fly: the remote
client kept only instabuild and the flying speed of
ClientboundPlayerAbilitiesPacket, and the motion step clears flying for
a player that may not fly. The packet also comes before the client has
a player (it is made at the first teleport). Single player creative on
its server thread could not fly at all; a LAN guest only after a game
mode change.

- The remote client keeps the last abilities and applies flying, may
  fly, invulnerable and the flying speed to the player, also when the
  player is made (ClientPacketListener.handlePlayerAbilities).
- When its flight turns on or off (double jump, spectator, landing) it
  sends ServerboundPlayerAbilitiesPacket (LocalPlayer.onUpdateAbilities).
- The hub keeps it as the guest's flying when the guest may fly
  (handlePlayerAbilities), so the save has it, and a game mode change
  sets it as GameType.updatePlayerAbilities does.
- lan_e2e `flight`: the guest, and on the server thread the host's
  window, fly in creative, stay up, and stop in survival.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 04:43:05 +02: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
Ludovic MarieandClaude Opus 5.5 56591efa5f A guest hears its door at once, and only once
Vanilla plays a block use's sound with `level.playSound(player, ...)`: the
player who clicked hears it from their own client's use, and the server
sends it to everyone else. Horizon's guest waited for the server's sound,
one round trip after its predicted door had already opened.

The guest now plays the sounds of the use it predicts (doors, trapdoors,
fence gates, levers, buttons), and the server sends the sounds of such a
use to the other players only. The control socket's `status` counts the
positional sounds each game played (`heard`).

lan_e2e `predict`: with the host frozen, the guest already hears its door;
once the server answers, the guest and the host have each heard it once.

Fixes #869

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 04:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 ceff635615 A guest draws a bow, and lets go to shoot
A client of a server sent the bow's use but never started using the item
itself, so letting go of the button sent no RELEASE_USE_ITEM: the server
kept the guest drawing and never shot. BowItem.use runs on the client too
(startUsingItem, with an arrow or in creative), and Minecraft.handleKeybinds
releases the item in use once the button is up.

The guest now draws on its own side as well (held until the button lets go
or another slot is picked), so the server shoots when it lets go.

lan_e2e: `arrows` passes both ways now (the guest's arrow hurts the host);
the `pvp` and `arrows` checks allow the victim natural regeneration while
pvp is off (it healed 19.3 -> 20 and failed the exact check).

Fixes #484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 04:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 a4247e9323 A guest's door, trapdoor, fence gate, lever and button use is predicted
Vanilla's MultiPlayerGameMode.useItemOn runs the use on the client's own
level inside startPrediction, records each block it changes under the
packet's sequence in BlockStatePredictionHandler, and keeps them until
ClientboundBlockChangedAckPacket settles them (a use the server refused
goes back). A Horizon guest only sent the use: its door opened one round
trip later.

The guest already ran the use on a dry copy of its level to know whether
the click ends there; the blocks that use changes are now kept on its
level as predictions under the use's sequence, as the break prediction
does, when they are all hand-operated blocks: a wooden or copper door,
trapdoor or fence gate, a lever or a button (an iron door has no hand use
and changes nothing). The sound still comes from the server.

lan_e2e `predict`: with the host process frozen, the oak door the guest
clicks is already open on the guest's side (both halves), stays open on
both sides once the server answers, and an iron door stays shut.

Fixes #869

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 04:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 5763854b0d A player's arrow hurts the other players on a served world
A remote player's projectiles never hit other players: a player's shots
only looked for mobs and the host, a guest's flew as if the host had shot
it (so it could not hit the host until it left the host's box), and a hit
on a guest from another guest ran as the shooter, who has no hub to reach
the victim. No bow PvP on a server or between LAN players.

A player's arrows, snowballs and eggs now meet every player, a guest's
flying from that guest (Projectile.getOwner). A hit on another player runs
as the victim's hurt, refused when the pvp game rule is off or the teams
are allies without friendly fire (ServerPlayer.canHarmPlayer), which turns
the arrow back as a raised shield does (AbstractArrow.onHitEntity).
Pearls, potions and wind charges fly past players as before.

lan_e2e `arrows`: the guest shoots the host and the host the guest with a
bow, both hurt; with pvp off, no harm.

Part of #484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 04:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 def242ecc1 A mob a guest kills is the guest's kill
LivingEntity.die credits the kill to lastHurtByPlayer: the killed
statistic, the mob_kills custom statistic, the kill score criteria and the
player_killed_entity trigger belong to that player. Horizon kept only
whether a player had hurt the mob, and the death ran as the host, so a LAN
or server guest's kills counted in the host's statistics and advancements.

The mobs a player hurt now remember which player (the acting guest's
connection, or the host), and the killer's side of the death runs as that
guest.

lan_e2e `kills`: a pig the guest kills shows in the guest's
minecraft.killed:minecraft.pig score, not the host's.

Part of #484

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 04:06:38 +02:00
Ludovic Marie 67569ef984 A LAN guest's worn armour protects it
The hub's players are server players too, and in vanilla the armour they
wear counts against every hit (LivingEntity.collectEquipmentChanges puts
each piece's attribute modifiers on the player, getDamageAfterArmorAbsorb
reads them). Horizon only put them on the host: a guest who put armour on
in its inventory menu (the hub moves it), joined wearing it, respawned with
it kept or came through a portal (a new body each time) was hurt as if it
wore nothing. Iron armour, 15 points, should take a 10-damage blow down to
6; a guest took the full 10.

Each guest now keeps its own list of applied equipment modifiers, swapped
in with the rest of its state for its turn, and its armour and main hand
are put on its attributes whenever they differ from what they were put on
for (a new body starts with none).

lan_e2e `worn`: the guest shift-clicks iron armour on in its inventory and
takes 10 mob-attack damage: 6 with it, 10 without (failed before: 10 and 10).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e0549f88ce)
2026-10-22 04:06:37 +02:00
Ludovic Marie 73c5a1c5d6 Explosions hurt and push every player, through a raised shield
ServerExplosion.hurtEntities takes every entity in range, players
included, but only the host player was hurt and pushed: LAN guests stood
in a blast untouched. The player's part is now its own function, run for
the host and then for each guest in the level, swapped in as the player.

It also follows Player.hurtServer more closely for everyone: the damage
goes through a raised shield (an explosion is blocked like any attack
from where the blast is, the shield takes wear), the push is reduced by
explosion knockback resistance, and a spectator or a creative player
flying is not pushed.

tools/lan_e2e.py gains a `blast` check: primed TNT five blocks from a
survival guest hurts it (20 -> 4.6) and pushes it away; facing it behind
a raised shield, the guest takes nothing. Before this change the guest
took no damage and no push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 0782a598fe)
2026-10-22 04:06:37 +02:00
Ludovic Marie 8df160f63f Piglins throw their barter toward the nearest player, guests included
PiglinAi.throwItemsTowardPlayer aims at NEAREST_VISIBLE_PLAYER: the
nearest living, non-spectator player within 16 blocks. Only the host was
considered, so a LAN guest who bartered saw the loot fly to the host.

lan_e2e's new barter check: the guest, in gold boots, drops a gold ingot
to a piglin in a glass corridor with the host on the far side; the
piglin stays calm and the barter reaches the guest. It fails on the old
build (the loot lands on the host's side).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 896f12f732)
2026-10-22 04:06:36 +02:00
Ludovic Marie ba72a097fa A player's blow turns a ghast's fireball back
Player.attack on a #minecraft:redirectable_projectile (a ghast's fireball,
a wind charge) deflects it with ProjectileDeflection.AIM_DEFLECT: its
movement becomes the attacker's unit view vector and the attacker its
owner, with the no-damage sound. The crosshair finds these projectiles a
block round their box (AbstractHurtingProjectile.getPickRadius), a remote
client sends the attack for them, and the host turns one a guest strikes
along that guest's view. A fireball a player turned back kills the ghast
it hits (Ghast.isReflectedFireball: no fire immunity, 1000 damage).

lan_e2e.py --check deflect: the host strikes a caged ghast's fireball and
the ghast dies; before, the fireball hit the host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 6ba02f5205)
2026-10-22 04:06:36 +02:00
Ludovic Marie cb4ecfbbe9 A small fireball's fire is placed like vanilla's, and goes out
SmallFireball.onHitBlock places fire with Level.setBlockAndUpdate and
BaseFireBlock.getState (soul fire on soul soil and soul sand), and a mob's
fireball only when mobGriefing is on. Horizon wrote a plain fire state
straight into the chunk: no block updates and no scheduled fire tick, so
the fire never aged and never went out, even on stone or nether bricks
where vanilla's goes out within about half a minute (FireBlock.tick). Every
blaze fireball that missed left a fire that stayed for good: fortress
floors round a spawner filled with fire that kept everyone near it burning.

The fire is now the level's fire state for the spot, set with updates
(the fire's tick is scheduled on place), and only with mobGriefing when a
mob shot it.

lan_e2e `fireball`: a dispenser shoots a fire charge onto stone; both
players see the fire and it goes out by itself (failed before: still burning
after 1200 ticks; now out after about 480).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 5b7235c24b)
2026-10-22 04:06:35 +02:00
Ludovic Marie c1b5fe95d2 Merge pull request #1152 from ludovic111/perf/advancement-index-async-log
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:05:36 +02:00
Ludovic Marie 635faf6d27 Merge pull request #1150 from ludovic111/fix/locator-bar-per-level
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:05:15 +02:00
Ludovic Marie 229f2139d6 Merge pull request #1145 from ludovic111/fix/hurtlog-guest-respawn
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:05:02 +02:00
Ludovic Marie 615aa8f926 Merge pull request #1143 from ludovic111/fix/ominous-item-spawner-save
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:04:50 +02:00
Ludovic Marie 96ae26d865 Merge pull request #1136 from ludovic111/fix/summon-tracked-ids
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:04:37 +02:00
Ludovic Marie affb823466 Merge pull request #1135 from ludovic111/fix/world-spawn-before-save
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:04:25 +02:00
Ludovic Marie c690303948 Merge pull request #1144 from ludovic111/fix/lan-players-lie-in-bed
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:03:23 +02:00
Ludovic Marie dbeebefbda Merge pull request #1134 from ludovic111/fix/loot-set-potion
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102, plus lan_e2e (flakes listed in FEATURES.md LATER 4l).
2026-10-22 04:03:02 +02: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
Ludovic MarieandClaude Opus 5.5 ac25d41578 Shader Packs page: show the first line of a pack's error
Translator errors go on with a drawn source excerpt that the row can't show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 acbfb8ceca docs/shader-packs.md: the ten packs tested and what stops the rest
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 6ea7a51ff3 Shader Packs page: the folder's path, Open Folder, a switch per pack
The Shader Pack setting cycled through what shaderpacks/ held, so with the
folder empty (or the packs in the launcher's version folder, as brabus_ had
them) it silently stayed Off. The row now opens a Shader Packs page: a
sidebar section in the Horizon menus, Video Settings > Shader Packs... in
the Classic ones. It shows the game home's shaderpacks/ path and an Open
Folder button (made if missing), says when there is no pack, and lists one
switch per pack. A pack that fails to load says why on its row. The list is
read again every second while the page shows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 e570561db9 Shader packs: write narrow fragment outputs through a vec4
GL fills the channels a fragment shader leaves out; wgpu refuses a pipeline
whose output has fewer channels than its target. Steadfast's final pass
writes `out vec3` and failed to build in the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 21998a7e6a Shader packs: read pack sources as Iris's JCPP does; legalise what naga refuses
Preprocessor (GLSL and shaders.properties), as JCPP does:
- a #define replaces an earlier macro (Bloop, Steadfast: DefineRedefined);
- in #if, a float counts as its integer part (Steadfast's
  shaders.properties float token), and a macro always defined empty counts
  as 0 (Iris's MC_GL_VENDOR_OTHER, Bloop's TAA_USE_YCOCG);
- an #if may run to the end of the input (Bloop's taa.fsh);
- #error "..." lexes in skipped blocks (Photon);
- shaders.properties keeps its empty lines and hides backslashes from the
  preprocessor (IRIS_PASSTHROUGHBACKSLASH), so a continued value no longer
  swallows the next directive or line (Photon, Bliss, Steadfast's custom
  uniforms; Complementary's isCold uniform is now complete).

Legaliser: % through overloads (naga won't take ivec2 % int: Noble),
sqrt of a negative constant left to the GPU (Bloop's NaN), constants naga
can't fold (?:, mix: Bliss) become globals set in main, and 16/8-bit types
(u16vec2 & co., Noble on NVIDIA) become their 32-bit ones.

horizon-client: real_packs_load runs the game's own load over the packs in
HORIZON_TEST_SHADERPACKS (the compatibility table's source).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:32:23 +02:00
Ludovic MarieandClaude Opus 5.5 cf96b7c097 Mod browser (3/3): the launcher's Mods page
A Mods page between Versions and Modpacks: resource packs, shader packs and
modpacks as tabs, a search field, Show everything, and Modrinth / CurseForge
once CurseForge has a key. Results are index rows with summary, author,
downloads, source, licence and how well each runs (with the reason), Page and
Install (Import for a modpack, which then shows on the Modpacks page).
Installed packs list their version, source, file and the checksum they were
checked against, with Update and Remove. Searches, installs and update checks
run on threads; stale search answers are dropped.

Fixes #577

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:27:33 +02:00
Ludovic Marie 5aff29807a Merge pull request #1149 from ludovic111/fix/others-breaking-progress
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102 (the explosion-dependent 22 rerun after rebasing onto #1101), and lan_e2e passes except checks that fail only after earlier checks and pass alone (in FEATURES.md LATER 4l).
2026-10-22 03:24:35 +02:00
Ludovic Marie d4739f31b2 Merge pull request #1137 from ludovic111/fix/dragon-server-levels
🤖 Agent: merged. Verify passes. B5's local integration of all its PRs on main: parity --changed 102/102 (the explosion-dependent 22 rerun after rebasing onto #1101), and lan_e2e passes except checks that fail only after earlier checks and pass alone (in FEATURES.md LATER 4l).
2026-10-22 03:24:32 +02:00
Ludovic MarieandClaude Opus 5.5 9346ac04ad Mod browser (2/3): install, update and remove, with horizon-launcher mods
browser::installed installs a pack and its required dependencies into
resourcepacks/ or shaderpacks/, every file checked before any download
(a dependency that is a Java mod stops it) and checksum-verified on the
way in; mod-browser.json records source, version, file and checksum.
Update swaps the file, remove deletes only what the browser installed (a
player's own file of the same name is never overwritten). A modpack is
downloaded, checked, and imported through the Modpacks page's pipeline.
The CLI gains `mods list|search|install|update|remove`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:22:39 +02:00
Ludovic MarieandClaude Opus 5.5 784e8f0648 Mod browser (1/3): Modrinth and CurseForge sources, checksum-checked downloads
The launcher's core gains `browser`: search Modrinth for what Horizon loads
(resource packs, Iris/OptiFine shader packs, modpacks; never Java mod jars),
say how well each result runs, pick the newest version that runs, and
download a file only from Modrinth's or CurseForge's file servers, with a
plain file name, kept only when its size and SHA-512 (SHA-1 on CurseForge)
match. CurseForge is a pluggable source that stays off until an API key
exists (HORIZON_CURSEFORGE_API_KEY at run time or build time; the launcher
workflow passes the repository secret). docs/mod-browser.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:17:25 +02:00
Ludovic MarieandClaude Opus 5.5 49a224df9f Axolotl: free water paths, flat walk targets and the attack sound
Three differences from vanilla's Axolotl kept the brain from matching:

- The constructor's setPathfindingMalus(WATER, 0) was missing, so every
  swim target in water failed DefaultRandomPos's malus check and idle
  axolotls never picked a walk target.
- Axolotl.getWalkTargetValue returns 0 everywhere; Horizon used Animal's
  light-based value, which picked the brightest candidate instead of the
  first.
- Axolotl.playAttackSound (entity.axolotl.attack at pitch 1) never played.

The mob AI harness gets #64's two scenarios, in a pool sunk into the ground
so no water flows out of it: hunting a cod, and playing dead after repeated
hits. Both now match vanilla on every tick (393/393 mob-ticks, was 26).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:15:12 +02: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
Ludovic Marie 1fda49b9b0 Merge pull request #1119 from ludovic111/fix/boat-water-patch
🤖 Agent: merged. Verify passes. CI parity's failures are only the known flakes: world_spawn (vanilla gives several spawn heights for one seed across runs) and dedicated_server (play-pearl). This client rendering change can't move either. B2's integration passed every rendering harness, and before/after screenshots are in the body.
2026-10-22 03:03:27 +02:00
Ludovic Marie cb5355b3ff Merge pull request #1116 from ludovic111/fix/end-crystal-spin
🤖 Agent: merged. Verify passes. CI parity's failures are only the known flakes: world_spawn (vanilla gives several spawn heights for one seed across runs) and dedicated_server (play-pearl). This client rendering change can't move either. B2's integration passed every rendering harness, and before/after screenshots are in the body.
2026-10-22 03:03:23 +02:00
Ludovic MarieandClaude Opus 5.5 c4d6b34172 Other players' block cracks are drawn
On a server or a world open to LAN, a player saw only their own crack.
The server sent each new stage to the others
(ClientboundBlockDestruction), but a client of a server ignored the
packet, and the host of a LAN world neither drew its guests' cracks nor
sent its own to them.

Vanilla keeps the cracks per breaker (ClientLevel.destroyBlockProgress:
a progress outside 0..9 removes it, 400 ticks without news drops it) and
draws, within 32 blocks, the furthest one on each block
(LevelExtractor.extractBlockDestroyAnimation). Horizon now does the
same: a client of a server takes the packet, a LAN host records its
guests' stages and sends its own (each new stage, then -1 when it
stops), and every crack is drawn with its own stage. A guest's crack
also goes when the block it digs is gone before its STOP_DESTROY_BLOCK
(its own tick broke it), as ServerPlayerGameMode.tick clears it on air:
the others kept its last stage for 400 ticks. Each crack vertex carries
its stage sprite's atlas origin in its light bytes (the crumbling pass
is unlit); the shader keeps the shared sprite size.

lan_e2e `cracks`: each side watches the block the other mines and sees
its crack grow; no crack is left once the blocks break. The screenshots
from both sides show the cracks.

Fixes #1096

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 03:02:56 +02:00
Ludovic Marie 9ad152d2b8 Merge pull request #1130 from ludovic111/fix/website-semver-tags
🤖 Agent: merged. Website only; verify passes and the website tests pass (2/2). The site shows nothing about 0.2 until a 0.2 release exists.
2026-10-22 03:02:53 +02:00
Ludovic Marie 8c99b1099a Merge pull request #1126 from ludovic111/docs/versioning-0-2
🤖 Agent: merged. Docs only; verify and CI parity pass. The review's corrections are in (a56317f9).
2026-10-22 03:02:50 +02: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 Marie fd0473605b Merge pull request #1125 from ludovic111/fix/station-quick-move
🤖 Agent: merged. Verify passes. Local integration of #1121–#1125 on main 2d586fac: parity --changed, all 101 affected harnesses pass. Screenshots in the body.
2026-10-22 03:00:02 +02:00
Ludovic Marie 24ce07cb93 Merge pull request #1124 from ludovic111/fix/menu-style-test-race
🤖 Agent: merged. Verify passes. Local integration of #1121–#1125 on main 2d586fac: parity --changed, all 101 affected harnesses pass. Screenshots in the body.
2026-10-22 02:59:58 +02:00
Ludovic Marie 194bad0b08 Merge pull request #1123 from ludovic111/fix/book-title-edit-box
🤖 Agent: merged. Verify passes. Local integration of #1121–#1125 on main 2d586fac: parity --changed, all 101 affected harnesses pass. Screenshots in the body.
2026-10-22 02:59:54 +02:00
Ludovic Marie f0b2083ca8 Merge pull request #1122 from ludovic111/fix/classic-extras-pairs
🤖 Agent: merged. Verify passes. Local integration of #1121–#1125 on main 2d586fac: parity --changed, all 101 affected harnesses pass. Screenshots in the body.
2026-10-22 02:59:50 +02:00
Ludovic Marie d87fe66e3e Merge pull request #1121 from ludovic111/fix/session-lock-release
🤖 Agent: merged. Verify passes. Local integration of #1121–#1125 on main 2d586fac: parity --changed, all 101 affected harnesses pass. Screenshots in the body.
2026-10-22 02:59:46 +02:00
Ludovic MarieandClaude Opus 5.5 52eb549aec Particles: add 26.3's sulfur bubbles over potent sulfur
PotentSulfurBlock.animateTick sends two sulfur_bubbles up through a water
source above a wet potent sulfur block, and one time in ten plays
block.potent_sulfur.noxious_gas. SulfurBubbleParticle rises at -0.04
gravity with 0.85 friction, wiggles sideways, grows from 0.02 to 0.15 and
pops when it leaves a water source, stops rising or travels 3 blocks. Its
provider passes yAux as the Z speed, which is kept.

The particle providers harness gets water worlds for sulfur_bubbles,
bubble and bubble_column_up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 02:21:36 +02:00
Ludovic Marie afd9e820f5 Merge pull request #1127 from ludovic111/fix/server-update-semver-tags
🤖 Agent: merged. Verify and CI parity pass, and the independent review is OK: 12 update tests; v0.2.0.1 ranks below every 0.2 tag; a 0.2 install is never offered a dated tag.
2026-10-22 02:16:01 +02:00
Ludovic MarieandClaude Sonnet 5.5 0ee03671ba Website: the fallback releases link matches launcher@ and launcher-v tags
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 02:15:09 +02:00
Ludovic MarieandClaude Sonnet 5.5 a56317f943 docs/versioning.md: servers don't update themselves, a decision on v2026.10.21, bridge soaks first, irreversible list
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-22 02:15:07 +02:00
Ludovic MarieandClaude Opus 5.5 fde383c6e8 Website: read 0.2 tags (v0.2.0, [email protected]) as newer than the dated ones
From 0.2 the game is tagged vX.Y.Z and the launcher [email protected] (docs/versioning.md). The site
took only vYYYY.MM.DD as a game release and launcher-v as a launcher, so it would keep offering
launcher 0.16 and showing the last dated patch notes.

src/versions.js holds the tag test and order (every 0.2 tag above every tag from before, semver
within, a pre-release before its release), shared by build.mjs and main.js; the download button
picks the newest launcher by tag, the notes the newest stable game releases, and the launcher
lookup falls back to launcher@ tags before launcher-v. Nothing shows "0.2" until a 0.2 release
exists. `npm test` runs its tests (node --test).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 02:13:33 +02:00
Ludovic MarieandClaude Opus 5.5 385abe5f0c docs/versioning.md: the 0.2 numbering, the way over for old installs, the go-day steps
Ludovic decided (2026-10-21) that the next release is 0.2 for both the game and the launcher, with one
clear scheme instead of vYYYY.MM.DD(.N) and launcher-vX.Y.Z. This page is the design the other 0.2
pull requests implement:

- tags vX.Y.Z and [email protected] (launcher-v0.2.0 is taken), on a line in .github/release-line.txt;
  betas stay pre-releases under their final tag; semver pre-release tags reserved for previews;
- one order for every reader: each 0.2 tag above every tag from before;
- how release.yml, launcher.yml and promote.yml map onto it (HORIZON_VERSION, HORIZON_LAUNCHER_TAG);
- what launchers and servers from before 0.2 do with it, and the bridges that move them
  (launcher-v0.17.0, the last dated game release made latest before v0.2.0);
- the orchestrator's steps for the day Ludovic says go, freeze off included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 02:13:33 +02:00
Ludovic Marie fcfcd408a4 Merge pull request #1129 from ludovic111/feat/release-line
🤖 Agent: merged. Verify and CI parity pass, and an independent review found no blocking issue. The release, launcher and promote workflows stay disabled with RELEASES_PAUSED=1. Without .github/release-line.txt the train decides exactly as before (checked against every existing tag).
2026-10-22 02:12:49 +02:00
Ludovic Marie 65276e7ef6 Merge pull request #1128 from ludovic111/fix/launcher-semver-tags
🤖 Agent: merged. Verify and CI parity pass, and an independent review found no blocking issue. The release, launcher and promote workflows stay disabled with RELEASES_PAUSED=1. Without .github/release-line.txt the train decides exactly as before (checked against every existing tag).
2026-10-22 02:12:45 +02:00
Ludovic Marie 3ca2fc8c8b Merge pull request #1115 from ludovic111/fix/break-particle-sprite
🤖 Agent: merged. Verify passes. CI parity's only failure is world_spawn seed -2080727421395798566, where vanilla itself gives several heights across runs (also on main; see #1112). Screenshots in the body.
2026-10-22 02:03:42 +02:00
Ludovic Marie e935f5dc55 Merge pull request #1118 from ludovic111/fix/biome-fog-blend
🤖 Agent: merged. Verify and CI parity pass, with before/after screenshots in the body.
2026-10-22 02:03:21 +02:00
Ludovic Marie 323cc49198 Merge pull request #1120 from feeldev12/fix/benchmark-world-folder-race
🤖 Agent: merged. Thank you @feeldev12! Verify passes. A 2-line fix to benchmark tooling: the world folder is created before the marker is written, so slow devices no longer race the async world open.
2026-10-22 02:03:17 +02:00
Ludovic MarieandClaude Opus 5.5 3fdf210ccb Advancement index: measured on vanilla's tree
An ignored test reads vanilla's advancements and item tags from the
prepared resources (HORIZON_RESOURCES) and reports what an inventory
change and a recipe unlock test now. On the Mac mini (release):
inventory_changed has 1914 criteria, the index is built once in 0.75 ms
with the tree (off the tick), and a nine-stack inventory tests 67 of them
(found in 16 us); recipe_unlocked has 1739, an iron pickaxe tests 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:57:27 +02:00
Ludovic MarieandClaude Opus 5.5 cc84150f6c A player leaving a level loses its waypoints there
ServerWaypointManager.removePlayer disconnects every waypoint the player
was receiving: a player who goes to another level gets an untrack for each
player it tracked in the one it left. Horizon's per-level managers tick
with the players in their own level only, so the untrack had no connection
to go to and the player kept the old level's markers.

Each level's manager now also gets the connections of the players in the
other levels, for these untracks.

The player-targets and dimension-players harnesses compare the waypoints
as each step leaves them (the last packet for each waypoint): arriving in a
level, vanilla sends the same waypoint up to three times within the step,
and the packet by packet order is test_locator_bar_parity's. Both pass:
123 and 40 lines identical.

Part of #843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:57:27 +02:00
Ludovic MarieandClaude Opus 5.5 815625e547 Advancement triggers test only the criteria that can pass; the log writes on its own thread
Two main-thread costs from the hub tick (found on the companion branch with
HORIZON_SPIKES, commit 060c795e; its companion and metering parts stay there):

- Every inventory change tested all ~1,900 inventory_changed criteria, and
  every recipe unlocked (an iron ingot unlocks dozens) all ~1,300
  recipe_unlocked ones: 1.5-5 ms on such a tick. The tree now indexes
  inventory_changed criteria by the items that can pass them (tags resolved;
  the few without an item list are always tried) and recipe_unlocked
  criteria by recipe, so a change tests the 20-40 that could match. Same
  answers: a criterion with an item-limited predicate can only pass with one
  of those items held.
- Each log line was an unbuffered stderr write plus a latest.log write under
  a mutex on the calling thread; a full pipe or a slow disk held the tick.
  The logger now formats the line and keeps it for crash reports on the
  calling thread and hands the I/O to a "Log writer" thread; flush() waits
  for it (crash report, exit).

(cherry picked from commit 060c795e, companion parts left out)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:57:27 +02:00
Ludovic MarieandClaude Opus 5.5 369179f331 The locator bar has one waypoint manager per level
Vanilla keeps a ServerWaypointManager per level: a player in another level
is neither a transmitter nor a receiver there. Horizon passed every player
to one manager whatever their dimension, so a player who went to the
Nether stayed tracked at their Nether coordinates by the players left in
the Overworld (`track Bob block 0 100 0`), and nothing was re-tracked when
they came back.

Each level now has its own manager, ticked with the players in that level:
a player changing level leaves the old one (untracked for everyone there,
as ServerLevel.removePlayerImmediately) and joins the new one
(addDuringTeleport). The player-targets and dimension-players harnesses
now compare the waypoints too, instead of leaving them out.

Fixes #843

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:57:27 +02:00
Ludovic Marie 169be6374c hurtlog keeps a LAN guest's hurts across a respawn
A guest's body is rebuilt when it respawns, and its hurt and attack logs
went with the old one: the control socket's hurtlog lost the hurts that
killed it whenever the death and the respawn fell between two reads.
The logs now pass to the new body, and a dead guest's are read too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit df9fa5f018)
2026-10-22 01:57:26 +02:00
Ludovic Marie 33183952d5 Hurt and attack logs per player, read with hurtlog on the control socket
Every hurt a player takes (PlayerMotion::hurt_blocking, which all player
damage goes through) is kept: the damage type, the amount before blocking
and armour, what a raised blocking item stopped, the health and absorption
lost, the armour and armour toughness it met, the fire ticks, the health
left and, for a mob's blow, the knockback it gave. Every melee blow on a
mob is kept too: the damage after the attack strength
(getAttackStrengthScale), the strength, a critical hit, the mob, whether it
landed and killed. The last 64 of each are kept.

`hurtlog` on the control socket hands them out, per player (the host and
each LAN guest), and clears them: a harness can read what a fight did hit
by hit and compare it with vanilla's numbers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit fdfa952379)
2026-10-22 01:57:26 +02:00
Ludovic MarieandClaude Opus 5.5 d7701e7e26 Ominous item spawners are saved with their chunk
OminousItemSpawner.addAdditionalSaveData keeps its item and
spawn_item_after_ticks; Horizon saved neither, so an ominous trial
spawner's key or potion floating mid-wave vanished when the world closed
or its chunk unloaded. They are now written with the chunk's entities
(UUID and unmodelled fields kept), read back as live spawners whose tick
count starts again from 0 (Entity.tickCount isn't saved), and leave the
level with their chunk.

Fixes #462

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:57:25 +02:00
Ludovic MarieandClaude Opus 5.5 4f461b27ed Other players are drawn lying in their beds
A sleeping guest stood on top of its bed for everyone else: the server
sent its pose but never its bed (LivingEntity.SLEEPING_POS goes only to
the sleeper itself), Horizon's clients had no sleeping pose for other
players, and the host's own models of its guests ignored the bed. A
player paired with the sleeper later (the host walking up from afar)
saw the same.

The tracked state of a sleeping player (the host or a guest) now holds
its bed, so pairing and updates send it, and its removal sends the empty
position back; the clients and the host's guest models lie the player in
the bed as LivingEntityRenderer.setupRotations does for Pose.SLEEPING
(the villagers' sleeping pose, by the bed's facing).

lan_e2e `sleep`: the host draws the sleeping guest lying, the guest the
sleeping host, and neither once they are up.

Fixes #1045

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:57:25 +02:00
Ludovic MarieandClaude Opus 5.5 4b4b88b061 dragon: FightWho's doc names LAN and server players only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:57:24 +02:00
Ludovic Marie 1845cc73af fix: end crystals take their entity ids when the fight places them
Like the dragon, a spike's crystal gets its id from the entity counter as
EndCrystal's constructor does, instead of only once a client tracks it: a
player the level serves without a client can then aim at it. The fight's
control status lists each crystal's id and position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e32e5031c1)
2026-10-22 01:57:24 +02:00
Ludovic Marie ac06314bd9 fix: the dragon fights every player in the End, not only the window's
The dragon read the window's own player for everything: network players
(LAN guests, players on a server) were never targeted, strafed, charged,
knocked back, bitten, breathed on or hit by its fireballs.

- It sees the level's players (the window's and every guest in the level)
  through `TargetingConditions.forCombat` as each phase asks
  (`Level.getNearestPlayer`): alive, neither creative nor spectator, none on
  Peaceful. The holding pattern strafes the enemy nearest the egg, the
  landing approach comes in opposite it, the perched dragon scans within 20
  blocks (10 up or down) and charges within 150.
- The strafe keeps its own target (`attackTarget`) and goes back to the
  holding pattern when it leaves the level.
- A destroyed crystal sends a holding dragon after the player who destroyed
  it, else the nearest player (`EnderDragon.onCrystalDestroyed`), when it can
  attack them.
- Wing knockback and the head and neck bites, the breath and fireball clouds
  and its fireballs act on every player alike; what hits a guest runs as
  that guest, its knockback sent to its client.
- The dragon takes its id and its parts' when it is created, so a guest can
  attack them in a world without network players too.

A unit test covers the targeting rules (spectators and creative players
passed over, ranges, Peaceful, the nearest to the egg).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 2eef4e4bf5)
2026-10-22 01:57:24 +02:00
Ludovic Marie 02ba66b721 /summon reports the entities that take their id when first tracked
Primed TNT (and falling blocks, projectiles) spawned from /summon get
their entity id only when first tracked, so the summon found no new id
and answered "Unable to summon entity" though the entity was there and
went off. The summon now also counts those lists, and names the type:
"Summoned new Primed TNT", as vanilla.

Test: a TNT summon in the command execution corpus (world group:
125/126 match vanilla; the one left, /waypoint list, was there before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit dd733684bc)
2026-10-22 01:57:23 +02:00
Ludovic Marie f16c0318b7 fix: a new world knows its world spawn before its first save
The world spawn was only filled in when the world was first saved, from
the player's spawn point at that moment: a bed slept in before then became
the world spawn, and anything asking for the world spawn earlier (a
guest's respawn) fell back on the host's spawn point instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 03e621ed3a)
2026-10-22 01:57:23 +02:00
Ludovic Marie 2adfd75193 Loot tables apply set_potion
SetPotionFunction was skipped, so the potions a loot table gives came
out with no contents: a piglin's barter never held fire resistance (the
drinkable and splash ones, 16 of 469), only uncraftable potions. The
merchant and /loot code had it; the shared loot evaluator now does too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1b2767122d)
2026-10-22 01:57:23 +02:00
Ludovic Marie f5688883ad fix: dragon damage follows vanilla (perched arrows, cooldown, explosions)
- A perched dragon (scanning, attacking, flaming, hovering) shrugs arrows
  off as `AbstractDragonSittingPhase.onHurt` does: no damage, and the arrow
  is set alight for a second and turns back instead of being spent. Arrows
  that hit a part or a crystal deal the arrow's damage with its Power bonus,
  and turn back when the hit is refused (a dying dragon, an invulnerable
  crystal).
- The damage cooldown is `LivingEntity.hurtServer`'s: within it a stronger
  hit lands its excess over the last one instead of being dropped; the hurt
  sound plays only for a new hit.
- Explosions hit as `ServerExplosion.hurtEntities` does: the dragon itself
  (its body) and then each part, each by its distance and its exposure
  (`getSeenPercent`, so cover protects it), through the cooldown; and every
  end crystal in reach blows up, not only the first.

`Dragon::hurt` and `Dragon::explosion_hits` are pure, with unit tests for
the perched arrows, the cooldown's excess, the parts' quarter plus one,
the take-off after a quarter of its health, and exposure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit c730ed9ed8)
2026-10-22 01:57:23 +02:00
Ludovic Marie f2f3fb6ede fix: the ender dragon fight runs on server levels
On a dedicated server (and single player's server thread) the End is ticked
with no local player, and `tick_fight` returned before spawning the dragon:
players in the End found crystals but never a dragon.

- The fight finds its players as `EnderDragonFight.updatePlayers` does, every
  20 ticks: every player alive within 192 blocks of (0, 128, 0), the host and
  the network players in the level alike. Scanning the state, the respawn
  sequence, `createNewDragon` and the crystal count run while it has one;
  the dragon and crystals tick whenever the End does.
- The dragon is tracked to network players (`minecraft:ender_dragon`, its
  health and `DATA_PHASE` as vanilla pairs them, no attribute instances) and
  reserves its eight part ids after its own, as `EnderDragon`'s constructor
  does, so clients can attack the parts; a network player's attack on a part
  or an end crystal is aimed at it.

tools/test_server_dragon_parity.py sends a lone vanilla-codec client to the
End of vanilla's dedicated server and of Horizon's: the fight starts and the
dragon is tracked with the same spawn data, synched data entries and
attributes. VanillaProtocolClient's entity mode answers a scene's teleport
with the acceptance alone, as the 26.3 client does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 618db927d9)
2026-10-22 01:57:23 +02: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
Ludovic MarieandClaude Opus 5.5 30cdd6298a Version sources: a release build says its version (0.2.3), not 0.1.0 or a folder name
release.yml builds every game release with HORIZON_VERSION, the tag without its v
(docs/versioning.md), as launcher.yml already does for the launcher: no version-bump commits.

- horizon_core::release::{RELEASE, VERSION}: the built-in version, or Cargo.toml's for a build from
  source. (horizon_core::version stays Minecraft's, generated by gen_version_consts.py.)
- horizon-server and horizon-prepare --version, the server's /api/v1/status, horizon-ctl's MCP
  serverInfo and the "Horizon/<version>" user agents use it; they all said 0.1.0.
- The client's crash reports and telemetry say v0.2.3 from the built-in version wherever the game
  is installed; releases from before 0.2 (no version built in) still read the launcher's folder name.
- The launcher's benchmark telemetry used Cargo.toml's version instead of the release's.
- release.yml checks that horizon-prepare and horizon-server --version say the release's version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:20:13 +02:00
Ludovic Marie b31cb42242 Merge pull request #1111 from ludovic111/fix/occlusion-camera-above-world
🤖 Agent: merged. Verify passes. Local integration of #1109–#1112 on main ae21bd54: parity --changed 142/142. After the rebase onto #1094: 101/101, with screenshots in the body.
2026-10-22 01:15:59 +02:00
Ludovic Marie 7672c3d86c Merge pull request #1112 from ludovic111/fix/loading-screen-square
🤖 Agent: merged. Verify passes. Local integration of #1109–#1112 on main ae21bd54: parity --changed 142/142. CI parity's world_spawn diff (seed -2080727421395798566) also happens on main: vanilla itself gives y64/65/66/72 across runs. The other failure is the known dedicated_server pearl flake.
2026-10-22 01:15:55 +02:00
Ludovic Marie ec257d331e Merge pull request #1110 from ludovic111/fix/item-merge-falling-landing
🤖 Agent: merged. Verify passes. Local integration of #1109–#1112 on main ae21bd54: parity --changed 142/142, with before/after item counts in the body.
2026-10-22 01:15:50 +02:00
Ludovic MarieandClaude Opus 5.5 cfd4ac33bf Station shift-clicks follow vanilla's quickMoveStack; the brewing stand runs on the menu engine
The station screens' hand-written quick move put a payment stack's first item
into the beacon, fell back to the other inventory half when an enchanting,
grindstone, stonecutter, cartography or anvil rule's own slots were full, and
sent every input back hotbar-last-first. Follow each menu's quickMoveStack
(EnchantmentMenu, BeaconMenu, GrindstoneMenu, StonecutterMenu,
CartographyTableMenu, ItemCombinerMenu with SmithingMenu's
canMoveIntoInputSlots), repeated as doClick's QUICK_MOVE does.

A local brewing stand's clicks now run on the verified menu engine, as a
server's already did, so its shift-clicks, drags (quick craft) and double
clicks are BrewingStandMenu's, which test_menu_click_parity covers.

Part of #275

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:12:43 +02:00
Ludovic MarieandClaude Opus 5.5 aaf6384751 Tests: the menu style is per test thread, so parallel tests can't flip it
The menu style is one process-wide flag, and options tests switch it to lay
out a page in Horizon's or Classic's style. Running in parallel, one test's
switch moved another's widgets (classic_graphics_presets_set_the_distances
failed 4 of 200 runs of the options tests). Under cfg(test) the flag is a
thread local, Classic first; the game keeps the shared atomic.

Fixes #802

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:12:43 +02:00
Ludovic MarieandClaude Opus 5.5 cb20d7056c Book title: draw Select All's highlight, as BookSignScreen's EditBox
The title box is an unbordered, centred EditBox (black, no shadow) in
vanilla. Horizon kept Ctrl/Cmd+A's selection but never drew it, and centred
the title together with its cursor. Draw it with ui::append_field (now with
a text shadow flag): the value centred alone, the cursor after it, and the
inverted highlight. horizon-ctl gets the super (Cmd) and F6 key names, and
A/C/V/X with the edit modifier held act on the open book.

Part of #275

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:12:42 +02:00
Ludovic MarieandClaude Opus 5.5 7e82252f12 Extras: classic switches two to a row; settings nest under their switch
Classic Extras laid every setting out as a full-width button. Pair each
section's buttons as vanilla's OptionsList.addSmall does (the same layout as
Video Settings), and shorten "Lower sensitivity while zoomed" to "Slower
turning" so it fits a half-width button.

Horizon's Extras page shows a switch's own settings (Zoom key, Minimap size,
FPS corner...) only while that switch is on, so it stays one row per extra
as the list grows. Classic keeps every button, as vanilla screens do.

Fixes #812
Fixes #815

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:12:42 +02:00
Ludovic MarieandClaude Opus 5.5 782d93bf99 Release the session lock before closing it, as DirectoryLock.close does
A world's session.lock is an flock held by the open file. Closing the file
releases it only once every copy of the descriptor is gone, and a process
spawned on another thread holds a copy until it execs. Under a parallel
cargo test (crash reports spawn uname/sw_vers), deleting a world right after
dropping its lock could then fail with "already open". Unlock explicitly
in Drop, as vanilla releases the FileLock before closing the channel.

Fixes #749

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 01:12:42 +02:00
Ludovic Marie e1d39b9787 Merge pull request #1109 from ludovic111/tools/chunk-harness-postprocess
🤖 Agent: merged. Verify and CI parity pass (chunk harness tooling).
2026-10-22 01:03:10 +02:00
Ludovic MarieandClaude Opus 5.5 082cc278af Audio: pan like OpenAL Soft's stereo output, not at full gain on both sides
A centred mono source played at full gain on each channel; OpenAL Soft
(vanilla's LWJGL build, HRTF off) renders stereo pairwise: 0.596 per side
ahead, 0.404 behind, 0.5 above or below. Explosions were 4.5 dB louder than
vanilla. Pan with the camera's forward and up vectors, play a mono output
unpanned, and release the limiter over 0.2 s like OpenAL Soft's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:52:08 +02:00
Ludovic MarieandClaude Opus 5.5 9a3237c5ac Release train: a release line (0.2) for vX.Y.Z and [email protected] tags
docs/versioning.md: with .github/release-line.txt (MAJOR.MINOR) the train tags game releases
vMAJOR.MINOR.PATCH and launcher releases [email protected], the patch after the newest
release on the line; the line's first release goes out at once (still not while RELEASES_PAUSED is
set). Without the file nothing changes: vYYYY.MM.DD[.N] and launcher-vX.Y.Z. The file is not added
here; the switch to 0.2 is a separate one-line pull request for the day Ludovic says go.

- tag_key: every 0.2 tag ranks above every dated or launcher-v tag; semver order within, a
  pre-release before its release. release_channels.py uses the same key and tag test, so promotion
  and "latest only moves forward" work across the switch.
- A line's first release says so in its notes; 0.2.0's explains the numbering and how a server
  that says it is up to date takes it (horizon-server update --version v0.2.0).
- launcher.yml strips either prefix for HORIZON_LAUNCHER_VERSION and passes HORIZON_LAUNCHER_TAG,
  which the launcher uses to know its own line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:47:35 +02:00
Ludovic MarieandClaude Opus 5.5 0d747f1a86 Launcher: 0.2 tags (v0.2.0, [email protected]) follow every date and launcher-v tag
From 0.2 the game is tagged vX.Y.Z and the launcher [email protected] (docs/versioning.md). The launcher
compared every tag number by number, so it would never offer game v0.2.0 over v2026.10.19.15 nor
update itself to 0.2.0 from 0.16.0 (launcher-v0.2.0 is also taken by an old release).

- release_order(): tags from before 0.2 (vYYYY.MM.DD[.N], launcher-vX.Y.Z, bare versions) compare as
  before; every 0.2 tag follows them, in semver's order (a pre-release before its release).
- own_tag(): the tag this launcher was released as. launcher.yml will pass HORIZON_LAUNCHER_TAG; a
  release with only HORIZON_LAUNCHER_VERSION was a launcher-v one; a build from source is on 0.2.
  So the last launcher-v release (the bridge old launchers can see) updates itself to launcher@.
- The matching-refs fallback looks for launcher@ tags first, then launcher-v.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:38:37 +02:00
Ludovic MarieandClaude Opus 5.5 0dd31c0757 horizon-server update: semver tags (v0.2.0) follow every date tag
From 0.2 Horizon's game releases are tagged vMAJOR.MINOR.PATCH (docs/versioning.md). newer() compared
tags number by number, so v0.2.0 looked older than v2026.10.19.15 and an installed server would never
offer or install it. Every semver tag now outranks every date tag; within semver, patch and minor
compare as numbers and a pre-release (v0.3.0-rc.1) comes before its release. Pruning old releases
sorts with the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 00:29:34 +02:00
Ludovic Marie 2d586fac52 Merge pull request #1107 from ludovic111/fix/beehive-break-releases-bees
🤖 Agent: merged. Verify passes. Local integration of #1101–#1107 on main caeffffb: parity --changed 302/304, and the two failures (block_particles, entity_model) fail identically on plain main. CI parity's secure_chat failure on #1101 logged 'transcripts identical' (flaky).
2026-10-22 00:17:45 +02:00
Ludovic Marie ef1142ab49 Merge pull request #1101 from ludovic111/fix/explosion-knockback-resistance
🤖 Agent: merged. Verify passes. Local integration of #1101–#1107 on main caeffffb: parity --changed 302/304, and the two failures (block_particles, entity_model) fail identically on plain main. CI parity's secure_chat failure on #1101 logged 'transcripts identical' (flaky).
2026-10-22 00:17:41 +02:00
feeldev c833fbe212 fix(benchmark): create the world folder before writing its marker
`open` replies once the world starts opening on a worker, so the save
folder may not exist yet when the benchmark writes horizon-benchmark.txt
into it. On a slower device (an Android phone) the write failed with
"No such file or directory" and the run ended before the first scene.
2026-10-21 17:04:34 -05:00
Ludovic MarieandClaude Opus 5.5 4eaa0251e2 Cave culling: see the world from above or below its height
With the camera above the build height (or below the bottom), vanilla's
SectionOcclusionGraph.initializeQueueForFullUpdate seeds the search with
the top (bottom) layer of sections around the camera instead of the
camera's own section. Horizon started from the camera's section and could
only step one section past the world's edge, so from y 352 up (two
sections above the top) it reached nothing and every chunk vanished.

Fixes #1037

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 23:09:57 +02:00
Ludovic Marie b7390d783b Merge pull request #1106 from ludovic111/fix/attack-knockback
🤖 Agent: merged. Verify passes; CI parity passes except dedicated_server, which fails the same way on main (play-pearl teleported 0 vs 1, the known flake in FEATURES.md LATER).
2026-10-21 23:02:44 +02:00
Ludovic Marie aac5ff91e7 Merge pull request #1104 from ludovic111/fix/elytra-hop
🤖 Agent: merged. Verify passes; CI parity passes except dedicated_server, which fails the same way on main (play-pearl teleported 0 vs 1, the known flake in FEATURES.md LATER).
2026-10-21 23:02:40 +02:00
Ludovic Marie 7f4de08a9a Merge pull request #1102 from ludovic111/fix/no-use-while-using-item
🤖 Agent: merged. Verify passes; CI parity passes except dedicated_server, which fails the same way on main (play-pearl teleported 0 vs 1, the known flake in FEATURES.md LATER).
2026-10-21 23:02:37 +02:00
Ludovic Marie b8261b3270 Merge pull request #1105 from ludovic111/fix/attribute-swapping
🤖 Agent: merged. Verify and CI parity pass.
2026-10-21 23:02:07 +02:00
Ludovic Marie 81042a8155 Merge pull request #1103 from ludovic111/fix/elytra-fall-distance
🤖 Agent: merged. Verify and CI parity pass.
2026-10-21 23:02:03 +02:00
Ludovic MarieandClaude Opus 5.5 1cca55b667 Draw and collide blocks while a piston moves them
Moving blocks are minecraft:moving_piston blocks with a block entity.
The client drew nothing for them and gave them no collision, so pistons
on a clock flickered and players walked through them (#935).

The world now keeps a copy of the level's moving-piston block entities.
SceneCollisions::moving_piston_shape returns their shape
(PistonMovingBlockEntity.getCollisionShape, already in piston_shape),
and game/moving_pistons.rs ports PistonHeadRenderer: the moved block at
its interpolated offset, the moving head's short state, and a retracting
piston's head and extended base.

Entity pushing (moveCollidedEntities) is not ported yet.

Fixes #935

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:34:13 +02:00
Ludovic MarieandClaude Opus 5.5 e3cea08736 Boats: the water patch hides water inside the hull; the riding camera turns smoothly
BoatRenderer draws its water_patch part with RenderTypes.waterMask (depth
only) unless the boat is under water, so translucent water behind it fails
the depth test inside the hull. Horizon loaded the part but never drew it:
the water surface showed through the boat. The patch now draws with a
depth-only Water mask pipeline after the entities and before translucent
terrain (rafts have none, as RaftRenderer).

Turning in a boat stepped the camera at the tick rate: positionRider adds
the boat's deltaRotation to the passenger's yRot after its yRotO was kept,
and LocalPlayer.getViewYRot interpolates a passenger's yaw between them.
The camera now spreads the turn over the tick's frames.

Fixes #1016

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:33:08 +02:00
Ludovic MarieandClaude Opus 5.5 7404c2b94e Sky, fog and water fog colours blend across biomes (GaussianSampler)
The camera's biome alone gave the sky, fog and water fog colours, so they
jumped at every biome boundary (most visibly the Nether fog). Vanilla's
EnvironmentAttributeProbe weights the noise biomes of a 6x6x6 quart
neighbourhood around the camera with GaussianSampler's kernel and lerps
the spatially interpolated attributes between them
(SpatialAttributeInterpolator, ARGB.srgbLerp); unloaded chunks count as
plains.

Fixes #797

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:33:08 +02:00
Ludovic MarieandClaude Opus 5.5 08fdafc4fc Thrown potions: drawn as their item, facing the camera (ThrownItemRenderer)
A thrown splash or lingering potion or experience bottle was a stand-in
terrain particle spawned at its tick position: darkened by the terrain
particle colour, without the potion's colour, two overlapping copies and
no interpolation between ticks. Draw it as vanilla's ThrownItemRenderer
does: the item model (GROUND, with the potion tint) facing the camera,
interpolated, lit at its light probe, hidden for its first two ticks near
the camera (ThrowableProjectile.shouldRenderAtSqrDistance). Witch potions
draw as the splash potion of their effect instead of a bare bottle.

Fixes #818

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:33:07 +02:00
Ludovic MarieandClaude Opus 5.5 621b791a33 End crystals spin and bob in every dimension
EndCrystal.tick advances the crystal's time wherever it is; only the fire
at its block needs a dragon fight. Horizon advanced it only during the End
fight, so a crystal placed elsewhere kept a fixed time and jumped back
every tick (it flickered instead of turning).

Fixes #1051

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:33:07 +02:00
Ludovic MarieandClaude Opus 5.5 38b16f44cb Terrain particles: the model's particle sprite and vanilla tint sources
Breaking, mining, landing and other block particles took the block's up
face and a fixed grass or foliage colour, so grass blocks crumbled as
tinted grass tops (moss-like). Vanilla's TerrainParticle uses the model's
particle material (first variant / first multipart selector) and tint
source 0's colorAsTerrainParticle: the biome-blended in-world colour,
none for grass blocks (BlockTintSources.grassBlock) or blocks without a
source. The in-wall screen overlay and block markers use the particle
material too.

Fixes #1034

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 22:33:07 +02:00
Ludovic Marie 252f877f45 Merge pull request #1100 from ludovic111/fix/agent-e2e-wait-loaded
🤖 Agent: merged. Test-only fix (agent_control_e2e waits for the world to load); verify and CI parity pass.
2026-10-21 22:02:46 +02:00
Ludovic MarieandClaude Opus 5.5 8b0872f901 Elytra: a landing glide lasts one more tick, so hops keep their speed
The glide flag is the server's: LivingEntity.updateFallFlying clears it when
canGlide fails (the player is on the ground), from the position the client
sent, and the client gets the change on its next tick. So in vanilla a glide
that lands goes on for one tick on the ground, with travelFallFlying's drag
instead of ground friction, and a jump on that tick (with its sprint boost)
keeps the speed: the elytra hop. Horizon ended the glide on the landing tick,
so each hop lost about half of the speed to ground friction.

Fixes #1056

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:06:54 +02:00
Ludovic MarieandClaude Opus 5.5 d32fd5457a Beehives: nests grown in the live level get their block entity and bees
BeehiveDecorator stores 2 or 3 bees in the nest's block entity. World
generation kept them, but trees placed in the live level (a sapling grown next
to flowers, which picks the *_bees_005 trees, and /place feature) dropped them:
LevelFeatureWorld had no store_beehive_occupants, so the nest had no block
entity at all. Breaking it released nothing and bees could not enter it.

LevelAccess gets load_block_entity, the game loads the occupants into the
nest's block entity, and the occupant NBT is shared with world generation.

Fixes #1097

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:06:32 +02:00
Ludovic MarieandClaude Opus 5.5 9d9f259f94 Combat: sprint and yaw knockback (Player.causeExtraKnockback); vanilla knockback on the player
Player.attack adds causeExtraKnockback after the hurt: the weapon's knockback
enchantment (halved) plus 0.5 for a sprinting full-strength hit, pushed along
the attacker's yaw (sin, -cos) rather than away from it, which lets a player
steer a hit target by turning while attacking (knockback displacement).
Horizon had only the enchantment part, on mobs; sprinting hits added nothing
and players (LAN PvP) got no extra knockback at all. Now:
- player hits on mobs and players: one extra knockback of that strength with
  LivingEntity.knockback (knockback resistance, lift only from the ground),
  the attack.knockback sound, and the attacker's sprint stops;
- the knockback a player takes (mob and PvP hits, dealDefaultKnockback with
  0.4) reads its knockback resistance (netherite) and only lifts from the
  ground: an airborne player kept being thrown up by every hit.

Fixes #504

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:06:32 +02:00
Ludovic MarieandClaude Opus 5.5 c01739300a Combat: attack damage and speed come from last tick's main hand (attribute swapping)
The player's ATTACK_DAMAGE and ATTACK_SPEED modifiers change in
LivingEntity.detectEquipmentUpdates, once a tick, while Player.attack reads
the weapon's enchantments and smash bonus from the stack in hand at the
attack. So swapping hotbar slots just before an attack hits with the old
item's damage and charge and the new item's enchantments (a Wind Burst or
Breach mace after another weapon): attribute swapping. Horizon read both from
the item in hand at the attack.

Also Player.tick's reset: switching to a different item now resets the attack
strength the damage uses (only the indicator's ticker reset before), for the
host and for LAN guests.

Fixes #505

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:06:32 +02:00
Ludovic MarieandClaude Opus 5.5 9d96bb6ec7 Explosions: knockback reads explosion_knockback_resistance; armor enchantment modifiers stack per slot
Two Blast Protection pieces now cancel explosion knockback (#1049):
- ServerExplosion.hurtEntities scales knockback by EXPLOSION_KNOCKBACK_RESISTANCE
  for living entities. The player's knockback ignored it, and mobs used
  KNOCKBACK_RESISTANCE (iron golems were never pushed; vanilla pushes them,
  every type's explosion resistance base is 0).
- EnchantmentAttributeEffect.idForSlot: the modifier id ends with the slot,
  so the same enchantment on two armor pieces adds up. The id was
  <id>.<level>, so the second piece replaced the first.
- hitPlayers skips creative players who are flying: no knockback for them.

Fixes #1049

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:06:31 +02:00
Ludovic MarieandClaude Opus 5.5 2c47e79cc7 Use: no block placing or item use while an item is in use
Minecraft.handleKeybinds only calls startUseItem while the player is not
using an item: use clicks are consumed and held use doesn't repeat. Horizon
repeated held use every 4 ticks regardless, so with a shield raised in the off
hand, aiming at a block placed the main hand's block (or spawn egg, boat...).

Fixes #1050

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:06:31 +02:00
Ludovic MarieandClaude Opus 5.5 705be141d7 Elytra: a glide that isn't diving keeps the fall distance at 1
LivingEntity.updateFallFlying starts with Entity.checkFallDistanceAccumulation:
while gliding and falling slower than 0.5 blocks a tick, the fall distance is
capped at 1. Horizon kept accumulating it for the whole glide, so landing after
gliding down (or after climbing and gliding back) dealt the full drop's damage.

Fixes #1046

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 21:06:31 +02:00
Ludovic Marie caeffffb94 Merge pull request #1099 from ludovic111/fix/lan-e2e-xp-host-back
🤖 Agent: merged. Test-only fix to tools/lan_e2e.py (root cause in the body); verify and CI parity pass.
2026-10-21 21:02:45 +02:00
Ludovic Marie a0c3d531c2 Merge pull request #1094 from ludovic111/fix/cave-culling-vanilla-rules
🤖 Agent: merged. Verify and CI parity pass. The body has the frame-time table and screenshot masks against no-culling for the visibility fix.
2026-10-21 21:02:36 +02:00
Ludovic Marie 4d22eb0fbf Merge pull request #1084 from ludovic111/fix/weather-frame-cost
🤖 Agent: merged. Verify and CI parity pass on the rebased head (b591c9dc), with before/after frame times in the body (rain 561 → 759 FPS, snow 663 → 981 on the Mac mini).
2026-10-21 21:02:30 +02:00
Ludovic MarieandClaude Opus 5.5 1bd1d57fb3 agent_control_e2e survival: a tree whose log is hidden is skipped
Low leaves can hide every face of the trunk's bottom log from where the
walk stops ("no face of block ... is visible from here"). The scenario
now breaks inside the tree loop and moves on to the next tree, as it
already did for one it cannot reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:55:20 +02:00
Ludovic MarieandClaude Opus 5.5 5f0e924221 agent_control_e2e: wait for the world, and start below a tree's crown
- survival and ui asserted that the Create New World click itself
  answered `playing`. Since 7f4193f8 the in-frame world is prepared off
  the UI thread behind the title screen, so the click answers `title`
  and both scenarios failed in the frame (main's default) every time.
  They now wait for `loaded`, as the other scenarios do.
- The survival spawn is random within the spawn radius, on the first
  full top face below the MOTION_BLOCKING height
  (PlayerSpawnFinder.getLevelRespawnPos), so a tree's leaves too: from
  there no tree was reachable within goto's three-block drops. The
  scenario now drops the player to the ground under the leaves first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:46:21 +02:00
Ludovic MarieandClaude Opus 5.5 32a48282cc Loading screen: fill the chunk map square by square, as vanilla
Vanilla's spawn chunks load by ticket level, so the level loading screen's
chunk map fills in Chebyshev rings. While the loading screen is up,
Horizon now picks chunks ring by ring (nearest first inside a ring)
instead of by Euclidean distance, which grew a disc.

Fixes #770

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:31:43 +02:00
Ludovic MarieandClaude Opus 5.5 5c3b05938f lan_e2e: put the host back after the xp check
check_xp makes the host a spectator, out of the orbs' way, and left it
one for the rest of the run. A spectator sinks (Player.travel keeps 0.6
of its vertical speed each tick, about 0.2 blocks from standing), so the
next check that made it survival again, swings, landed it inside the
floor with the guest teleported beside it: the guest's feet were in the
floor's stone, which hid the dirt it tills from every angle ("no face of
block ... is visible from here"). It failed only after the earlier
checks, never alone.

check_xp now ends in survival where the host stood, as check_trials
already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:19:33 +02:00
Ludovic MarieandClaude Opus 5.5 b4100ed752 Items merge with their neighbours; falling blocks land in their own tick
ItemEntity.tick merges a dropped stack with mergeable items whose boxes
meet its own grown by half a block sideways, every 2 ticks while it moves
between blocks and every 40 otherwise (mergeWithNeighbours, tryToMerge,
merge). Horizon never merged, so fresh chunks, where water and falling
blocks break leaf litter, moss and plants, filled up with single items.

FallingBlockEntity.tick also places (or breaks) the block within the
entity's own tick. Horizon moved every falling block first and placed them
after, so a block falling right behind another landed in the same cell and
broke into an item: stacked sand and gravel in fresh chunks dropped.

Fixes #996

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 20:01:42 +02:00
Ludovic MarieandClaude Opus 5.5 e725e5257f Chunk generation harness: compare postprocessing marks too
The oracle prints every ProtoChunk postprocessing position and chunk_dump
prints Horizon's, so a chunk that schedules different fluid or shape
updates after generation (the blocks that later tick and drop items in
fresh chunks, #996) fails the harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:39:37 +02:00
Ludovic Marie ae21bd5464 Merge pull request #1095 from ludovic111/fix/hub-default-game-mode
🤖 Agent: merged. Verify passes. The change runs only on the server thread (opt-in today), and the PR body's proof shows lan_e2e --server-thread going from 35/39 to 38/39. #1072's switch needs it.
2026-10-21 19:38:28 +02:00
Ludovic Marie 297843939c Merge pull request #1093 from ludovic111/fix/prepare-finds-jdks
🤖 Agent: merged after integration C: the desktop ran 322/325 harnesses on main + #1088–#1093, and its 3 failures were the known dedicated_server flake plus the two display harnesses; world_reopen and save_roundtrip pass on the Mac mini (main + the six branches).
2026-10-21 19:37:48 +02:00
Ludovic Marie e6842705b0 Merge pull request #1090 from ludovic111/fix/missingno-warning
🤖 Agent: merged after integration C: the desktop ran 322/325 harnesses on main + #1088–#1093, and its 3 failures were the known dedicated_server flake plus the two display harnesses; world_reopen and save_roundtrip pass on the Mac mini (main + the six branches).
2026-10-21 19:37:44 +02:00
Ludovic Marie c6dbb61759 Merge pull request #1089 from ludovic111/fix/video-cost-labels
🤖 Agent: merged after integration C: the desktop ran 322/325 harnesses on main + #1088–#1093, and its 3 failures were the known dedicated_server flake plus the two display harnesses; world_reopen and save_roundtrip pass on the Mac mini (main + the six branches).
2026-10-21 19:37:41 +02:00
Ludovic Marie b58f1b156f Merge pull request #1088 from ludovic111/fix/tick-server-thread
🤖 Agent: merged after integration C: the desktop ran 322/325 harnesses on main + #1088–#1093, and its 3 failures were the known dedicated_server flake plus the two display harnesses; world_reopen and save_roundtrip pass on the Mac mini (main + the six branches).
2026-10-21 19:37:36 +02:00
Ludovic Marie e4fb401c3c Merge pull request #1092 from ludovic111/fix/prepare-unifont
🤖 Agent: merged after integration C: the desktop ran 322/325 harnesses on main + #1088–#1093, and its 3 failures were the known dedicated_server flake plus the two display harnesses; world_reopen and save_roundtrip pass on the Mac mini (main + the six branches).
2026-10-21 19:37:33 +02:00
Ludovic Marie 7d512ed829 Merge pull request #1091 from ludovic111/fix/republished-asset-index
🤖 Agent: merged after integration C: the desktop ran 322/325 harnesses on main + #1088–#1093, and its 3 failures were the known dedicated_server flake plus the two display harnesses; world_reopen and save_roundtrip pass on the Mac mini (main + the six branches).
2026-10-21 19:37:21 +02:00
Ludovic MarieandClaude Opus 5.5 6f6354c2c0 Cave culling: vanilla's graph rules (every entry face, distant ray test)
SectionOcclusionGraph.runUpdates, both rules Horizon left out:
- a section leads on through whatever any face it was entered through
  connects to (Node.sourceDirections), not only the first one;
- beyond 3 sections from the camera, a step is taken only if the line
  from the section's corner towards the camera, in 28-block steps while
  farther than 60 blocks, stays in sections the search has reached.

The search takes the camera position for the ray test. Exact equivalent
of vanilla's, faster: one ray per axis per node (both steps along an
axis share the corner; while the camera stays in the searched-from
section no point falls in a neighbour the loop adds), an answer per
section corner during a full search (neighbours share corners; reached
sections are only added, so a line that stayed in them still does, and
one that failed is checked again from the sample it failed at, stepping
over the earlier ones with the same additions), and an integer floor
instead of libm's on x86-64. Tests compare every cell with a literal
port of runUpdates (map of nodes, FIFO, JOML arithmetic), and a resumed
line with one checked from its corner.

Fixes #782

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:00:46 +02:00
Ludovic MarieandClaude Opus 5.5 b591c9dc64 Weather: rain heights from a cached MOTION_BLOCKING heightmap, no per-column allocations
WeatherEffectRenderer reads level.getHeight(MOTION_BLOCKING, x, z) for
every column within the weather radius. Horizon scanned each column block
by block from 10 blocks above the radius down to the ground, with a
string search per block, every frame: about half a millisecond of CPU per
frame in rain on a Ryzen, more on laptops (#899). Client chunks now keep
a MOTION_BLOCKING map next to their WORLD_SURFACE one, built on the first
read and dropped by writes that can move it, from the facts world
generation already uses (blocks_motion_in_heightmap or a fluid).

This is also vanilla's rule where the scan differed: slabs, stairs and
other motion-blocking blocks without a full collision box now stop rain,
and a roof more than 20 blocks above the camera does too.

Tiling a column at each texture repeat no longer allocates two vectors
per column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:57:30 +02:00
Ludovic Marie e66c35b2ef Merge pull request #390 from ludovic111/feat/version-port-agent
🤖 Agent: merged (version-port tooling, rebased on main). Ludovic asked on 10-21 to clear the PR queue.
2026-10-21 18:53:08 +02:00
Ludovic Marie dacdab490e Merge pull request #389 from ludovic111/feat/version-port-publish
🤖 Agent: merged (version-port tooling, rebased on main). Ludovic asked on 10-21 to clear the PR queue.
2026-10-21 18:52:42 +02:00
Ludovic Marie 90b675a882 Merge pull request #388 from ludovic111/feat/version-port-harnesses
🤖 Agent: merged (version-port tooling, rebased on main). Ludovic asked on 10-21 to clear the PR queue.
2026-10-21 18:52:16 +02:00
Ludovic Marie ac4a63fd22 Merge pull request #359 from ludovic111/feat/version-port-regen
🤖 Agent: merged (version-port tooling, rebased on main). Ludovic asked on 10-21 to clear the PR queue.
2026-10-21 18:51:15 +02:00
Ludovic Marie a96bfb39fd Merge pull request #357 from ludovic111/feat/tools-follow-lock
🤖 Agent: merged (version-port tooling, rebased on main). Ludovic asked on 10-21 to clear the PR queue.
2026-10-21 18:50:46 +02:00
Ludovic MarieandClaude Opus 5.5 87d5cd27c1 Server thread: the owner's commands run with the hub in place
owner_console_command ran the window's commands as the world's owner with
the hub taken out, so a selector saw only the owner (`team join … @a` left
the guests out, and friendly fire still hurt) and `defaultgamemode` never
reached the hub (guests joined in the world's old mode). It now puts the
hub back while the command runs, as run_guest_commands does for a guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:32:41 +02:00
Ludovic Marie 7e0b202374 Merge pull request #1087 from ludovic111/fix/backdrop-worker
🤖 Agent: merged. Title backdrop built on a worker (fixes #896). Integration proof (main + #1079 #1081 #1082 #1083 #1085 #1086 #1087), 2026-10-21: desktop parity 103/105; the 2 others (world_reopen, save_roundtrip) need a display and pass on the Mac mini. Verify passed.
2026-10-21 18:05:47 +02:00
Ludovic Marie d235e760c3 Merge pull request #1086 from ludovic111/fix/creative-middle-click
🤖 Agent: merged. Creative pick block in inventories (fixes #1048, Visivel). Integration proof (main + #1079 #1081 #1082 #1083 #1085 #1086 #1087), 2026-10-21: desktop parity 103/105; the 2 others (world_reopen, save_roundtrip) need a display and pass on the Mac mini. Verify passed.
2026-10-21 18:05:41 +02:00
Ludovic Marie 3f0e3230d2 Merge pull request #1083 from ludovic111/fix/firework-tooltips
🤖 Agent: merged. Firework tooltips (fixes #1047, Visivel). Integration proof (main + #1079 #1081 #1082 #1083 #1085 #1086 #1087), 2026-10-21: desktop parity 103/105; the 2 others (world_reopen, save_roundtrip) need a display and pass on the Mac mini. Verify passed.
2026-10-21 18:05:36 +02:00
Ludovic Marie 0f22b49f74 Merge pull request #1082 from ludovic111/fix/cobweb-falling-blocks
🤖 Agent: merged. Cobwebs slow falling blocks (fixes #933). Integration proof (main + #1079 #1081 #1082 #1083 #1085 #1086 #1087), 2026-10-21: desktop parity 103/105; the 2 others (world_reopen, save_roundtrip) need a display and pass on the Mac mini. Verify passed.
2026-10-21 18:05:31 +02:00
Ludovic Marie b1d15653b2 Merge pull request #1079 from ludovic111/fix/night-sky-stars-cost
🤖 Agent: merged. Stars built once (fixes #1054, Visivel and rdbtCVS). Integration proof (main + #1079 #1081 #1082 #1083 #1085 #1086 #1087), 2026-10-21: desktop parity 103/105; the 2 others (world_reopen, save_roundtrip) need a display and pass on the Mac mini. Verify passed.
2026-10-21 18:05:26 +02:00
Ludovic Marie 29b3aff942 Merge pull request #1085 from ludovic111/fix/place-blocked-by-entities
🤖 Agent: merged. Blocks can't be placed into entities (fixes #1052, Visivel). Integration proof (main + #1079 #1081 #1082 #1083 #1085 #1086 #1087), 2026-10-21: desktop parity 103/105; the 2 others (world_reopen, save_roundtrip) need a display and pass on the Mac mini. Verify passed.
2026-10-21 18:05:21 +02:00
Ludovic Marie db4eb06784 Merge pull request #1081 from ludovic111/fix/shears-carve-pumpkin
🤖 Agent: merged. Shears carve pumpkins (fixes #1067, reported by Tydragon). Integration proof (main + #1079 #1081 #1082 #1083 #1085 #1086 #1087), 2026-10-21: desktop parity 103/105; the 2 others (world_reopen, save_roundtrip) need a display and pass on the Mac mini. Verify passed.
2026-10-21 18:05:02 +02:00
Ludovic MarieandClaude Opus 5.5 57cc113be4 Client logs: no warning for minecraft:missingno; say when no sounds loaded
- sprites: vanilla models name the missing texture on purpose (block/air.json's
  particle is minecraft:missingno), so using it is not worth a warning (#853).
- audio: when the resources hold no sounds.json, the game plays nothing on a
  working output and the log said only which device opened. It now says the
  sounds were not copied and how to fix it (#1035, #1076).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:37:45 +02:00
Ludovic MarieandClaude Opus 5.5 1fdc003d52 Prepare: find installed JDKs; launcher shows the preparation error up top (#666)
A player installed Java 25 but JAVA_HOME still named an older JDK, so
Prepare stopped with "Java 25 is needed", and the launcher showed that
error at the foot of the page, out of sight until the window was enlarged.

- horizon-prepare java::find: after JAVA_HOME, the PATH and macOS's
  java_home, look in the folders JDK installers use (Program Files vendors
  on Windows, /usr/lib/jvm on Linux, JavaVirtualMachines on macOS, ~/.jdks,
  SDKMAN), JDKs named for the pinned major first.
- launcher: the preparation's progress and error sit right under the
  buttons instead of below the rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:37:44 +02:00
Ludovic MarieandClaude Opus 5.5 b5c7c5789a Prepare: import the Unifont glyphs from the asset index (#1076)
The client JAR ships an empty font/include/unifont.json; the real provider
list and font/unifont.zip, unifont_jp.zip and unifont_pua.zip (1.9 MB) come
only from the asset index, which preparation never copied. Every character
the bitmap fonts lack showed as a missing-glyph box (a server's "▸").

Full preparation now imports them after extracting the JAR, like the title
panorama, and `horizon-prepare fonts` repairs prepared resources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:34:05 +02:00
Ludovic MarieandClaude Opus 5.5 3ac4d26cd0 Prepare: accept Mojang's republished 26.3 asset indexes (sounds, panorama)
Mojang republished the 26.3 version JSON again (sha1 61fff131), with a new
asset index 34 (0c8f43b2) that changes only 142 translation files. A launcher
keeps one indexes/34.json, so every install made since then, official
launcher or Prism, failed the "pinned asset index" check: no sounds and no
title panorama.

- research-lock.json: accept the new version JSON, and list the asset
  indexes of every accepted version JSON (accepted_asset_index_sha1).
- horizon-prepare: an index the lock does not know is still used, with a
  line in the log, since every object copied from it is checked against its
  own SHA-1; the sounds now require minecraft/sounds.json in the index.
- tools/lab.py: the same for the dev tools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:32:00 +02:00
Ludovic MarieandClaude Opus 5.5 a197adaed0 Video settings: cost labels from measured frame times, and "Saves time" for cave culling
Measured on an Intel HD 620 (#897): rain and snow cost about 3 ms of GPU
per frame there, most of it from the weather radius; FXAA about 0.8 ms;
cave culling saves about 0.6 ms; mipmaps cost nothing measurable and
clouds about 0.1 ms. A new Cost::Saves tag ("Saves time", in the success
colour) marks a setting that makes frames faster when on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:19:43 +02:00
Ludovic MarieandClaude Opus 5.5 be8b2d54fc /tick on a server thread: sprint, query, step and a smooth frozen camera
The integrated and dedicated server loops paced every tick at the tick rate,
so /tick sprint ran at 20 tps and never reported, and the integrated server
recorded no tick times for /tick query. They now share
MinecraftServer.runServer's pacing: no wait while sprinting, the sprint's
report sent to the operators as the server's, and tick times recorded.

The hub broadcasts ClientboundTickingStepPacket when a step starts or stops
(ServerTickRateManager.updateStepTicks) and sends a joining player the
steps left, not 0.

The camera, hand, view bobbing and the player's own model use
Camera.getCameraEntityPartialTicks (getGameTimeDeltaPartialTick(true)), so
they stay smooth while the level is frozen instead of moving at 20 Hz.

Fixes #1038

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 17:07:10 +02:00
Ludovic MarieandClaude Opus 5.5 0a1959077c Title backdrop: build its generator on a worker
start_backdrop built VanillaTerrain on the event loop, so the title stopped
drawing for the whole build: 2.1 s cold and 0.86 s warm on an M4 Pro (fast
profile), far longer on slow CPUs. The worker now builds it and finds the
spot; the title draws its panorama until the generator is installed.

Fixes #896

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 16:53:54 +02:00
Ludovic MarieandClaude Opus 5.5 403fead15d Blocks can't be placed into mobs and other building blockers (Level.isUnobstructed)
BlockItem.canPlace now checks every entity whose blocksBuilding is set: mobs,
armor stands (not markers), boats, minecarts, end crystals, primed TNT and
falling blocks, not only players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 16:41:17 +02:00
Ludovic Marie d128289ca6 Merge pull request #1006 from ludovic111/perf/server-helpers-behind
🤖 Agent: merged. Linux dedicated servers lower their world-generation and packet-writer threads behind the server thread; nothing changes elsewhere. verify passed; parity: every harness passes except dedicated_server, which is flaky on main with the same failure modes (7-run A/B against main in the last comment).
2026-10-21 16:34:41 +02:00
Ludovic Marie fd9eb84bea Merge pull request #356 from ludovic111/feat/version-consts
🤖 Agent: merged (version-port tooling, rebased on main). Ludovic asked on 10-21 to clear the PR queue.
2026-10-21 16:16:03 +02:00
Ludovic MarieandClaude Opus 5.5 f18f6d58ac Creative pick block in inventories: full stacks and drags (AbstractContainerScreen cloning)
The middle button (keyPickItem) over an inventory or the creative screen no
longer clicks like the left button: with infinite materials it clones the
hovered stack at full size (CLONE) or, holding a stack, drags full stacks into
slots (quick craft type 2); otherwise it does nothing. Agents can middle-click
widgets (click ID middle), and creative slots are ui widgets now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 16:14:41 +02:00
Ludovic MarieandClaude Opus 5.5 8f13b2be8c Firework tooltips: flight duration and explosions (Fireworks.addToTooltip)
Rockets list their flight duration and each run of equal explosions (shape,
colours, fade colours, trail, twinkle); firework stars list their explosion.
Both honour TooltipDisplay. New harness: tools/test_firework_tooltip_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 16:14:41 +02:00
Ludovic MarieandClaude Opus 5.5 8a7ebee269 Cobwebs hold falling blocks (WebBlock.entityInside)
A falling block's move now runs vanilla's checkInsideBlocks along its path: a
cobweb it passes or lands in makes it stuck (0.25, 0.05, 0.25), which scales
its next move and zeroes its velocity, and resets its fall distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 16:14:41 +02:00
Ludovic MarieandClaude Opus 5.5 8186baba8b Shears carve pumpkins (PumpkinBlock.useItemOn)
Right-clicking a pumpkin with shears now carves it like vanilla: a carved
pumpkin facing the clicked side (the side facing the player on the top and
bottom), four pumpkin seeds from carve/pumpkin popped out of that side, the
carve sound, one point of wear, the shear game event and the used
statistic. A pumpkin carved on a golem base builds the golem, as
CarvedPumpkinBlock.onPlace does; the dispenser shares that placement.

use_item_on parity: pumpkin cases on any face with the player turned any
way (the oracle and fixture take an optional yaw and face).

Fixes #1067

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 16:14:41 +02:00
Ludovic Marie 85f65a69b7 Merge pull request #1021 from ludovic111/feat/mod-vanilla-tags
🤖 Agent: merged. Verify passed. Integration proof (main + #1041 #1043 #1021 #1020 #1032 #659 #1029 #1066), 2026-10-21: 97 of 100 harnesses passed on the desktop. world_reopen and save_roundtrip need a display there; they pass on the Mac mini with #1041/#1043. dedicated_server is a pre-existing flake: it fails on plain main too, and is pass/fail in 2 runs on the integration.
2026-10-21 15:38:16 +02:00
Ludovic Marie edcf8b26f3 Merge pull request #1032 from ludovic111/feat/rs-waterlog-when-replacing-water
🤖 Agent: merged. Verify passed. Integration proof (main + #1041 #1043 #1021 #1020 #1032 #659 #1029 #1066), 2026-10-21: 97 of 100 harnesses passed on the desktop. world_reopen and save_roundtrip need a display there; they pass on the Mac mini with #1041/#1043. dedicated_server is a pre-existing flake: it fails on plain main too, and is pass/fail in 2 runs on the integration.
2026-10-21 15:38:11 +02:00
Ludovic Marie a69baf681b Merge pull request #659 from ludovic111/perf/collision-scan
🤖 Agent: merged. Verify passed. Integration proof (main + #1041 #1043 #1021 #1020 #1032 #659 #1029 #1066), 2026-10-21: 97 of 100 harnesses passed on the desktop. world_reopen and save_roundtrip need a display there; they pass on the Mac mini with #1041/#1043. dedicated_server is a pre-existing flake: it fails on plain main too, and is pass/fail in 2 runs on the integration.
2026-10-21 15:38:06 +02:00
Ludovic Marie 921a9f4588 Merge pull request #1043 from rdbtCVS/t3/async-world-save-exit
🤖 Agent: merged, thanks @rdbtCVS! World saves are spread over frames and keep the session lock until every write is durable (with my small clippy fix). Integration proof (main + #1041 #1043 #1021 #1020 #1032 #659 #1029 #1066), 2026-10-21: 97 of 100 harnesses passed on the desktop. world_reopen and save_roundtrip need a display there; they pass on the Mac mini with #1041/#1043. dedicated_server is a pre-existing flake: it fails on plain main too, and is pass/fail in 2 runs on the integration.
2026-10-21 15:38:01 +02:00
Ludovic Marie 914dbf0402 Merge pull request #1041 from rdbtCVS/t3/move-world-init-off-ui
🤖 Agent: merged, thanks @rdbtCVS! Local worlds are prepared on a worker; the loading screen keeps drawing. Integration proof (main + #1041 #1043 #1021 #1020 #1032 #659 #1029 #1066), 2026-10-21: 97 of 100 harnesses passed on the desktop. world_reopen and save_roundtrip need a display there; they pass on the Mac mini with #1041/#1043. dedicated_server is a pre-existing flake: it fails on plain main too, and is pass/fail in 2 runs on the integration.
2026-10-21 15:37:56 +02:00
Ludovic Marie bc8904213d Merge pull request #1078 from ludovic111/docs/java-perf-mods-survey
🤖 Agent: merged (docs only: the Java perf-mod survey). Fixes #991.
2026-10-21 15:05:46 +02:00
Ludovic Marie c104ceff4a Merge pull request #1077 from ItsPotatoTime/fix/hud-legacy-formatting
🤖 Agent: merged, thanks @ItsPotatoTime! The action bar, title, subtitle and boss bar names now resolve legacy § codes, like the sidebar since #1003 and like vanilla's StringDecomposer.
2026-10-21 15:05:36 +02:00
Ludovic Marie 870d2f4297 Merge pull request #1020 from ludovic111/feat/close-off-air-sources
🤖 Agent: merged. Verify and CI parity passed; local proof on main + the four modpacks PRs is in the body.
2026-10-21 15:05:14 +02:00
Ludovic Marie 1bcf90def5 Merge pull request #1014 from ludovic111/feat/verify-signed-updates
🤖 Agent: merged. Every merge condition holds: the latest stable game release (v2026.10.19.15) and launcher release (launcher-v0.16.0) verify against the release key, legacy_releases.py --check passes, and launcher and horizon-server built from this branch install signed, pinned-legacy and beta releases and refuse an edited SHA256SUMS, an altered archive and a missing signature. verify passed; parity proof in the last comment.
2026-10-21 14:47:43 +02:00
Ludovic MarieandClaude Opus 5.5 1a89c52173 docs: survey of the Java performance mods' techniques for Horizon
Lithium, FerriteCore, ImmediatelyFast, Gnetum, ScalableLux, Structure
Layout Optimizer, AsyncParticles and Ixeris: per technique, the vanilla
cost it targets, whether Horizon already avoids it, and whether it is
worth measuring. From public descriptions only; no mod code read.

Fixes #991

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 14:46:03 +02:00
Ludovic Marie 8ee29c7844 Merge pull request #213 from ludovic111/feat/macos-signing
🤖 Agent: merged inert. Without the MACOS_SIGN_IDENTITY variable (not set: Ludovic's Developer ID and notary setup don't exist yet), every new signing/notarizing step is skipped and the release, launcher, sign and promote pipelines are unchanged; verify and parity passed on this head, a launcher dry run passed with signing skipped, and today's ad hoc launcher bundle passes the new self-update signature check. Setting it up is in FEATURES.md LATER (4b).
2026-10-21 14:28:32 +02:00
Ludovic Marie 3ceb8e4307 Merge pull request #1029 from ludovic111/perf/lod-worker-inputs
🤖 Agent: merged. Verify and CI parity (`parity / parity`) passed; before/after numbers are in the body.
2026-10-21 14:03:30 +02:00
itspotatotimeandcairn-code 41c4caac25 fix(hud): apply legacy formatting codes to action bar and titles
Servers send § codes inside a component's text. Vanilla's font applies them to every literal, but the action bar, title, subtitle and boss bar names drew the component raw, so the codes showed as glyphs. Resolve them before measuring and drawing, as the sidebar already does.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-21 14:48:01 +03:00
Ludovic Marie 02bd3cbc72 Merge pull request #1075 from ItsPotatoTime/fix/washed-title-screen-colors
🤖 Agent: merged, thanks @ItsPotatoTime! Prefers an 8-bit non-sRGB surface (Bgra8Unorm/Rgba8Unorm) instead of RADV's fp16 ExtendedSrgb one, so colours are no longer washed out on Wayland. Mac and Windows already got Bgra8Unorm. Verify passed; parity doesn't cover surface formats.
2026-10-21 13:32:03 +02:00
Ludovic MarieandClaude Opus 5.5 50a5e64012 Stars: build the star buffer once and draw it with the sky's rotation
SkyRenderer.buildStars builds the 1500 star quads once into a static
buffer; Horizon regenerated them on the CPU and uploaded about 200 KB of
vertices every frame while stars were out, which made nights cost more
than days (#1054). The stars now live in a static vertex buffer built
when the renderer starts, drawn after the sun and moon with one small
uniform: the view projection times the sky's rotation, and the star
brightness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 13:14:39 +02:00
itspotatotimeandcairn-code 6bb6009133 fix(gfx): keep the swapchain off the linear float format
Rgba16Float is the first non-sRGB format RADV advertises on Wayland, and wgpu gives it the linear ExtendedSrgb color space. The renderer writes gamma-encoded values, so the compositor read them as linear light and washed every colour out.

Prefer Bgra8Unorm or Rgba8Unorm and keep the old choice as the fallback. A screenshot on a surface that is not 32-bit now reports an error instead of copying with an 8-bit row stride, which aborted wgpu validation.

Co-Authored-By: cairn-code (DeepSeek V4.1 Flash) <[email protected]>
2026-10-21 13:46:55 +03:00
Ludovic Marie 7756660d6e Merge pull request #1066 from ludovic111/docs/servers-page-released
🤖 Agent: merged. Docs only (README and website/docs): the Servers page is in launcher 0.15+, and releases go from beta to stable. Verify passed; parity doesn't cover docs. This also redeploys playhorizon.dev, which still showed the 10-19 14:29 build.
2026-10-21 12:37:48 +02:00
Ludovic MarieandClaude Opus 5.5 d8eafd7dc0 version-port-daily.sh: no fleet-load priority change (it lowered the calling session); launchd's Nice and LowPriorityIO do it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 70e058142c version_port.py publish: the report's work items as GitHub issues, a dry run unless --yes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 c82d3f25c4 docs/version-ports.md: the harness stage replayed both ways
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 b3bc3c0768 docs/version-ports.md: regen, its replay, status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 94b5d2dbc4 tools/ci: the daily version-port agent (detect, port, harnesses, report, publish dry run), not installed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 fec2e20f72 port_harness: horizon-prepare's javac errors from the log it names (also when classifying again)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 3b5eabaf5f regen: command_parse_fixture exports Horizon's registration order; javac errors summarized
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 1cd72f1ba7 port_harness: horizon-prepare writes the data generator's reports into the candidate, not through regen's link to the version store
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 938e6b1191 version_port.py regen: a candidate worktree pinned to the new version, every table regenerated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 285593e531 docs/version-ports.md: the candidate harness run, status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 ec0725aeb0 version_port.py harnesses: horizon-prepare and every harness in the candidate, newly failing ones in the report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:21 +02:00
Ludovic MarieandClaude Opus 5.5 9ff74a58a1 lan_e2e.py: the prepared resources of the pinned version (lab)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:20 +02:00
Ludovic MarieandClaude Opus 5.5 ef8b045d79 Tests main added since: prepared resources under the generated version
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:20 +02:00
Ludovic MarieandClaude Opus 5.5 eee7786609 Generated version facts through lab.version_info; options and world harnesses take the data version from the jar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:20 +02:00
Ludovic MarieandClaude Opus 5.5 605b9f8511 datafix: the version name through horizon_core::version, in the harness's Rust scope
horizon_core::VANILLA_VERSION is a re-export, which left the datafix harness's
PARITY_RUST_SCOPE open: its six shards reran on any Rust change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:20 +02:00
Ludovic MarieandClaude Opus 5.5 ce87bfb8c7 Tools read the pinned Minecraft version from research-lock.json
lab.VERSION, lab.GAME (.local/minecraft/<version>) and lab.RESOURCES
(.local/horizon-client/<version>) come from the lock, and lab.version_info()
reads the pinned client jar's version.json. Every harness, generator and
tool that named 26.3 (resource folders, jar and source paths, result
records, the vanilla client's --version and asset index, the bare login
client's protocol, the benchmark's options.txt data version, the packet id
table's header) uses them, so a candidate worktree pinned to another version
runs them unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:20 +02:00
Ludovic MarieandClaude Opus 5.5 326f69d326 lan_bot: the status's protocol is the generated constant
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:20 +02:00
Ludovic MarieandClaude Opus 5.5 9f385280ef Version facts in one generated file: crates/horizon-core/src/version.rs
tools/gen_version_consts.py writes what the pinned client jar's version.json
states (name, id, protocol, data version, pack formats, Java, stable, build
time) into horizon_core::version, and every Rust site reads it from there:
PROTOCOL_VERSION, GAME_VERSION, DATA_VERSION, the save's and the datafixer's
version name, /version, the crash report, the legacy ping, the dedicated
server's start line, the title screens, the default resource folder, the
resource pack format, and the launcher's and horizon-ctl's fallbacks. CI
checks the file against the pinned jar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:22:20 +02:00
Ludovic MarieandClaude Opus 5.5 b928dc3c9b Merge origin/main into perf/server-helpers-behind
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:20:38 +02:00
Ludovic MarieandClaude Opus 5.5 846f681b9a Signed updates: releases from before signing stay installable with pinned files; tests need a release key
- crates/horizon-release-sign/legacy-releases.txt pins the SHA-256 of every archive of the 131 releases published
  before signing (GitHub's upload digests, written by tools/ci/legacy_releases.py). The launcher and
  horizon-server update install those releases only with exactly those files, so rolling back to one works and
  nobody can swap its files later; every other release needs its signed SHA256SUMS.
- the_launcher_ships_with_a_release_key and horizon_server_ships_with_a_release_key fail while
  release-keys.txt lists no key, so this can't ship refusing everything.
- Test tags moved to v2099.* so they never collide with a pinned release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:20:36 +02:00
Ludovic MarieandClaude Opus 5.5 36908858a3 Launcher and horizon-server update: install only releases whose signed checksums match
The launcher (game installs and its own updates) and `horizon-server update` fetch a release's
SHA256SUMS and SHA256SUMS.sig, check the signature against the keys built in from
crates/horizon-release-sign/release-keys.txt and the archive against the signed SHA-256, and
refuse anything unsigned, signed for another tag or by another key, or that doesn't match, with a
message saying why. Merge only once release-keys.txt lists the key and a signed stable release
exists: until then every download is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:20:36 +02:00
Ludovic MarieandClaude Opus 5.5 3ffbb1cb9f Sign and notarize the macOS builds with Developer ID
With the repository variable MACOS_SIGN_IDENTITY set, the launcher's Horizon.app and .dmg and the
game's three programs are signed with the hardened runtime and a timestamp, notarized and (app,
dmg) stapled, from a keychain of the Mac mini's own that keychain.sh unlocks with its password from
1Password. Unset, everything is signed ad hoc as before. The launcher checks an updated bundle's
signature before and after swapping it in, and puts the old one back when it doesn't hold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:20:34 +02:00
Ludovic Marie 4d2e05dda7 Merge pull request #1074 from ludovic111/fix/input-capture-fixture
🤖 Agent: merged on verify. Test-only (a Python test fixture and a temp-folder name inside a #[test] fn), so no harness can see it; it turns main's verify green again after #235 and removes a horizon-server test flake.
2026-10-21 12:20:25 +02:00
Ludovic MarieandClaude Opus 5.5 386cb5203a horizon-server tests: settings and config no longer share a temp folder
a_stopped_server_saves_and_keeps_comments (settings.rs) and
writes_properties_and_syncs_back (config.rs) both used
horizon-server-config-<pid>, so when they ran at the same time one
removed or rewrote the other's horizon.toml and
writes_properties_and_syncs_back failed now and then. The settings test
now has its own folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:11:23 +02:00
Ludovic MarieandClaude Opus 5.5 b67b3c23ed tests: the input capture fixture models #235's cursor recentring
#235 made Game::capture read the surface size, warp the pointer to the
window's centre and set the HUD cursor, so the fixture's copy of the method
no longer compiled and verify failed on main. The fixture now stubs
winit's PhysicalPosition/PhysicalSize, Window::inner_size and
set_cursor_position, HudBatch and Game's renderer and cursor, and checks
the recentring: one warp on grab, two on release, the HUD cursor at the
centre, and nothing when releasing an already released pointer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 12:01:11 +02:00
Ludovic MarieandClaude Opus 5.5 f137a8d3cc Merge origin/main into perf/server-helpers-behind
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:52:33 +02:00
Ludovic Marie 1225f89e57 Merge pull request #235 from ludovic111/fix/cursor-centre-on-screen-open
🤖 Agent: merged. Recentres the cursor like vanilla's MouseHandler.grabMouse/releaseMouse. CI verify and parity passed; Ludovic asked on 10-21 to clear the PR queue autonomously. If the cursor misbehaves on a Wayland compositor, file it and we'll follow up.
2026-10-21 11:40:44 +02:00
Ludovic Marie 34443128ba Merge pull request #1061 from rdbtCVS/t3/fix-issue-1058
🤖 Agent: merged, fixes #1058. Options we can't support yet stay greyed out in Classic UI with an explanation. Thanks @rdbtCVS!
2026-10-21 11:35:04 +02:00
Ludovic Marie a717ff012a Merge pull request #1044 from Yvonne-Aizawa/feat/mod-storage-capability
🤖 Agent: merged. Reviewed the storage host: keys are one [a-z0-9_.-] segment, ids are [a-z0-9_], and the quota, key cap and per-call write budget all hold. Thanks @Yvonne-Aizawa!
2026-10-21 11:34:36 +02:00
Ludovic Marie 30740f902f Merge pull request #1065 from rdbtCVS/fix/pr-ready-validation-scope
🤖 Agent: merged. pr_ready now checks the touched crates and their dependents instead of the whole workspace; CI keeps its workspace-wide checks. The earlier verify failure was an unrelated supervisor test. Thanks @rdbtCVS!
2026-10-21 11:34:07 +02:00
Ludovic Marie 04abab60ab Merge pull request #1042 from rdbtCVS/t3/optimize-startup-handoff 2026-10-21 11:30:59 +02:00
Ludovic MarieandClaude Opus 5.5 ab9e0cdea4 Use if let for the integrated server stop request
Clippy's unnecessary_unwrap rejected the is_some check followed by unwrap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:30:52 +02:00
Ludovic Marie a55da8d210 Merge pull request #1062 from rdbtCVS/t3/fix-issue-1057
🤖 Agent: merged. Follows ClientLevel.getMarkerParticleTarget with a 432-case vanilla harness; fixes #1057. Thanks @rdbtCVS!
2026-10-21 11:29:52 +02:00
Ludovic Marie 9ee99c632a Merge pull request #1063 from rdbtCVS/t3/add-launcher-menu-scroll
🤖 Agent: merged, the launcher's long pages scroll properly now. Thanks @rdbtCVS!
2026-10-21 11:29:07 +02:00
Ludovic Marie 9f114e1de8 Merge pull request #1064 from rdbtCVS/t3/fix-settings-subheadings
🤖 Agent: merged, thanks @rdbtCVS!
2026-10-21 11:28:38 +02:00
Ludovic Marie 71826576e8 Merge pull request #1059 from rdbtCVS/fix/spectator-item-drop-1053
🤖 Agent: merged. Matches Minecraft.handleKeybinds (no drops in spectator) with a 128-snapshot vanilla harness. Thanks @rdbtCVS!
2026-10-21 11:28:09 +02:00
Ludovic Marie 04854e96b5 Merge pull request #1036 from rdbtCVS/fix/1033-creative-hunger
🤖 Agent: merged. Follows Player.causeFoodExhaustion and hurtServer for invulnerable abilities; fixes #1033. Thanks @rdbtCVS!
2026-10-21 11:27:42 +02:00
Ludovic Marie 8e360e377c Merge pull request #1039 from rdbtCVS/t3/reset-spectator-fly-speed
🤖 Agent: merged. Matches vanilla's mode switch (flying speed back to 0.05 when leaving spectator). Thanks @rdbtCVS!
2026-10-21 11:27:13 +02:00
Ludovic MarieandClaude Opus 5.5 0d9c964711 Docs: the Servers page is released (launcher 0.15), and releases go beta → stable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:05:13 +02:00
Ludovic Marie 5513361783 Merge pull request #1027 from ludovic111/fix/carry-vine-template
🤖 Agent: merged after verify and CI parity passed (modpacks-11: Chipped's 11 vines carried as vines; the 11 blockstate errors are gone from the game log).
2026-10-21 11:02:04 +02:00
rdbtCVS a7941a8eca Scope PR validation to affected crates and avoid unrelated example builds 2026-10-21 00:56:37 +01:00
rdbtCVS 91b5256fc2 Add pointer-operated scrollbars throughout the launcher 2026-10-21 00:24:17 +01:00
rdbtCVS 800fbc757e Fix classic video search section headings 2026-10-21 00:20:52 +01:00
rdbtCVS 01fcfc9095 Match vanilla accessibility controls and hide unavailable Horizon options 2026-10-20 16:39:58 +01:00
rdbtCVS cadc20cd58 Fix creative barrier and light block marker particles 2026-10-20 16:30:34 +01:00
rdbtCVS 2469b92e4f Restore missing accessibility settings and disable unfinished features 2026-10-20 16:29:50 +01:00
rdbtCVS a29d293e8e fix: prevent spectators from dropping hotbar items 2026-10-20 16:20:54 +01:00
yvonneandClaude Sonnet 5 c3f23d9452 Mods: close three storage gaps Codex's review found
- Reject a second running mod with an id already in use: two mods
  sharing an id would also share capability storage's data_dir,
  letting one read, overwrite or delete the other's state.
- Cap storage to 64 keys per mod, not just 4 MiB: zero- or near-zero-
  byte values could otherwise flood the directory with files the
  byte quota never sees.
- Add a 1 MiB per-call write budget, separate from the 4 MiB on-disk
  quota: a loop replacing one key stays under the quota on every
  check (the total on disk never grows) while still doing unbounded
  synchronous disk I/O per call, which fuel never meters.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-19 19:56:12 +02:00
yvonne 8d55af7cae Mods: capability storage, a quota'd per-mod key/value folder
A mod with capability storage gets get(key)/set(key, value)/delete(key),
backed by real files beside horizon-mods/ (.horizon-mod-data/<id>/, 4 MiB
quota per mod). Keys are a single path segment so they can't leave the
mod's own directory. Unlike in-memory mod state, this survives a
cargo-horizon-mod dev reload, since it's keyed by the mod's path and id
rather than its Store.
2026-10-19 19:43:29 +02:00
rdbtCVS d5d3f5b3c7 Prepare world save snapshots incrementally and poll durable completion 2026-10-19 18:32:40 +01:00
rdbtCVS 4ffb073b59 Prepare startup handoff CPU resources on the loader worker 2026-10-19 18:31:52 +01:00
rdbtCVS 7f4193f88e Move local world initialization off the UI thread 2026-10-19 18:26:38 +01:00
rdbtCVS 20579bf638 fix: reset spectator fly speed on game mode changes 2026-10-19 17:55:13 +01:00
rdbtCVS 8d8d8ab221 Fix creative and spectator hunger exhaustion and starvation 2026-10-19 17:39:53 +01:00
Ludovic MarieandClaude Opus 5.5 0eda0b0aaa docs/modpacks.md: Repurposed's waterlogging fix is carried
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:34:07 +02:00
Ludovic MarieandClaude Opus 5.5 032446fb68 Structures: Repurposed Structures' waterlogging fix, as horizon:waterlog_in_world_water
WaterlogWhenReplacingWaterProcessor (16 lists: the bottom pillars of its
villages and outposts) waterlogs a block that can be where the world holds
water, makes it dry where it does not, and gives a block placed in water a
block tick. Carry used to drop it; it is now the game's
horizon:waterlog_in_world_water, in the chunk being generated as the mod's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:30:52 +02:00
Ludovic MarieandClaude Opus 5.5 9adeb682cb docs/modpacks.md: Repurposed's air fix is carried
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:18:43 +02:00
Ludovic MarieandClaude Opus 5.5 4459ed9dd5 Structures: Repurposed Structures' air fix, as horizon:close_off_air_sources
CloseOffAirSourcesProcessor walls off the air and the other liquids beside
a template's fluids, so its ocean mineshafts keep their water in. The game
runs it as horizon:close_off_air_sources (the same weighted blocks and the
mod's random, neighbours written in their sections as the mod does), and
carry maps the mod's 4 uses to it. The weighted block list is shared with
the fluid fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:18:31 +02:00
Ludovic MarieandClaude Opus 5.5 72515b99a7 Content mods: shared tags merge like a data pack's, replace honoured with a warning
A mod's file for a vanilla, convention or other mod's tag adds its values in
load order; one with "replace": true now drops vanilla's entries and the
earlier mods' first, as a higher data pack does, and the log says which mod
replaced what (kept with the cached root, so a reused root warns too).
Carry keeps such tags instead of setting them aside: Towns and Towers'
seven minecraft:has_structure biome tags load. Mods still own no other
minecraft content.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:18:12 +02:00
Ludovic MarieandClaude Opus 5.5 6b5694d2b7 port-pack: blocks with sides and up that are true or false are vines, not walls
Without the oracle, a block's template is guessed from its blockstate's
property names, and a vine's (east, north, south, up, west) fit
cobblestone_wall. The game then refused Chipped's 11 vine blockstates:
a wall's sides are none, low or tall, so 'true' is an unknown multipart
property value. block_template looks at the sides' values and makes those
vines; Luminax's 32 walls stay walls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:11:16 +02:00
Ludovic Marie c719d215ef Merge remote-tracking branch 'origin/main' into integ-659 2026-10-19 15:51:09 +02:00
Ludovic MarieandClaude Opus 5.5 2193559cb1 Far terrain: chunk summaries and mesh inputs on the worker
The render thread summarized every observed chunk (256 columns, seven
light reads per block) and gathered each mesh job's inputs (neighbour
edges, visited columns, cloned spans) itself, within a budget of about
1 ms and 2 ms a frame but at least one chunk or tile: a large tile or a
chunk on a slow machine still cost tens of milliseconds in one frame.

Both now run on the far-terrain worker. Lod::observe hands the chunk
over (the latest per column, queued once), and the worker returns its
summary with its reductions (Done::Observed): an unchanged one is
dropped, a new one is kept and applied as a reduction was. A mesh job
takes a snapshot of the active tiles, their samples and the visited
summaries (Arc maps, cheap to clone) and gathers every tile's inputs
on the worker, as it did before inputs moved to the render thread.
At most OBSERVE_AHEAD resident chunks wait at once, since an edit
copies a chunk the worker still holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:07:28 +02:00
Ludovic Marie 2bfe3391a8 Merge branch 'perf/collision-scan' of https://github.com/ludovic111/horizon into integ-659 2026-10-19 13:50:47 +02:00
Ludovic MarieandClaude Opus 5.5 c7f9fdd706 Dedicated server on Linux: world generation and packet writers behind the server thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:33 +02:00
Ludovic MarieandClaude Opus 5.5 a3bf174ddb Collision scans: a counting cursor, and fewer lookups and allocations per block
Block collision scans (vanilla's BlockCollisions) are most of every mob's
move, and moves were the largest share of the world tick with 600 mobs.
Per cell and per hit, the scan did work vanilla's JIT makes cheap:

- the cursor divided its index by the box width and height for every cell;
  it now counts x, then y, then z, as Cursor3D does (same cells, same order,
  same budget);
- the unit cube test cloned the shared unit cube to compare identities
  (`VoxelShape::is_block` compares with it in place), and built a
  normalised box to test it against the entity box (`intersects_corners`);
- every non-empty block looked its state up in the contextual rule map
  (scaffolding, liquids, powder snow); a flag in the scan table now says
  which states have one;
- a moved shape decomposed its occupancy into boxes again; the moved
  shape's boxes are its own boxes moved, the same values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:55:21 +02:00
Ludovic MarieandClaude Opus 5.5 f0aa88b0f6 Centre the cursor when a screen opens from gameplay
Releasing the pointer only ungrabbed it, so the cursor showed up wherever
the system had it: on Wayland, where the locked pointer last stopped (often
where the previous screen was closed), elsewhere wherever the hidden pointer
had drifted. Opening the inventory, a chest, the pause menu, chat, a sign or
a book could put it anywhere.

Game::capture now follows MouseHandler.grabMouse/releaseMouse: both move the
pointer to the window centre, and releasing also sets Horizon's own cursor
(what every screen hit-tests and draws hover with) to that point. On Wayland
the position can only be set as the locked pointer's hint, applied by the
compositor as the lock goes, so it is set before ungrabbing; X11, Windows
and macOS warp, so it is set again once the pointer is free. Releasing an
already released pointer does nothing, so screen-to-screen changes keep the
cursor where it is, as in vanilla, and nothing warps while the window is
unfocused (GLFW's glfwSetCursorPos does the same), so losing focus never
pulls the pointer back into the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:12:18 +02:00
643 changed files with 51309 additions and 9867 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
+34 -10
View File
@@ -3,9 +3,14 @@ name: launcher
# push to main that changes the launcher or a crate it builds in (the paths below),
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
# for the game.
# A release is launcher-vX.Y.Z: the patch after the last launcher tag, or the next minor when a merged pull
# request is labelled feature. The version is given to the build (HORIZON_LAUNCHER_VERSION): no bump commits.
# A release is launcher@X.Y.Z on the release line in .github/release-line.txt (docs/versioning.md); without it
# launcher-vX.Y.Z, the patch after the last launcher tag or the next minor when a merged pull request is labelled
# 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: 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
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -20,6 +25,7 @@ on:
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
- '.github/workflows/launcher.yml'
@@ -43,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
@@ -71,7 +81,8 @@ jobs:
python3 tools/release_train.py decide --kind launcher ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#launcher-v}" >> "$GITHUB_OUTPUT"
version=${tag#launcher-v}
echo "version=${version#launcher@}" >> "$GITHUB_OUTPUT"
# A dry run (and a launcher branch's push) builds whatever the decision.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then
echo "release=true" >> "$GITHUB_OUTPUT"
@@ -102,15 +113,20 @@ jobs:
# which only the Visual C++ redistributable installs.
rustflags: -C target-feature=+crt-static
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
timeout-minutes: 40
# Notarizing the app and then the .dmg can take a few minutes each.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# 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 }}
# The release's version, built in (launcher/src/backend.rs VERSION); a launcher branch's build keeps
# Cargo.toml's.
# The mod browser's CurseForge source (docs/mod-browser.md#curseforge): a repository secret. Unset, the
# launcher searches Modrinth only.
HORIZON_CURSEFORGE_API_KEY: ${{ secrets.HORIZON_CURSEFORGE_API_KEY }}
# The release's version and tag, built in (launcher/src/backend.rs VERSION and own_tag: the tag says which
# line it is on, docs/versioning.md); a launcher branch's build keeps Cargo.toml's.
HORIZON_LAUNCHER_VERSION: ${{ (github.event_name != 'push' || github.ref == 'refs/heads/main') && needs.decide.outputs.version || '' }}
HORIZON_LAUNCHER_TAG: ${{ (github.event_name != 'push' || github.ref == 'refs/heads/main') && needs.decide.outputs.tag || '' }}
defaults:
run:
working-directory: launcher
@@ -176,9 +192,6 @@ jobs:
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
"$launcher" menu-style classic
grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt"
"$launcher" menu-style horizon
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
@@ -194,11 +207,19 @@ jobs:
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
rm -rf "$HORIZON_LAUNCHER_HOME"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: ../packaging/macos/keychain.sh unlock
- name: Package
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
rm -rf dist out && mkdir -p out
if [[ "$RUNNER_OS" == "macOS" ]]; then
# Horizon.app, zipped (self-update) and in a .dmg (installer): packaging/macos.
# Horizon.app, zipped (self-update) and in a .dmg (installer), signed (and notarized with
# 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 target/release/${{ matrix.binary }} "$version" out
@@ -218,6 +239,9 @@ jobs:
name: ${{ matrix.asset }}
path: launcher/out/*
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: ../packaging/macos/keychain.sh lock
sign:
needs: [decide, build]
+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]
+6
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]
@@ -33,6 +37,8 @@ jobs:
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
run: bash tools/ci/vanilla-data.sh
- name: The version constants are the pinned jar's
run: python3 tools/gen_version_consts.py --check
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
+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:
+52 -9
View File
@@ -1,13 +1,16 @@
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), then
# publishes vYYYY.MM.DD[.N] with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
# stable after 6 hours.
# 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 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
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
on:
@@ -42,6 +45,7 @@ jobs:
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
version: ${{ steps.decide.outputs.version }}
head: ${{ steps.decide.outputs.head }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
@@ -62,6 +66,9 @@ jobs:
run: |
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
# The version the programs are built with (docs/versioning.md): the tag without its v.
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
@@ -92,9 +99,13 @@ jobs:
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
timeout-minutes: 40
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# 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 }}
defaults:
run:
shell: bash
@@ -123,6 +134,22 @@ jobs:
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
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 != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
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" "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.
@@ -142,9 +169,13 @@ jobs:
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 }}" --version
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
"$bin/horizon-server${{ matrix.exe }}" --version
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
@@ -200,6 +231,14 @@ jobs:
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
@@ -217,7 +256,7 @@ jobs:
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
"$folder/horizon-server" --version
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
@@ -229,6 +268,9 @@ jobs:
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: packaging/macos/keychain.sh lock
sign:
needs: [decide, build]
@@ -307,7 +349,8 @@ 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-server-linux-x86_64.tar.gz \
+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'
+5
View File
@@ -53,6 +53,11 @@ crates you touched (and the launcher's), generated files, `Cargo.lock`, the Pyth
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
`--quick` skips the harnesses. It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies.
Tests run library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Pull requests
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
Generated
+2
View File
@@ -1539,6 +1539,7 @@ version = "0.1.0"
dependencies = [
"flate2",
"getrandom 0.3.4",
"horizon-core",
"horizon-world",
"serde_json",
"sha1",
@@ -1568,6 +1569,7 @@ version = "0.1.0"
dependencies = [
"chrono",
"horizon-bench",
"horizon-release-sign",
"horizon-server-files",
"libc",
"serde",
+4 -4
View File
@@ -46,7 +46,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs, 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>
@@ -67,7 +67,7 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
## Host a server
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale (it arrives with the next launcher release). On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Modpacks
+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`).
@@ -82,6 +82,19 @@ impl CompiledChoices {
.collect::<Result<_>>()?,
})
}
/// The variant `particleMaterial` comes from: the first entry of a weighted
/// list (`WeightedVariants`) and, for a multipart root, of its first
/// selector whether or not that selector applies (`MultiPartModel`).
pub fn particle_variant(&self, selected: &SelectedModels) -> Option<&Variant> {
let choice = match selected {
SelectedModels::Variant(index) => self.variants.get(*index)?,
SelectedModels::Multipart(_) => self.multipart.first()?,
};
Some(match choice {
Choice::Single(value) => value,
Choice::Weighted { variants, .. } => variants.first()?,
})
}
/// Append selected variants without allocating intermediate part lists. A
/// multipart root consumes nextLong even for an empty selected subset, then
/// resets the same random source to that seed before each selected part.
+3 -2
View File
@@ -42,8 +42,9 @@ log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
@@ -14,6 +14,8 @@ use std::{
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
/// The stage sprite origin the cracks carry (the oracle compares the geometry and UVs only).
const STAGE: [f32; 2] = [0.25, 0.5];
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().collect();
let root = Path::new(&args[1]);
@@ -47,7 +49,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.iter()
.any(|(_, m)| m.faces.iter().any(|f| f.translucent));
for (_, m) in models {
breaking::geometry(m, origin, translucent, &mut vertices);
breaking::geometry(m, origin, translucent, STAGE, &mut vertices);
}
}
let quads: Vec<_> = vertices
@@ -58,7 +60,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
q.iter()
.map(|v| {
assert_eq!(v.color, 0xffff_ffff);
assert_eq!(v.light, 240 | (240 << 8));
assert_eq!(v.light, breaking::stage_light(STAGE));
assert_eq!(v.layer, if translucent { 12 } else { 11 });
[v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]
})
@@ -82,12 +82,19 @@ fn main() -> horizon_client::Result<()> {
let brewing = horizon_client::brewing::Brewing::load(root, &items);
// The inventory menu never reads banner patterns.
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
let enchantments = horizon_client::enchant::Enchantments::default();
let names = horizon_client::creative::Names::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
let mut out = String::new();
for line in input.lines() {
@@ -135,7 +135,7 @@ fn tree(d: &Dispatcher, node: usize) -> Value {
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.get(1).map(String::as_str) == Some("tree") {
let d = vanilla::release_dispatcher();
let d = &vanilla::registered(false);
let mut v = tree(d, 0);
v["type"] = json!("root");
fs::write(&args[2], v.to_string())?;
@@ -30,7 +30,11 @@ fn main() -> horizon_client::Result<()> {
),
};
let info = biomes.get(biomes.id(&format!("minecraft:{}", f[4])));
let mut sky = environment.sample(f[1].parse()?, info.sky, info.fog);
let mut sky = environment.sample(
f[1].parse()?,
info.sky.map(horizon_client::biome::rgb),
info.fog.map(horizon_client::biome::rgb),
);
if dimension == "overworld" {
apply_weather(&mut sky, f[2].parse()?, f[3].parse()?);
}
@@ -0,0 +1,30 @@
//! 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::{enchant::Enchantments, item_data::parse_stack, item_tooltip::firework_lines};
use std::{error::Error, fmt::Write, path::PathBuf};
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let input = std::fs::read_to_string(&args[1])?;
let root = input
.lines()
.find_map(|l| l.strip_prefix("#resources "))
.map(PathBuf::from)
.ok_or("no #resources line")?;
// 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")?;
let lines = firework_lines(&stack, &translate);
if lines.is_empty() {
writeln!(output, "-")?;
} else {
writeln!(output, "{}", lines.join(" | "))?;
}
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -0,0 +1,38 @@
//! Selected-slot drop state for the vanilla key-handler parity harness.
use horizon_client::command::args::GameMode;
use horizon_client::items::{ItemStack, take_held_drop};
fn main() {
for mode in [
GameMode::Survival,
GameMode::Creative,
GameMode::Adventure,
GameMode::Spectator,
] {
for all in [false, true] {
for _selected in [0, 8] {
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;
}
if mode != GameMode::Spectator {
packets += 1;
}
println!(
"{} {dropped} {swings} {packets}",
slot.as_ref().map_or(0, |s| s.count),
);
}
}
}
}
}
}
@@ -102,6 +102,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
equipment: Default::default(),
health: 20.0,
attributes: Vec::new(),
bed: None,
living_flags: 0,
};
loop {
@@ -118,6 +119,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
clock_rate: 1.0,
tick_rate: 20.0,
tick_frozen: false,
tick_steps: (0, 0),
collision: None,
};
for event in hub.tick(&mut ctx) {
@@ -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(())
}
@@ -0,0 +1,90 @@
//! Probe for tools/test_marker_particles_parity.py.
use horizon_client::{
Result,
animate_tick::{AnimateContext, AnimateTables, animate_tick, marker_particle_target},
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::Chunk,
collisions,
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType},
world::World,
};
use horizon_core::random::RandomSource;
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
sync::Arc,
};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(".local/horizon-client/26.3");
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let biomes = Biomes::load(root)?;
let collision = collisions::load(root, assets.registry)?;
let tables = AnimateTables::load(root, assets.registry, &biomes)?;
let mut world = World::new(facts.air, 0);
let barrier = assets
.registry
.default_state("minecraft:barrier")
.ok_or("barrier")?;
for cz in -2..2 {
for cx in -2..2 {
world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
for y in 33..96 {
for z in 0..16 {
for x in 0..16 {
world.set(cx * 16 + x, y, cz * 16 + z, barrier)?;
}
}
}
}
}
let mut engine = ParticleEngine::new(root, &HashMap::new());
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let case: Value = serde_json::from_str(&line?)?;
let mode = GameMode::ALL
.into_iter()
.find(|mode| Some(mode.name()) == case["mode"].as_str())
.ok_or("mode")?;
let stack = ItemStack::new(
&format!("minecraft:{}", case["main"].as_str().ok_or("main")?),
case["count"].as_u64().ok_or("count")? as u32,
);
let target = marker_particle_target(mode, Some(&stack));
writeln!(out, "{}", target.unwrap_or("none"))?;
// Exercise the real ambient sampling loop in a dry barrier-filled world.
let options =
ParticleOptions::block(ParticleType::BlockMarker, "item/barrier".into(), 0xFFFFFF);
let options = |_| Some(options.clone());
let ctx = AnimateContext {
world: &world,
facts: &facts,
collision: &collision,
biomes: &biomes,
registry: assets.registry,
raining_at: &|_| false,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
};
engine.seed_randoms(1057, 0, 0);
engine.requested = Some(Vec::new());
animate_tick(&tables, &mut engine, &ctx, [0, 64, 0]);
for (options, p, v) in engine.requested.take().ok_or("requests")? {
assert_eq!(options.ty, ParticleType::BlockMarker);
assert_eq!(v, [0.0; 3]);
writeln!(out, "marker {} {} {}", p[0], p[1], p[2])?;
}
writeln!(out, "next {}", engine.random().next_int())?;
}
Ok(())
}
+106 -4
View File
@@ -11,8 +11,41 @@ use horizon_client::{
items::Items,
menu::{Env, Input, Kind, Menu},
};
use horizon_protocol::registry::Ids;
use serde_json::Value;
use std::{fs, path::Path};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
fs,
path::Path,
};
/// The case's maps: (locked, scale) by id, and the next free id.
#[derive(Default)]
struct CaseMaps {
maps: RefCell<HashMap<i32, (bool, u8)>>,
next: Cell<i32>,
}
impl horizon_client::menu::Maps for CaseMaps {
fn state(&self, id: i32) -> Option<(bool, u8)> {
self.maps.borrow().get(&id).copied()
}
/// `MapItemSavedData.scaled` (unlocked, one scale up) or `locked`.
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
let (_, s) = self.state(id)?;
let new = self.next.get();
self.next.set(new + 1);
let made = if scale {
(false, (s + 1).min(4))
} else {
(true, s)
};
self.maps.borrow_mut().insert(new, made);
Some(new)
}
}
fn state(menu: &Menu) -> String {
let slots: Vec<String> = (0..menu.slot_count())
@@ -24,13 +57,21 @@ fn state(menu: &Menu) -> String {
.iter()
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
.collect();
format!(
let line = format!(
"{} | {} |{data} | {} | {}",
slots.join(" "),
canon_stack(menu.carried.as_ref()),
menu.drops.join(";"),
equipped.join(";")
)
);
if menu.kind() != Kind::Lectern {
return line;
}
// No inventory slots: the inventory Take Book fills.
let items: String = (0..36)
.map(|i| format!(" {}", canon_stack(menu.player_item(i))))
.collect();
format!("{line} | inv{items}")
}
fn main() -> horizon_client::Result<()> {
@@ -44,13 +85,28 @@ fn main() -> horizon_client::Result<()> {
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::load(root, &items);
let enchantments = horizon_client::enchant::Enchantments::load(root);
let names = horizon_client::creative::Names::load(root);
let case_maps = CaseMaps::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: Some(&case_maps),
};
// The mob effects' network ids (BeaconMenu.encodeEffect).
let builtin = horizon_protocol::registry::Builtin::find(root)
.and_then(|p| horizon_protocol::registry::Builtin::load(&p).ok());
let effects = builtin
.as_ref()
.and_then(|b| b.get("minecraft:mob_effect"))
.cloned();
let mut out = String::new();
for line in input.lines() {
let case: Value = serde_json::from_str(line)?;
@@ -68,15 +124,41 @@ fn main() -> horizon_client::Result<()> {
.unwrap_or(name),
)
.ok_or("menu kind")?;
// [id, scale, locked] each; the level's new maps from 1000.
case_maps.next.set(1000);
*case_maps.maps.borrow_mut() = case["maps"]
.as_array()
.into_iter()
.flatten()
.map(|m| {
let id = m[0].as_i64().unwrap_or(0) as i32;
(
id,
(
m[2].as_bool().unwrap_or(false),
m[1].as_u64().unwrap_or(0) as u8,
),
)
})
.collect();
let mut menu = Menu::new(kind);
menu.creative = case["creative"].as_bool().unwrap_or(false);
menu.levels = case["levels"].as_i64().unwrap_or(0) as i32;
for (i, slot) in case["slots"].as_array().ok_or("slots")?.iter().enumerate() {
menu.set_slot(&env, i, parse_stack(slot.as_str().unwrap_or("-"))?);
}
menu.carried = parse_stack(case["carried"].as_str().unwrap_or("-"))?;
for (i, item) in case["inventory"]
.as_array()
.into_iter()
.flatten()
.enumerate()
{
menu.set_player_item(i, parse_stack(item.as_str().unwrap_or("-"))?);
}
menu.settle(&env);
for (i, v) in case["data"].as_array().ok_or("data")?.iter().enumerate() {
menu.data[i] = v.as_i64().unwrap_or(0) as i32;
menu.set_data(i, v.as_i64().unwrap_or(0) as i32);
}
let mut furnace = furnace_kind.map(|kind| Furnace {
kind,
@@ -100,6 +182,26 @@ fn main() -> horizon_client::Result<()> {
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundRenameItemPacket: AnvilMenu.setItemName.
"RENAME" => {
menu.rename(&env, op[3].as_str().unwrap_or_default());
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundSetBeaconPacket: BeaconMenu.updateEffects.
"SET_BEACON" => {
fn power<'a>(ids: Option<&Ids>, v: &'a Value) -> Option<(i32, &'a str)> {
let name = v.as_str()?;
Some((ids?.id(name)?, name))
}
let (primary, secondary) = (
power(effects.as_ref(), &op[3]),
power(effects.as_ref(), &op[4]),
);
menu.beacon_update(&env, primary, secondary);
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundContainerSlotStateChangedPacket.
"SLOT_STATE" => {
if (0..9).contains(&slot) {
@@ -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 {
@@ -112,21 +112,26 @@ fn state(s: &ParticleSnapshot) -> String {
}
out
}
fn world(assets: &Assets, facts: &BlockFacts, floor: bool) -> Result<World> {
fn world(assets: &Assets, facts: &BlockFacts, floor: bool, water: bool) -> Result<World> {
let mut world = World::new(facts.air, 0);
let stone = assets
.registry
.default_state("minecraft:stone")
.ok_or("missing stone")?;
let state = |name: &str| assets.registry.default_state(name).ok_or("missing block");
let (stone, still) = (state("minecraft:stone")?, state("minecraft:water")?);
for cz in -3..=2 {
for cx in -3..=2 {
world
.chunks
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
if floor {
for z in 0..16 {
for x in 0..16 {
world.set(cx * 16 + x, 63, cz * 16 + z, stone)?;
for z in 0..16 {
for x in 0..16 {
let (x, z) = (cx * 16 + x, cz * 16 + z);
if floor {
world.set(x, 63, z, stone)?;
}
// The oracle's water: still sources from y 64 to 67.
if water {
for y in 64..=67 {
world.set(x, y, z, still)?;
}
}
}
}
@@ -137,13 +142,15 @@ fn world(assets: &Assets, facts: &BlockFacts, floor: bool) -> Result<World> {
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(".local/horizon-client/26.3");
let root = &Path::new(".local/horizon-client").join(horizon_core::VANILLA_VERSION);
let assets = Assets::load(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let collision = collisions::load(root, assets.registry)?;
let worlds = [
world(&assets, &facts, false)?,
world(&assets, &facts, true)?,
world(&assets, &facts, false, false)?,
world(&assets, &facts, true, false)?,
world(&assets, &facts, false, true)?,
world(&assets, &facts, true, true)?,
];
let mut engine = ParticleEngine::new(root, &HashMap::new());
for (ty, item) in [
@@ -174,7 +181,8 @@ fn main() -> Result<()> {
engine.requested = Some(Vec::new());
engine.sounds.clear();
let level = ParticleLevel {
world: &worlds[usize::from(case["floor"].as_bool() == Some(true))],
world: &worlds[usize::from(case["floor"].as_bool() == Some(true))
+ 2 * usize::from(case["water"].as_bool() == Some(true))],
facts: &facts,
collision: &collision,
player: None,
@@ -38,12 +38,19 @@ fn main() -> horizon_client::Result<()> {
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::default();
let station_recipes = horizon_client::stations::StationRecipes::default();
let enchantments = horizon_client::enchant::Enchantments::default();
let names = horizon_client::creative::Names::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
let mut out = String::new();
for (line, order) in cases.lines().zip(orders.lines()) {
+1 -1
View File
@@ -87,7 +87,7 @@ fn main() -> Result<()> {
let _ = save.tables.set(tables.clone());
let started = Instant::now();
save.save_later(&chunks, registry, &biomes)?;
save.flush();
save.flush().unwrap();
times[2].push(started.elapsed().as_secs_f64());
drop(save);
@@ -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);
@@ -239,10 +239,12 @@ fn main() -> horizon_client::Result<()> {
let vanilla = fs::read_to_string(args.get(3).ok_or(usage)?)?;
let items = Items::catalog(root)?;
let enchantments = Enchantments::load(root);
let names = horizon_client::creative::Names::default();
let trades = Trades::load(root);
let recipes = horizon_client::crafting::Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::load(root);
let station_recipes = horizon_client::stations::StationRecipes::default();
let types: HashMap<_, _> = horizon_client::mobs::MobTypes::load(root)?
.types
.into_iter()
@@ -378,7 +380,11 @@ fn main() -> horizon_client::Result<()> {
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: Some(&env_trade),
maps: None,
};
trade(&mut out, name, &kv, &mut world, &env, origin)?;
}
@@ -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!(
+262
View File
@@ -53,6 +53,10 @@ pub struct Tree {
pub by_id: HashMap<String, usize>,
/// trigger id -> (advancement index, criterion index).
pub by_trigger: HashMap<String, Vec<(usize, usize)>>,
/// `recipe_unlocked` criteria by the recipe they name (`index_recipes`);
/// those naming none in `recipes_any`.
by_recipe: HashMap<String, Vec<(usize, usize)>>,
recipes_any: Vec<(usize, usize)>,
}
fn frame(v: &Value) -> Frame {
match v.as_str() {
@@ -93,12 +97,111 @@ impl Tree {
tree.list.push(a);
}
}
tree.index_recipes();
tree
}
/// Index the `recipe_unlocked` criteria by recipe: a grant that unlocks
/// twenty recipes tested every one of the ~1,300 recipe advancements'
/// criteria for each (several ms in one tick).
pub fn index_recipes(&mut self) {
self.by_recipe.clear();
self.recipes_any.clear();
for &(ai, ci) in self
.by_trigger
.get("minecraft:recipe_unlocked")
.into_iter()
.flatten()
{
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
Some(r) => self
.by_recipe
.entry(r.to_owned())
.or_default()
.push((ai, ci)),
None => self.recipes_any.push((ai, ci)),
}
}
}
/// The `recipe_unlocked` criteria that could pass for `recipe`, in tree
/// order (the others name another recipe).
pub fn recipe_criteria(&self, recipe: &str) -> Vec<(usize, usize)> {
let mut out = self.recipes_any.clone();
out.extend(self.by_recipe.get(recipe).into_iter().flatten());
out.sort_unstable();
out
}
pub fn get(&self, id: &str) -> Option<&Advancement> {
self.by_id.get(id).map(|&i| &self.list[i])
}
}
/// The `inventory_changed` criteria by the items they need. A criterion whose
/// item predicates include one limited to some items (an id, a `#tag`, a
/// list) can only pass with one of those in the inventory, so an inventory
/// change tests only the criteria of the items it holds, plus those that
/// need no particular item. The same answers as testing them all: the ~1,300
/// recipe advancements each have one, and testing every one of them cost a
/// player 1–4 ms on every tick its inventory changed.
#[derive(Default)]
pub struct InventoryIndex {
by_item: HashMap<String, Vec<(usize, usize)>>,
any: Vec<(usize, usize)>,
}
impl InventoryIndex {
/// `tag` gives a `#tag`'s item ids (none for a tag that doesn't exist,
/// which matches nothing).
pub fn build<'t>(tree: &Tree, tag: impl Fn(&str) -> Vec<&'t str>) -> Self {
fn ids<'t>(v: &Value, tag: &impl Fn(&str) -> Vec<&'t str>, out: &mut Vec<String>) {
match v {
Value::String(s) => match s.strip_prefix('#') {
Some(t) => out.extend(tag(t).into_iter().map(str::to_owned)),
None => out.push(s.clone()),
},
Value::Array(a) => a.iter().for_each(|x| ids(x, tag, out)),
_ => {}
}
}
let mut index = Self::default();
let Some(list) = tree.by_trigger.get("minecraft:inventory_changed") else {
return index;
};
for &(ai, ci) in list {
let items = &tree.list[ai].criteria[ci].conditions["items"];
// `id_matches`: only a string or a list limits the items.
let limited = items.as_array().and_then(|a| {
a.iter()
.map(|p| &p["items"])
.find(|v| v.is_string() || v.is_array())
});
match limited {
Some(v) => {
let mut out = Vec::new();
ids(v, &tag, &mut out);
out.sort();
out.dedup();
for id in out {
index.by_item.entry(id).or_default().push((ai, ci));
}
}
None => index.any.push((ai, ci)),
}
}
index
}
/// The criteria an inventory holding these item ids could pass, in
/// tree order.
pub fn candidates<'a>(&self, held: impl IntoIterator<Item = &'a str>) -> Vec<(usize, usize)> {
let mut out = self.any.clone();
for id in held {
if let Some(list) = self.by_item.get(id) {
out.extend_from_slice(list);
}
}
out.sort_unstable();
out.dedup();
out
}
}
fn collect(root: &Path, dir: &Path, ns: &str, out: &mut Vec<(String, std::path::PathBuf)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
@@ -992,6 +1095,77 @@ impl Tree {
#[cfg(test)]
mod tests {
use super::*;
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
/// with HORIZON_RESOURCES set to the prepared resources.
#[test]
#[ignore = "needs the prepared resources"]
fn index_on_vanilla_tree() {
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
return;
};
let data = root.join("data");
let t = std::time::Instant::now();
let tree = Tree::load(&data);
let load = t.elapsed();
let tags = data.join("minecraft/tags/item");
let tag_items: HashMap<String, Vec<String>> = std::fs::read_dir(&tags)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let v: Value =
serde_json::from_str(&std::fs::read_to_string(e.path()).ok()?).ok()?;
let name = format!("minecraft:{}", e.path().file_stem()?.to_string_lossy());
let values = v["values"]
.as_array()?
.iter()
.filter_map(|x| x.as_str().map(str::to_owned));
Some((name, values.collect()))
})
.collect();
let t = std::time::Instant::now();
let index = InventoryIndex::build(&tree, |tag| {
tag_items
.get(tag)
.map(|v| v.iter().map(String::as_str).collect())
.unwrap_or_default()
});
let build = t.elapsed();
let all = tree
.by_trigger
.get("minecraft:inventory_changed")
.map_or(0, Vec::len);
let held = [
"minecraft:iron_ingot",
"minecraft:oak_log",
"minecraft:cobblestone",
"minecraft:diamond_pickaxe",
"minecraft:bread",
"minecraft:torch",
"minecraft:oak_planks",
"minecraft:stick",
"minecraft:coal",
];
let t = std::time::Instant::now();
let mut candidates = 0;
for _ in 0..1000 {
candidates = index.candidates(held).len();
}
let per_change = t.elapsed() / 1000;
let recipes = tree
.by_trigger
.get("minecraft:recipe_unlocked")
.map_or(0, Vec::len);
let per_recipe = tree.recipe_criteria("minecraft:iron_pickaxe").len();
eprintln!(
"tree {load:?}; inventory_changed: {all} criteria, index built in {build:?}, \
a 9-stack inventory tests {candidates} (looked up in {per_change:?}); \
recipe_unlocked: {recipes} criteria, iron_pickaxe tests {per_recipe}"
);
assert!(candidates < all / 10);
}
struct Flat;
impl World for Flat {
fn dimension(&self) -> &str {
@@ -1090,6 +1264,94 @@ mod tests {
stack.id = "minecraft:tnt".into();
}
}
/// Only the criteria the held items could pass are tested; those that
/// need no particular item always are.
#[test]
fn inventory_index_keeps_the_criteria_the_items_could_pass() {
let criteria = [
serde_json::json!({"items": [{"items": "minecraft:diamond"}]}),
serde_json::json!({"items": [{"items": "#minecraft:planks"}]}),
serde_json::json!({"items": [{"count": {"min": 2}}]}),
serde_json::json!({"items": [{"count": 1}, {"items": ["minecraft:stick", "minecraft:apple"]}]}),
serde_json::json!({"slots": {"empty": 0}}),
serde_json::json!({"items": [{"items": "#minecraft:no_such_tag"}]}),
];
let mut tree = Tree::default();
for (i, conditions) in criteria.into_iter().enumerate() {
tree.list.push(Advancement {
id: format!("test:{i}"),
parent: None,
display: None,
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:inventory_changed".into(),
conditions,
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
});
tree.by_trigger
.entry("minecraft:inventory_changed".into())
.or_default()
.push((i, 0));
}
let index = InventoryIndex::build(&tree, |tag| match tag {
"minecraft:planks" => vec!["minecraft:oak_planks", "minecraft:birch_planks"],
_ => Vec::new(),
});
let found = |held: &[&str]| -> Vec<usize> {
index
.candidates(held.iter().copied())
.into_iter()
.map(|(ai, _)| ai)
.collect()
};
assert_eq!(found(&[]), [2, 4]);
assert_eq!(found(&["minecraft:dirt"]), [2, 4]);
assert_eq!(
found(&["minecraft:oak_planks", "minecraft:diamond"]),
[0, 1, 2, 4]
);
assert_eq!(found(&["minecraft:apple", "minecraft:apple"]), [2, 3, 4]);
}
/// A recipe unlocked tests only the criteria that name it.
#[test]
fn recipe_index_keeps_the_criteria_of_the_recipe() {
let mut tree = Tree::default();
for (i, conditions) in [
serde_json::json!({"recipes": "minecraft:oak_planks"}),
serde_json::json!({"recipes": "minecraft:stick"}),
serde_json::json!({}),
serde_json::json!({"recipes": "minecraft:oak_planks"}),
]
.into_iter()
.enumerate()
{
tree.list.push(Advancement {
id: format!("test:{i}"),
parent: None,
display: None,
criteria: vec![Criterion {
name: "c".into(),
trigger: "minecraft:recipe_unlocked".into(),
conditions,
}],
requirements: vec![vec!["c".into()]],
rewards: Rewards::default(),
});
tree.by_trigger
.entry("minecraft:recipe_unlocked".into())
.or_default()
.push((i, 0));
}
tree.index_recipes();
assert_eq!(
tree.recipe_criteria("minecraft:oak_planks"),
[(0, 0), (2, 0), (3, 0)]
);
assert_eq!(tree.recipe_criteria("minecraft:stick"), [(1, 0), (2, 0)]);
assert_eq!(tree.recipe_criteria("minecraft:torch"), [(2, 0)]);
}
#[test]
fn bucket_axolotl_block_advancements_require_the_placed_block() {
let ctx = Entities {
+51
View File
@@ -6,6 +6,8 @@
use crate::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
world::World,
};
@@ -59,6 +61,8 @@ enum Animate {
Fire,
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
PotentSulfur,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -126,6 +130,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
}
}
"respawn_anchor" if prop("charges").is_some_and(|c| c != "0") => Animate::RespawnAnchor,
"potent_sulfur" if prop("potent_sulfur_state").is_some_and(|s| s != "dry") => {
Animate::PotentSulfur
}
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
@@ -254,6 +261,24 @@ pub struct AnimateContext<'a> {
pub registry: &'a BlockRegistry,
/// `Level.isRainingAt`.
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
}
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
pub fn marker_particle_target(
mode: GameMode,
main_hand: Option<&ItemStack>,
) -> Option<&'static str> {
if mode != GameMode::Creative {
return None;
}
let stack = main_hand.filter(|stack| stack.count > 0)?;
match stack.item.as_ref() {
"minecraft:barrier" => Some("minecraft:barrier"),
"minecraft:light" => Some("minecraft:light"),
_ => None,
}
}
impl AnimateContext<'_> {
fn state(&self, p: [i32; 3]) -> BlockStateId {
@@ -358,6 +383,12 @@ fn do_animate_tick(
try_spawn_drip(tables, engine, ctx, [x, y - 1, z], drip, top_solid);
}
}
if let Some((block, options)) = ctx.marker
&& ctx.registry.state(state).is_some_and(|s| s.name == block)
&& let Some(options) = options(state)
{
engine.add(&options, pos.map(|v| f64::from(v) + 0.5), [0.0; 3]);
}
if !facts.full_collision {
let biome = ctx
.world
@@ -793,6 +824,26 @@ fn block_animate_tick(
}
}
}
Animate::PotentSulfur => {
// PotentSulfurBlock.animateTick: under a water source, two
// bubbles and, one time in ten, the noxious gas.
let above = ctx.facts.get(ctx.state([pos[0], pos[1] + 1, pos[2]]));
if above.fluid == Fluid::Water && above.fluid_level == 8 && !above.fluid_falling {
for _ in 0..2 {
let r = engine.random();
let p = [
fx + f64::from(r.next_float()),
fy + 1.0 + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::SulfurBubbles, p, [0.0; 3]);
}
if next_int(engine.random(), 10) == 0 {
let name = "minecraft:block.potent_sulfur.noxious_gas";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::Mycelium => {
let r = engine.random();
if next_int(r, 10) == 0 {
+5 -1
View File
@@ -29,9 +29,11 @@ impl AttackStrength {
/// The attack-strength part of `Player.tick`: both tickers advance, and a
/// main-hand change to a different item resets them
/// (`!ItemStack.matches` and `!ItemStack.isSameItem`).
pub fn tick(&mut self, main_hand: Option<HeldStack>) {
/// Whether they were reset.
pub fn tick(&mut self, main_hand: Option<HeldStack>) -> bool {
self.ticker += 1;
self.swap_ticker += 1;
let mut reset = false;
if self.last_item != main_hand {
let same_item = match (&self.last_item, &main_hand) {
(Some(a), Some(b)) => a.item == b.item,
@@ -40,9 +42,11 @@ impl AttackStrength {
};
if !same_item {
self.reset();
reset = true;
}
self.last_item = main_hand;
}
reset
}
/// `resetAttackStrengthTicker`: swinging at nothing, attacking or
/// stopping a block break.
+146 -21
View File
@@ -2,7 +2,7 @@
//! small mixer standing in for OpenAL: one channel per source with its gain,
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
//! (streams on a worker thread) and stereo panning.
//! (streams on a worker thread) and OpenAL Soft's stereo panning.
use crate::Result;
use crate::sound_engine::{
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
@@ -45,6 +45,7 @@ struct Mixer {
channels: HashMap<ChannelId, Channel>,
listener: [f64; 3],
listener_yaw: f32,
listener_pitch: f32,
limiter_peak: f32,
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
@@ -157,6 +158,9 @@ pub struct Audio {
/// Positional sounds played since the last drain, kept while the world
/// is open to the network so guests hear them too.
pub capture: Option<Vec<(String, [f64; 3], f32, f32)>>,
/// How many times each positional sound played (the control socket's `status`),
/// with or without an audio device.
pub heard: std::collections::BTreeMap<String, u32>,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -331,6 +335,32 @@ impl Backend for MixerBackend {
.unwrap_or(true)
}
}
/// OpenAL Soft's stereo gains `[left, right]` for a mono source in a
/// direction given in listener space (right, up, forward; any length).
/// Vanilla's context (`Library.createAttributes`: HRTF off) on a stereo
/// output renders pairwise: front azimuths are stretched by 1.5
/// (`ScaleAzimuthFront`, so 60 degrees off-centre pans fully) and the
/// direction is decoded by the first-order stereo decoder, 0.5 W, 0.5 Y and
/// 0.0957 X in this scaling. A centred source gets 0.596 on each side, one
/// behind 0.404, one above or below 0.5, one at the source's own position
/// is panned to the front. Matches OpenAL Soft 1.25.2 (LWJGL 3.4.3, the
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
let length = (right * right + up * up + forward * forward).sqrt();
if length <= f32::EPSILON {
return stereo_gains(0.0, 0.0, 1.0);
}
let elevation = (up / length).clamp(-1.0, 1.0).asin();
let mut azimuth = right.atan2(forward);
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
azimuth = (azimuth.abs() * 1.5)
.min(std::f32::consts::FRAC_PI_2)
.copysign(azimuth);
}
let side = azimuth.sin() * elevation.cos();
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
/// distance 0 and rolloff 1: the distance clamps to the max distance).
fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
@@ -345,9 +375,30 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
data.fill(0.0);
let listener = mixer.listener;
let yaw = mixer.listener_yaw.to_radians();
// The camera's forward and up vectors (`Camera.forwardVector`/`upVector`)
// and OpenAL's right = forward x up.
let (yaw, pitch) = (
f64::from(mixer.listener_yaw.to_radians()),
f64::from(mixer.listener_pitch.to_radians()),
);
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, f32, f32, f64)> = Vec::new();
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
@@ -366,19 +417,17 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
// The listener's right vector for a Minecraft yaw.
let side = if c.relative {
(d[0] as f32) / distance.max(0.001)
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [left, right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
(-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32 / distance.max(0.001)
};
let pan = side.clamp(-1.0, 1.0);
let left = gain * (1.0 - pan.max(0.0) * 0.6);
let right = gain * (1.0 + pan.min(0.0) * 0.6);
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
}
.map(|pan| gain * pan);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, left, right, step));
voices.push((*id, [left, right, gain], step));
}
for (id, left, right, step) in voices {
for (id, [left, right, gain], step) in voices {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
@@ -402,16 +451,19 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
frame[0] += s * left;
frame[1] += s * right;
} else {
frame[0] += s * (left + right) / 2.0;
// A mono output has no panning: the source's own gain.
frame[0] += s * gain;
}
c.position += step;
}
}
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 50 ms.
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
// over 0.2 s).
let peak = data.iter().fold(1.0f32, |peak, s| peak.max(s.abs()));
mixer.limiter_peak = mixer.limiter_peak.max(peak);
let release = (-1.0 / (0.05 * rate as f32)).exp();
let release = (-1.0 / (0.2 * rate as f32)).exp();
for frame in data.chunks_mut(channels) {
let gain = mixer.limiter_peak.recip();
for s in frame {
@@ -498,6 +550,15 @@ impl Audio {
}
fn with_output(root: &Path, output: bool) -> Self {
let registry = SoundRegistry::load(root);
if output && registry.is_empty() {
// The game then plays nothing on a working output (#1035, #1076).
log::warn!(
"Horizon: no sounds in {}: they were not copied from your Minecraft \
install; prepare the game again in the launcher (or run \
horizon-prepare sounds)",
root.join("assets").display()
);
}
let mixer = Arc::new(Mutex::new(Mixer::default()));
let opened = if output {
Self::open(mixer.clone())
@@ -533,6 +594,7 @@ impl Audio {
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
heard: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -582,13 +644,15 @@ impl Audio {
backend: &mut self.backend,
}
}
/// `SoundManager.updateSource`: the camera position and yaw.
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32) {
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
self.listener = position;
self.listener_yaw = yaw;
if let Ok(mut m) = self.backend.mixer.lock() {
m.listener = position;
m.listener_yaw = yaw;
m.listener_pitch = pitch;
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -673,6 +737,9 @@ impl Audio {
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
capture.push((event.to_owned(), at, volume, pitch));
}
if at.is_some() {
*self.heard.entry(event.to_owned()).or_default() += 1;
}
self.sync_volumes();
let event = crate::sound_events::normalize(event);
let source = self
@@ -899,8 +966,9 @@ mod tests {
}
let mut data = [0.0; 16];
mix(&mut m, &mut data, 2, 48_000);
// West of a listener facing south: fully on the right.
for (frame, sample) in data.as_chunks::<2>().0.iter().zip(wave) {
assert!((frame[0] - sample * 0.4).abs() < 1e-6, "{frame:?}");
assert!(frame[0].abs() < 1e-6, "{frame:?}");
assert!((frame[1] - sample).abs() < 1e-6, "{frame:?}");
}
assert!(data.iter().all(|s| s.abs() <= 1.0));
@@ -911,9 +979,10 @@ mod tests {
let mut m = Mixer::default();
m.channels
.insert(1, channel(vec![0.25; 2], None, [0.0; 3], true));
let quiet = 0.25 * stereo_gains(0.0, 0.0, 1.0)[0];
let mut data = [0.0; 16];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.25; 16]);
assert_eq!(data, [quiet; 16]);
for id in 2..=5 {
m.channels
@@ -926,7 +995,7 @@ mod tests {
mix(&mut m, &mut recovery, 2, 48_000);
assert!(recovery[0] > 0.0 && recovery[0] < 0.1);
assert!(recovery.windows(2).all(|w| w[1] >= w[0]));
assert_eq!(*recovery.last().unwrap(), 0.25);
assert_eq!(*recovery.last().unwrap(), quiet);
}
#[test]
@@ -944,4 +1013,60 @@ mod tests {
let first = data[0] + data[1];
assert!(first > 1.4 && first < 3.1, "{first}");
}
#[test]
fn stereo_panning_matches_openal_soft() {
// OpenAL Soft 1.25.2 (LWJGL 3.4.3's macOS build), stereo loopback
// device with vanilla's attributes, 1 kHz sine 4 blocks away.
let cases = [
((0.0, 0.0, 1.0), [0.5957, 0.5957]),
((-1.0, 0.0, 0.0), [1.0, 0.0]),
((1.0, 0.0, 0.0), [0.0, 1.0]),
((0.0, 0.0, -1.0), [0.4043, 0.4043]),
((0.0, 1.0, 0.0), [0.5, 0.5]),
((0.0, -1.0, 0.0), [0.5, 0.5]),
((-1.0, 0.0, 1.0), [0.9985, 0.0747]),
((-1.0, 0.0, -1.0), [0.7859, 0.0788]),
((0.0, 0.0, 0.0), [0.5957, 0.5957]),
];
for ((right, up, forward), expected) in cases {
let gains = stereo_gains(right, up, forward);
for (g, e) in gains.into_iter().zip(expected) {
assert!((g - e).abs() < 1e-4, "{right} {up} {forward}: {gains:?}");
}
}
}
#[test]
fn panning_follows_the_camera_rotation() {
let gains = |yaw: f32, pitch: f32, at: [f32; 3]| {
let mut m = Mixer {
listener_yaw: yaw,
listener_pitch: pitch,
..Mixer::default()
};
m.channels.insert(1, channel(vec![1.0; 4], None, at, true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
data
};
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4;
// Facing south (yaw 0), east is on the left; facing north, on the right.
assert!(close(gains(0.0, 0.0, [1.0, 0.0, 0.0]), [1.0, 0.0]));
assert!(close(gains(180.0, 0.0, [1.0, 0.0, 0.0]), [0.0, 1.0]));
// Looking straight down, a source below is ahead and one south is above.
assert!(close(gains(0.0, 90.0, [0.0, -1.0, 0.0]), [0.5957, 0.5957]));
assert!(close(gains(0.0, 90.0, [0.0, 0.0, 1.0]), [0.5, 0.5]));
}
#[test]
fn a_mono_output_plays_sources_unpanned() {
let mut m = Mixer::default();
m.channels
.insert(1, channel(vec![0.5; 4], None, [0.0, 0.0, -3.0], true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 1, 48_000);
assert_eq!(data, [0.5; 2]);
}
}
+1 -1
View File
@@ -174,7 +174,7 @@ pub fn discovery_url() -> String {
fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout(Duration::from_secs(15))
.user_agent(concat!("Horizon/", env!("CARGO_PKG_VERSION")))
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
.build()
}
+2
View File
@@ -145,6 +145,8 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
replace_world(&setup.game_dir)?;
control.call(&format!("open {WORLD} {SEED} creative"))?;
// The world opens on a worker: its folder may not exist yet when `open` replies.
std::fs::create_dir_all(setup.game_dir.join("saves").join(WORLD)).map_err(|e| e.to_string())?;
std::fs::write(
setup.game_dir.join("saves").join(WORLD).join(MARKER),
"Created by horizon-client --benchmark; replaced by the next run.\n",
@@ -282,10 +282,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("ui", Screens, "ui", "every widget of the open screen: id, kind, label, bounds, value",
"Kinds: button, slider (value 0..1), field, row (worlds, servers, packs, languages), tab (selected), slot (slot-ID, with its stack). Ids come from labels (\"FOV: 70\" is fov, a repeat gets -2) or kind-N without one, and stay put when a value changes. Reflects the last drawn frame (frame counts them).",
["horizon-ctl ui"], &[], mcp: true, local: false),
cmd!("click", Screens, "click ID [right] [shift]", "click a widget of ui by its id, as the mouse would",
"A field takes focus (then type); slot-ID clicks a slot like click-slot.",
cmd!("click", Screens, "click ID [right|middle] [shift]", "click a widget of ui by its id, as the mouse would",
"A field takes focus (then type); slot-ID clicks a slot like click-slot. middle presses the middle button (pick block: a creative player clones the stack).",
["horizon-ctl click done", "horizon-ctl click video-settings", "horizon-ctl click row-0", "horizon-ctl click slot-0 shift"],
&[p("id", Ty::Str, "widget id from ui"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click (slots)")], mcp: true, local: false),
&[p("id", Ty::Str, "widget id from ui"), opt("right", Ty::Bool, "right-click"), opt("middle", Ty::Bool, "middle-click"), opt("shift", Ty::Bool, "shift-click (slots)")], mcp: true, local: false),
cmd!("set", Screens, "set ID VALUE", "move a slider of ui to VALUE (0..1), as a click on its track",
"The option behind it rounds to its own steps; read the new label with ui.",
["horizon-ctl set fov 0.5", "horizon-ctl set render-distance 0"],
@@ -54,7 +54,7 @@ pub fn prepared_resources(dir: &Path) -> Option<PathBuf> {
.ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.and_then(|v| v["minecraft"]["version"].as_str().map(str::to_owned))
.unwrap_or_else(|| "26.3".into());
.unwrap_or_else(|| horizon_core::VANILLA_VERSION.into());
let resources = dir.join(".local/horizon-client").join(version);
resources
.join("horizon-resources.json")
@@ -130,7 +130,7 @@ impl Session {
"initialize" => Ok(json!({
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": env!("CARGO_PKG_VERSION") },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
})),
"ping" => Ok(json!({})),
+99
View File
@@ -182,3 +182,102 @@ impl Biomes {
}
}
}
/// A 24-bit colour as sRGB components in 0..1.
pub fn rgb(c: u32) -> [f32; 3] {
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
}
/// `GaussianSampler.GAUSSIAN_SAMPLE_KERNEL`.
const GAUSSIAN_KERNEL: [f64; 7] = [0.0, 1.0, 4.0, 6.0, 4.0, 1.0, 0.0];
/// `EnvironmentAttributeProbe.tick`: `GaussianSampler.sample` over the noise
/// biomes (quart cells) around `position`, in blocks. Each biome comes once
/// with its summed weight, in the order it was first sampled, as
/// `SpatialAttributeInterpolator` keeps them (zero weights included).
pub fn gaussian_weights(
position: [f64; 3],
mut biome_at_quart: impl FnMut(i32, i32, i32) -> u16,
) -> Vec<(u16, f64)> {
let p = position.map(|c| c * 0.25 - 0.5);
let integral = p.map(|c| c.floor() as i32);
let relative: [f64; 3] = std::array::from_fn(|i| p[i] - f64::from(integral[i]));
let weight = |axis: usize, i: usize| {
let (start, end) = (GAUSSIAN_KERNEL[i + 1], GAUSSIAN_KERNEL[i]);
start + relative[axis] * (end - start)
};
let mut weights: Vec<(u16, f64)> = Vec::new();
for z in 0..6 {
let wz = weight(2, z);
for x in 0..6 {
let wx = weight(0, x);
for y in 0..6 {
let wy = weight(1, y);
let biome = biome_at_quart(
integral[0] - 2 + x as i32,
integral[1] - 2 + y as i32,
integral[2] - 2 + z as i32,
);
let w = wx * wy * wz;
match weights.iter_mut().find(|(b, _)| *b == biome) {
Some((_, total)) => *total += w,
None => weights.push((biome, w)),
}
}
}
}
weights
}
/// `SpatialAttributeInterpolator.applyAttributeLayer` for an RGB colour
/// (`ARGB.srgbLerp`: a component-wise lerp of the sRGB values).
pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3]) -> [f32; 3] {
let mut result: Option<[f32; 3]> = None;
let mut accumulated = 0.0;
for &(biome, weight) in weights {
let v = value(biome);
accumulated += weight;
result = Some(match result {
None => v,
Some(r) => {
let t = (weight / accumulated) as f32;
std::array::from_fn(|i| r[i] + (v[i] - r[i]) * t)
}
});
}
result.unwrap_or([0.0; 3])
}
#[cfg(test)]
mod blend_tests {
use super::*;
#[test]
fn one_biome_keeps_its_colour() {
let weights = gaussian_weights([10.3, 64.0, -7.9], |_, _, _| 4);
assert_eq!(weights.len(), 1);
// The kernel sums to 16 per axis whatever the offset.
assert!((weights[0].1 - 16f64.powi(3)).abs() < 1e-9);
assert_eq!(blend_rgb(&weights, |_| [0.2, 0.4, 0.6]), [0.2, 0.4, 0.6]);
}
#[test]
fn colour_changes_gradually_across_a_boundary() {
// Biome 1 for quart x >= 0 (block x >= 0), biome 0 below.
let at = |x: f64| {
let w = gaussian_weights([x, 40.0, 0.0], |qx, _, _| u16::from(qx >= 0));
blend_rgb(&w, |b| if b == 1 { [1.0, 0.0, 0.0] } else { [0.0; 3] })[0]
};
assert_eq!(at(-12.0), 0.0);
assert_eq!(at(12.0), 1.0);
// Halfway at the boundary, rising steadily through it.
assert!((at(0.0) - 0.5).abs() < 1e-5, "{}", at(0.0));
let mut last = 0.0;
for i in -16..=16 {
let v = at(f64::from(i) * 0.5);
assert!(v >= last - 1e-6, "{v} < {last} at {i}");
last = v;
}
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
}
}
@@ -939,7 +939,14 @@ impl BlockEntityRenderer {
!matches!(self.kind(state), Kind::None)
}
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
pub fn render_breaking(&self, scene: &Scene, pos: [i32; 3], state: u32, out: &mut Vec<Vertex>) {
pub fn render_breaking(
&self,
scene: &Scene,
pos: [i32; 3],
state: u32,
stage: [f32; 2],
out: &mut Vec<Vertex>,
) {
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
return;
}
@@ -958,7 +965,7 @@ impl BlockEntityRenderer {
false,
out,
);
crate::breaking::entity_geometry(&mut out[start..], origin);
crate::breaking::entity_geometry(&mut out[start..], origin, stage);
}
/// `ChestRenderer.extractRenderState`'s openness for the chest of `state`
/// at `pos`: `ChestBlock.combine` (`DoubleBlockCombiner.combineWithNeigbour`)
@@ -1942,7 +1949,7 @@ mod tests {
let mut base = Vec::new();
renderer.render_block(&scene, [0; 3], state, Mat4::IDENTITY, 0xff, &mut base);
let mut cracks = Vec::new();
renderer.render_breaking(&scene, [0; 3], state, &mut cracks);
renderer.render_breaking(&scene, [0; 3], state, [0.0; 2], &mut cracks);
assert!(!cracks.is_empty());
assert_eq!(
base.iter().map(|v| v.pos).collect::<Vec<_>>(),
@@ -1956,7 +1963,7 @@ mod tests {
scene.lid = &|_| 1.0;
}
let mut animated = Vec::new();
renderer.render_breaking(&scene, [0; 3], state, &mut animated);
renderer.render_breaking(&scene, [0; 3], state, [0.0; 2], &mut animated);
assert!(cracks.iter().zip(&animated).any(|(a, b)| a.pos != b.pos));
scene.game_time = 0;
scene.lid = &|_| 0.0;
+47 -9
View File
@@ -23,7 +23,21 @@ pub fn decal_uv([x, y, z]: [f32; 3], direction: Direction) -> [f32; 2] {
[-p[0], -p[1]]
}
pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out: &mut Vec<Vertex>) {
/// The light bytes of a crack vertex: the pass is unlit, so they carry the origin of the
/// vertex's destroy stage sprite (each crack in `LevelRenderer.destroyingBlocks` has its own
/// stage; every stage sprite has the same size, `crumbling_sprite.zw`).
pub fn stage_light([u0, v0]: [f32; 2]) -> u32 {
let q = |x: f32| (x.clamp(0.0, 1.0) * 65535.0).round() as u32;
q(u0) | (q(v0) << 16)
}
pub fn geometry(
model: &BakedModel,
origin: glam::Vec3,
translucent: bool,
stage: [f32; 2],
out: &mut Vec<Vertex>,
) {
// Vanilla visits all six cull groups and then the unculled faces, even beside solid neighbours.
for cull in Direction::ALL.map(Some).into_iter().chain([None]) {
for face in model.faces.iter().filter(|face| face.quad.cull == cull) {
@@ -33,7 +47,7 @@ pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out:
uv: decal_uv(pos, face.quad.facing),
layer: if translucent { 12 } else { 11 },
color: 0xffff_ffff,
light: 240 | (240 << 8),
light: stage_light(stage),
normal: [0; 4],
});
}
@@ -42,7 +56,7 @@ pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out:
}
/// Project a block entity's transformed quads onto the same destroy-stage pass.
pub fn entity_geometry(vertices: &mut [Vertex], origin: glam::Vec3) {
pub fn entity_geometry(vertices: &mut [Vertex], origin: glam::Vec3, stage: [f32; 2]) {
for quad in vertices.as_chunks_mut::<4>().0 {
let positions = [0, 1, 2].map(|i| glam::Vec3::from(quad[i].pos));
let normal = (positions[1] - positions[0]).cross(positions[2] - positions[0]);
@@ -65,7 +79,7 @@ pub fn entity_geometry(vertices: &mut [Vertex], origin: glam::Vec3) {
vertex.uv = decal_uv((glam::Vec3::from(vertex.pos) - origin).to_array(), facing);
vertex.layer = 11;
vertex.color = 0xffff_ffff;
vertex.light = 240 | (240 << 8);
vertex.light = stage_light(stage);
}
}
}
@@ -73,6 +87,24 @@ pub fn entity_geometry(vertices: &mut [Vertex], origin: glam::Vec3) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_origin_survives_the_light_bytes() {
// entity.wgsl's `wrap`: the low and high 16 bits over 65535.
for origin in [[0.0, 0.0], [0.5, 0.25], [0.123_456, 0.987_654], [1.0, 1.0]] {
let light = stage_light(origin);
let back = [
(light & 0xffff) as f32 / 65535.0,
(light >> 16) as f32 / 65535.0,
];
for k in 0..2 {
assert!(
(back[k] - origin[k]).abs() <= 0.5 / 65535.0,
"{origin:?} -> {back:?}"
);
}
}
}
use crate::models::catalog::BakedFace;
use horizon_assets::{bake::ModelQuad, model::Material};
fn slab_top() -> BakedModel {
@@ -109,7 +141,13 @@ mod tests {
fn slab_overlay_follows_baked_top_not_unit_cube() {
let model = slab_top();
let mut vertices = Vec::new();
geometry(&model, glam::Vec3::new(2.0, 3.0, 4.0), false, &mut vertices);
geometry(
&model,
glam::Vec3::new(2.0, 3.0, 4.0),
false,
[0.0; 2],
&mut vertices,
);
assert_eq!(vertices.len(), 4);
for (v, p) in vertices.iter().zip(model.faces[0].quad.positions) {
assert_eq!(v.pos, [p[0] + 2.0, p[1] + 3.0, p[2] + 4.0]);
@@ -120,14 +158,14 @@ mod tests {
fn entity_overlay_uses_block_local_projected_uvs() {
let origin = glam::Vec3::new(12.0, -3.0, 8.0);
let mut expected = Vec::new();
geometry(&slab_top(), origin, false, &mut expected);
geometry(&slab_top(), origin, false, [0.0; 2], &mut expected);
let mut actual = expected.clone();
for vertex in &mut actual {
vertex.uv = [0.0; 2];
vertex.layer = 0;
vertex.color = 0;
}
entity_geometry(&mut actual, origin);
entity_geometry(&mut actual, origin, [0.0; 2]);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&actual),
bytemuck::cast_slice::<_, u8>(&expected)
@@ -143,7 +181,7 @@ mod tests {
#[test]
fn translucent_overlay_has_its_own_pass() {
let mut vertices = Vec::new();
geometry(&slab_top(), glam::Vec3::ZERO, true, &mut vertices);
geometry(&slab_top(), glam::Vec3::ZERO, true, [0.0; 2], &mut vertices);
assert!(vertices.iter().all(|v| v.layer == 12));
}
#[test]
@@ -151,7 +189,7 @@ mod tests {
let mut model = slab_top();
model.faces.clear();
let mut vertices = Vec::new();
geometry(&model, glam::Vec3::ZERO, false, &mut vertices);
geometry(&model, glam::Vec3::ZERO, false, [0.0; 2], &mut vertices);
assert!(vertices.is_empty());
}
}
+68 -13
View File
@@ -126,6 +126,8 @@ pub struct Chunk {
/// the highest non-air block, or `MIN_Y`. Built on the first read; a
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: OnceLock<Box<[i16; 256]>>,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -155,6 +157,7 @@ impl Chunk {
.map(|_| Arc::new(Section::default()))
.collect(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
light_loaded: false,
fresh: false,
@@ -197,13 +200,17 @@ impl Chunk {
return v;
}
let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v);
// `Heightmap.update`: a write two or more below the surface can't
// move it. Any other change rebuilds the map on the next read.
if previous != v
&& let Some(surface) = self.surface.get()
&& y >= i32::from(surface[column(x, z)]) - 1
{
self.surface = OnceLock::new();
// `Heightmap.update`: a write two or more below a map's height can't
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
}
}
previous
}
@@ -211,12 +218,19 @@ impl Chunk {
/// chunk): one above the highest block that isn't `air`, or `MIN_Y`.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
let surface = self.surface.get_or_init(|| self.prime_surface(air));
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
}
/// `Heightmap.primeHeightmaps` for WORLD_SURFACE: each section from the
/// top, skipping those of one air state, until every column is found.
fn prime_surface(&self, air: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or `MIN_Y`. Kept as `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
fn prime(&self, counts: impl Fn(u32) -> bool) -> Box<[i16; 256]> {
let mut surface = Box::new([MIN_Y as i16; 256]);
let mut found = [false; 256];
let mut left = 256;
@@ -225,7 +239,7 @@ impl Chunk {
if left == 0 {
break;
}
if section.blocks.uniform().is_some_and(&air) {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
section.unpack(&mut values);
@@ -234,7 +248,7 @@ impl Chunk {
if found[i] {
continue;
}
if let Some(dy) = (0..16).rev().find(|&dy| !air(values[dy << 8 | i])) {
if let Some(dy) = (0..16).rev().find(|&dy| counts(values[dy << 8 | i])) {
surface[i] = (base + dy as i32 + 1) as i16;
found[i] = true;
left -= 1;
@@ -358,4 +372,45 @@ mod tests {
assert!(chunk.surface.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
fn motion_blocking_follows_writes() {
let (air, grass, water, stone) = (0, 3, 5, 1);
let blocks = |v: u32| v == water || v == stone;
let scan = |c: &Chunk, x: i32, z: i32| {
(MIN_Y..MIN_Y + HEIGHT)
.rev()
.find(|&y| blocks(c.get(x, y, z)))
.map_or(MIN_Y, |y| y + 1)
};
let mut chunk = Chunk::empty(0, 0);
assert_eq!(chunk.motion_blocking(5, 5, blocks), MIN_Y);
for y in MIN_Y..MIN_Y + 70 {
for i in 0..256 {
chunk.set(i & 15, y, i >> 4, stone);
}
}
for i in 0..4096 {
Arc::make_mut(&mut chunk.sections[8]).set(i, grass);
}
let mut random = horizon_core::random::LegacyRandom::new(11);
use horizon_core::random::RandomSource;
for step in 0..4000 {
let x = random.next_int_bound(16).unwrap();
let z = random.next_int_bound(16).unwrap();
let y = MIN_Y + 40 + random.next_int_bound(120).unwrap();
let v = [air, grass, water, stone][random.next_int_bound(4).unwrap() as usize];
chunk.set(x, y, z, v);
if step % 7 == 0 {
for i in 0..256 {
assert_eq!(
chunk.motion_blocking(i & 15, i >> 4, blocks),
scan(&chunk, i & 15, i >> 4)
);
chunk.world_surface(i & 15, i >> 4, |v| v == air);
}
}
}
}
}
+47 -3
View File
@@ -301,6 +301,9 @@ pub struct FixedStep {
/// A server's client: its level's tick rate only slows the timer down
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
client: bool,
/// How many times `/tick step` started or stopped a step
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
step_changes: u32,
}
impl Default for FixedStep {
@@ -316,6 +319,7 @@ impl Default for FixedStep {
aggregated: 0,
tick_count: 0,
client: false,
step_changes: 0,
}
}
}
@@ -372,6 +376,12 @@ impl FixedStep {
pub fn fraction(&self) -> f64 {
f64::from(self.timer.game_time_delta_partial_tick(false))
}
/// The camera entity's partial tick (`Camera.getCameraEntityPartialTicks`
/// for a player, who is never frozen): `getGameTimeDeltaPartialTick(true)`,
/// so the view and the hand stay smooth in a frozen level.
pub fn camera_fraction(&self) -> f64 {
f64::from(self.timer.game_time_delta_partial_tick(true))
}
/// After a frame: whether the game is paused by a screen (the residual
/// stops accumulating) and whether the level is frozen.
pub fn end_frame(&mut self, pause: bool) {
@@ -438,11 +448,21 @@ impl FixedStep {
}
/// `stepGameIfPaused`: only works while frozen.
pub fn start_step(&mut self, ticks: u32) -> bool {
self.manager
.step_game_if_paused(ticks.min(i32::MAX as u32) as i32)
let started = self
.manager
.step_game_if_paused(ticks.min(i32::MAX as u32) as i32);
self.step_changes += u32::from(started);
started
}
pub fn stop_step(&mut self) -> bool {
self.manager.stop_stepping()
let stopped = self.manager.stop_stepping();
self.step_changes += u32::from(stopped);
stopped
}
/// What `ClientboundTickingStepPacket` carries (`frozenTicksToRun`), and
/// a count that changes each time `updateStepTicks` would broadcast it.
pub fn step_state(&self) -> (u32, i32) {
(self.step_changes, self.manager.base.frozen_ticks_to_run())
}
/// Record how long a tick took (`MinecraftServer.tickServer` bookkeeping
/// and `endTickWork`).
@@ -506,6 +526,30 @@ mod tests {
assert!(!m.base.runs_normally());
}
#[test]
fn the_camera_keeps_interpolating_in_a_frozen_level() {
let mut clock = FixedStep::default();
clock.set_frozen(true);
clock.world_runs();
clock.advance(std::time::Duration::from_millis(25));
clock.end_frame(false);
assert_eq!(clock.fraction(), 1.0, "frozen entities stay put");
assert_eq!(clock.camera_fraction(), 0.5, "the player does not");
}
#[test]
fn only_a_started_or_stopped_step_is_broadcast() {
let mut clock = FixedStep::default();
assert!(!clock.start_step(5), "not frozen");
assert_eq!(clock.step_state(), (0, 0));
clock.set_frozen(true);
assert!(clock.start_step(5));
assert_eq!(clock.step_state(), (1, 5));
clock.world_runs();
assert_eq!(clock.step_state(), (1, 4), "a step tick is not broadcast");
assert!(clock.stop_step());
assert!(!clock.stop_step());
assert_eq!(clock.step_state(), (2, 0));
}
#[test]
fn a_servers_tick_rate_only_slows_its_client_down() {
let quarter = std::time::Duration::from_millis(250);
let mut client = FixedStep::client();
+26 -3
View File
@@ -15,8 +15,9 @@ use horizon_world::{
entity_obstruction::EntityIdentity,
fluid::{FluidKind, FluidState},
liquid_collision::LiquidCollisionWorld,
piston_shape::PistonCollisionWorld,
powder_snow_collision::{PowderSnowEntity, PowderSnowWorld},
registry::{BlockRegistry, BlockTableFile},
registry::{BlockRegistry, BlockStateId, BlockTableFile},
world_movement::{
CollisionCheckRequest, EntityMovementWorld, MovementRequest, no_world_collision,
resolve_world_movement,
@@ -259,8 +260,30 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result<Option<VoxelShape>> {
Ok(None)
/// `MovingPistonBlock.getCollisionShape`: the block entity's moving shape
/// (`PistonMovingBlockEntity.getCollisionShape`), outside any push.
fn moving_piston_shape(&mut self, p: [i32; 3]) -> io::Result<Option<VoxelShape>> {
let Some(&(_, piston)) = self.world.moving_pistons.iter().find(|(q, _)| *q == p) else {
return Ok(None);
};
let binding = self.binding;
let Some(pistons) = binding.pistons() else {
return Ok(None);
};
pistons
.collision_shape(self, p, piston, None)
.map(Some)
.map_err(io::Error::other)
}
}
impl PistonCollisionWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
/// `BlockState.getCollisionShape(level, pos)` of the moved state.
fn collision_shape(&mut self, state: BlockStateId, p: [i32; 3]) -> io::Result<VoxelShape> {
let binding = self.binding;
binding
.collision_shape(self, state, p)
.map_err(io::Error::other)
}
}
impl DynamicCollisionWorld for SceneCollisions<'_, '_> {
+8 -1
View File
@@ -36,6 +36,14 @@ pub fn build() -> Dispatcher {
/// The tree Commands registers; `dev` adds the commands vanilla only
/// registers in development builds (raid, warden_spawn_tracker).
pub fn build_with(dev: bool) -> Dispatcher {
let mut d = registered(dev);
apply_vanilla_order(&mut d);
d
}
/// The tree in the order Horizon's code registers it, before
/// [`vanilla_order`](super::vanilla_order) puts children in vanilla's order
/// (tools/generate_command_order.py compares the two).
pub fn registered(dev: bool) -> Dispatcher {
let mut d = Dispatcher::new();
let root = 0;
@@ -403,7 +411,6 @@ pub fn build_with(dev: bool) -> Dispatcher {
super::tree_misc::register(&mut d, root);
super::tree_world::register(&mut d, root);
super::tree_extra::register(&mut d, root);
apply_vanilla_order(&mut d);
d
}
+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);
}
+99 -9
View File
@@ -7,7 +7,7 @@ use crate::{
Result,
item_data::StackData,
items::{ItemStack, Items},
particles::{FireworkExplosion, FireworkShape},
particles::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape},
};
use horizon_world::nbt::Tag;
use serde_json::Value;
@@ -18,12 +18,6 @@ use std::{
sync::Arc,
};
/// `DyeColor.getFireworkColor` in dye order (white to black).
const FIREWORK_COLORS: [u32; 16] = [
15_790_320, 15_435_844, 12_801_229, 6_719_955, 14_602_026, 4_312_372, 14_188_952, 4_408_131,
11_250_603, 2_651_799, 8_073_150, 2_437_522, 5_320_730, 3_887_386, 11_743_532, 1_973_019,
];
/// A set of accepted items (expanded from an item, list or tag), and the
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
#[derive(Clone, Debug)]
@@ -965,7 +959,7 @@ impl Special {
} else if trail.test(s) {
has_trail = true;
} else if dye.test(s) {
colors.push(FIREWORK_COLORS[ctx.dye(s).unwrap_or(0)]);
colors.push(DYE_FIREWORK_COLORS[ctx.dye(s).unwrap_or(0)].1);
}
}
let mut out = result.create();
@@ -987,7 +981,7 @@ impl Special {
let mut target_stack = None;
for s in &stacks {
if dye.test(s) {
colors.push(FIREWORK_COLORS[ctx.dye(s).unwrap_or(0)]);
colors.push(DYE_FIREWORK_COLORS[ctx.dye(s).unwrap_or(0)].1);
} else if target.test(s) {
target_stack = Some(*s);
}
@@ -1108,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 {
@@ -1427,6 +1425,7 @@ impl Recipes {
meta,
facts,
curses,
cook_index: Default::default(),
})
}
/// Match a `size`x`size` grid (row-major) against the crafting recipes.
@@ -1612,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))
@@ -1631,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(),
@@ -1638,6 +1727,7 @@ mod tests {
meta: HashMap::new(),
facts: HashMap::new(),
curses: HashSet::new(),
cook_index: Default::default(),
};
let ctx = Context {
recipes: &recipes,
+31 -8
View File
@@ -105,18 +105,23 @@ impl Report {
}
}
/// The release tag (the launcher installs each build in a folder named after it) and the client's
/// own version, or just the latter.
/// The release this binary is (`v0.2.3`); for a release from before 0.2, its tag and the client's
/// crate version; for a build from source, that version alone.
pub fn version() -> String {
let version = env!("CARGO_PKG_VERSION");
match release_tag() {
Some(tag) => format!("{tag} (client {version})"),
None => version.to_owned(),
match (horizon_core::release::RELEASE, release_tag()) {
(Some(_), Some(tag)) => tag,
(None, Some(tag)) => format!("{tag} (client {version})"),
(_, None) => version.to_owned(),
}
}
/// The release this binary came with: launcher installs live in a folder named after the tag.
/// The release this binary came with: the version release.yml built in (docs/versioning.md), else
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
pub fn release_tag() -> Option<String> {
if let Some(version) = horizon_core::release::RELEASE {
return Some(format!("v{version}"));
}
let exe = std::env::current_exe().ok()?;
let folder = exe.parent()?.file_name()?.to_str()?;
(folder.starts_with('v') && folder[1..].starts_with(|c: char| c.is_ascii_digit()))
@@ -241,7 +246,10 @@ fn memory() -> String {
pub fn system_details() -> Vec<(String, String)> {
let mut out = vec![
("Horizon Version".to_owned(), version()),
("Minecraft Version".to_owned(), "26.3".to_owned()),
(
"Minecraft Version".to_owned(),
horizon_core::VANILLA_VERSION.to_owned(),
),
("Operating System".to_owned(), operating_system()),
("CPU".to_owned(), cpu()),
("Memory".to_owned(), memory()),
@@ -514,6 +522,18 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
match horizon_core::release::RELEASE {
Some(version) => {
assert_eq!(release_tag(), Some(format!("v{version}")));
assert_eq!(super::version(), format!("v{version}"));
}
None => assert!(release_tag().is_none() || super::version().contains("(client ")),
}
}
fn report() -> Report {
Report {
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
@@ -605,7 +625,10 @@ mod tests {
assert!(first.contains("crash.rs"), "{}", report.stack);
let text = report.text();
assert!(text.contains("Description: Unexpected error"));
assert!(text.contains("\tMinecraft Version: 26.3\n"));
assert!(text.contains(&format!(
"\tMinecraft Version: {}\n",
horizon_core::VANILLA_VERSION
)));
assert!(text.contains("\tOperating System: "));
assert!(text.contains("\tCPU: "));
let home = std::env::temp_dir().join(format!("horizon-crash-save-{}", std::process::id()));
+1
View File
@@ -44,6 +44,7 @@ pub struct Creative {
search_index: Vec<SearchEntry>,
}
/// Display names from the pinned en_us language file.
#[derive(Default)]
pub struct Names {
map: HashMap<String, String>,
}
+133
View File
@@ -0,0 +1,133 @@
//! Horizon Extras' dynamic lights (docs/extras.md): a torch, lantern or other light source held by
//! a player or mob, dropped as an item, or an entity on fire lights the blocks and entities
//! around it. Not in vanilla; off by default.
//!
//! Client-only and at render time: the light engine, chunk meshes and saves never see it. Each
//! frame the game picks the [`MAX`] sources nearest the camera; terrain.wgsl and entity.wgsl
//! raise a vertex's block light to `level - distance` (one level per block, as block light falls
//! off) when that is brighter. Off, the list is empty and the shaders skip the loop.
use crate::blocks::BlockFacts;
use glam::DVec3;
use horizon_world::registry::BlockRegistry;
use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// What an entity on fire gives off, as a lit campfire or fire block does.
pub const BURNING: u8 = 15;
/// Sources farther than this from the camera light nothing it can see well.
const REACH: f64 = 96.0;
/// How brightly each item glows when held or dropped.
#[derive(Debug, Default)]
pub struct Luminance(HashMap<String, u8>);
impl Luminance {
/// A block item glows like its block's default state (`getLightEmission`): torches 14,
/// lanterns and glowstone 15, a lit redstone torch 7; a furnace's item, unlit, not at all.
pub fn new(facts: &BlockFacts, registry: &BlockRegistry) -> Self {
let mut map = HashMap::new();
for (id, state) in facts.states.iter().enumerate() {
if state.emission == 0 || *state.item != *state.name {
continue;
}
if registry.default_state(&state.name).map(|d| d.0 as usize) == Some(id) {
map.insert(state.item.to_string(), state.emission);
}
}
// A bucket of lava glows as lava does.
map.insert("minecraft:lava_bucket".into(), 15);
Self(map)
}
pub fn of(&self, item: &str) -> u8 {
self.0.get(item).copied().unwrap_or(0)
}
#[cfg(test)]
fn from_pairs(pairs: &[(&str, u8)]) -> Self {
Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect())
}
}
/// One light this frame: where it is (world space) and its level, 1 to 15.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Source {
pub at: DVec3,
pub level: u8,
}
/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative
/// position and level. Overwrites `out`.
pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) {
out.clear();
let key = |s: &Source| s.at.distance_squared(camera);
let mut near: Vec<&Source> = sources
.iter()
.filter(|s| s.level > 0 && key(s) <= REACH * REACH)
.collect();
near.sort_by(|a, b| key(a).total_cmp(&key(b)));
out.extend(near.into_iter().take(MAX).map(|s| {
let p = (s.at - camera).as_vec3();
[p.x, p.y, p.z, f32::from(s.level.min(15))]
}));
}
/// The block light a vertex at `pos` (camera-relative) gets from `lights`, 0 to 15: the WGSL
/// `dynamic_block_light` in Rust, for tests.
pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
lights.iter().fold(0.0f32, |level, l| {
let d =
((pos[0] - l[0]).powi(2) + (pos[1] - l[1]).powi(2) + (pos[2] - l[2]).powi(2)).sqrt();
level.max(l[3] - d)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_sources_win() {
let camera = DVec3::new(100.0, 64.0, 100.0);
let mut sources: Vec<Source> = (0..40)
.map(|i| Source {
at: camera + DVec3::new(f64::from(i), 0.0, 0.0),
level: 14,
})
.collect();
sources.push(Source {
at: camera + DVec3::new(0.5, 0.0, 0.0),
level: 0,
});
sources.push(Source {
at: camera + DVec3::new(0.0, 0.0, 200.0),
level: 15,
});
let mut out = Vec::new();
select(&sources, camera, &mut out);
assert_eq!(out.len(), MAX);
assert_eq!(out[0], [0.0, 0.0, 0.0, 14.0]);
assert_eq!(out[MAX - 1][0], (MAX - 1) as f32);
select(&[], camera, &mut out);
assert!(out.is_empty());
}
#[test]
fn light_falls_off_a_level_per_block() {
let torch = [[0.0, 0.0, 0.0, 14.0]];
assert_eq!(level_at([0.0, 0.0, 0.0], &torch), 14.0);
assert_eq!(level_at([3.0, 4.0, 0.0], &torch), 9.0);
assert_eq!(level_at([20.0, 0.0, 0.0], &torch), 0.0);
let two = [[0.0, 0.0, 0.0, 14.0], [10.0, 0.0, 0.0, 15.0]];
assert_eq!(level_at([8.0, 0.0, 0.0], &two), 13.0);
assert_eq!(level_at([0.0; 3], &[]), 0.0);
}
#[test]
fn item_luminance() {
let l = Luminance::from_pairs(&[("minecraft:torch", 14)]);
assert_eq!(l.of("minecraft:torch"), 14);
assert_eq!(l.of("minecraft:stone"), 0);
}
}
+4 -1
View File
@@ -60,6 +60,7 @@ pub struct ItemProps {
/// `minecraft:brewing_fuel` present.
pub brewing_fuel: bool,
}
#[derive(Default)]
pub struct Enchantments {
pub list: Vec<Enchantment>,
index: HashMap<Arc<str>, usize>,
@@ -192,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.")
@@ -536,7 +539,7 @@ impl Enchantments {
{
out.push((
a["attribute"].as_str().unwrap_or("").to_owned(),
format!("{}.{}", a["id"].as_str().unwrap_or(""), level),
a["id"].as_str().unwrap_or("").to_owned(),
f64::from(level_value(&a["amount"], *level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
));
+313 -1
View File
@@ -24,6 +24,9 @@ pub struct ItemEntity {
pub age: u32,
pub pickup_delay: u32,
pub bob_offset: f32,
/// `Entity.tickCount`: ticks since the item entered the level, which
/// paces `ItemEntity.mergeWithNeighbours`.
pub ticks: u32,
}
/// Environment results needed by the entity owner, including lethal lava damage.
#[derive(Debug)]
@@ -102,6 +105,7 @@ impl ItemEntity {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
) -> bool {
self.ticks += 1;
self.previous = self.position;
if self.pickup_delay > 0 && self.pickup_delay != u32::MAX {
self.pickup_delay -= 1;
@@ -175,6 +179,130 @@ impl ItemEntity {
self.age += 1;
self.age < 6000 && self.position.y > -128.0
}
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
/// delay isn't infinite, before its lifetime ends.
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
}
}
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
/// search around an item. Holds indexes into the tick's item list.
#[derive(Default)]
pub struct ItemCells {
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
}
impl ItemCells {
pub fn new(items: &[ItemEntity]) -> Self {
let mut cells = Self::default();
for (i, item) in items.iter().enumerate() {
cells.add(i, item.position);
}
cells
}
fn cell(p: Vec3) -> (i32, i32, i32) {
(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32)
}
fn add(&mut self, i: usize, at: Vec3) {
self.cells.entry(Self::cell(at)).or_default().push(i as u32);
}
pub fn remove(&mut self, i: usize, at: Vec3) {
if let Some(list) = self.cells.get_mut(&Self::cell(at)) {
list.retain(|&j| j as usize != i);
}
}
pub fn moved(&mut self, i: usize, from: Vec3, to: Vec3) {
if Self::cell(from) != Self::cell(to) {
self.remove(i, from);
self.add(i, to);
}
}
/// The items whose cells could hold a box meeting `area`, in the order
/// they were added (vanilla lists a section's entities in the order they
/// entered it).
fn near(&self, area: Aabb, out: &mut Vec<u32>) {
out.clear();
// An item's box reaches 0.125 sideways and 0.25 up from its position.
let (min, max) = (area.min_position(), area.max_position());
let (lo, hi) = (
Self::cell(Vec3::new(min.x - 0.125, min.y - 0.25, min.z - 0.125)),
Self::cell(Vec3::new(max.x + 0.125, max.y, max.z + 0.125)),
);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(list) = self.cells.get(&(x, y, z)) {
out.extend_from_slice(list);
}
}
}
}
out.sort_unstable();
}
}
/// `ItemEntity.mergeWithNeighbours` for `items[i]`, which has just moved in
/// its tick (its `age` already counts this tick; vanilla's doesn't yet): each
/// mergeable item whose box meets this one's grown by 0.5 sideways takes this
/// stack or gives it its own, the smaller into the larger
/// (`ItemEntity.tryToMerge`), until this item is used up. An emptied item is
/// marked in `removed`.
pub fn merge_with_neighbours(
items: &mut [ItemEntity],
removed: &mut [bool],
cells: &mut ItemCells,
i: usize,
max_stack: &dyn Fn(&ItemStack) -> u32,
) {
let age = |items: &[ItemEntity], j: usize| {
if j == i {
items[j].age - 1
} else {
items[j].age
}
};
if removed[i] || !items[i].mergeable(age(items, i), max_stack(&items[i].stack)) {
return;
}
let area = items[i].bounds().inflate(Vec3::new(0.5, 0.0, 0.5));
let mut near = Vec::new();
cells.near(area, &mut near);
for j in near {
let j = j as usize;
if j == i
|| removed[j]
|| !items[j].bounds().intersects(area)
|| !items[j].mergeable(age(items, j), max_stack(&items[j].stack))
{
continue;
}
// ItemEntity.areMergable: both fit one stack of the same item and components.
let (this, other) = (&items[i].stack, &items[j].stack);
if this.count + other.count > max_stack(other) || !this.same_item_same_components(other) {
continue;
}
let (to, from) = if other.count < this.count {
(i, j)
} else {
(j, i)
};
// ItemEntity.merge(toStack, fromStack, 64).
let room = max_stack(&items[to].stack).min(64) - items[to].stack.count;
let moved = room.min(items[from].stack.count);
items[to].stack.count += moved;
items[from].stack.count -= moved;
let (delay, from_age, to_age) =
(items[from].pickup_delay, age(items, from), age(items, to));
let to_item = &mut items[to];
to_item.pickup_delay = to_item.pickup_delay.max(delay);
// This item's age goes up once its tick ends.
to_item.age = to_age.min(from_age) + u32::from(to == i);
if items[from].stack.count == 0 {
removed[from] = true;
cells.remove(from, items[from].position);
}
if removed[i] {
return;
}
}
}
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
#[derive(Clone, Debug)]
@@ -455,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,
}
@@ -475,6 +607,8 @@ impl ArmorStand {
no_base_plate: false,
invisible: false,
marker: false,
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
}
}
@@ -597,7 +731,8 @@ mod fire_environment_tests {
#[test]
fn ordinary_items_burn_but_native_netherite_resists_lava() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
@@ -633,6 +768,7 @@ mod fire_environment_tests {
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
};
let mut wood = make("minecraft:oak_planks");
let mut metal = make("minecraft:netherite_ingot");
@@ -704,3 +840,179 @@ mod fire_environment_tests {
assert_eq!(stand.health, 20.0, "lava does not hurt armor stands");
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
fn item(id: &str, count: u32, x: f64, age: u32) -> ItemEntity {
let at = Vec3::new(x, 64.0, 0.5);
ItemEntity {
id: 0,
thrower: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
age,
pickup_delay: 0,
bob_offset: 0.0,
ticks: age,
}
}
fn max_stack(stack: &ItemStack) -> u32 {
if &*stack.item == "minecraft:ender_pearl" {
16
} else {
64
}
}
/// Merges around `items[i]` as its tick would; returns the counts left.
fn merge(items: &mut [ItemEntity], i: usize) -> Vec<Option<u32>> {
let mut removed = vec![false; items.len()];
let mut cells = ItemCells::new(items);
merge_with_neighbours(items, &mut removed, &mut cells, i, &max_stack);
items
.iter()
.zip(removed)
.map(|(item, gone)| (!gone).then_some(item.stack.count))
.collect()
}
#[test]
fn the_smaller_stack_goes_into_the_larger() {
let mut items = [
item("minecraft:sand", 3, 0.5, 41),
item("minecraft:sand", 5, 1.2, 7),
];
assert_eq!(merge(&mut items, 0), [None, Some(8)]);
// ItemEntity.merge keeps the younger age; the ticking item's is
// still the one before its tick.
assert_eq!(items[1].age, 7);
let mut items = [
item("minecraft:sand", 5, 0.5, 41),
item("minecraft:sand", 3, 1.2, 7),
];
assert_eq!(merge(&mut items, 0), [Some(8), None]);
assert_eq!(items[0].age, 8, "age 7, plus the rest of this tick");
}
#[test]
fn items_merge_only_within_half_a_block_sideways() {
// Boxes 0.25 wide grown by 0.5: centres closer than 0.75 meet.
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 1.24, 1),
];
assert_eq!(merge(&mut items, 0), [None, Some(2)]);
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 1.26, 1),
];
assert_eq!(merge(&mut items, 0), [Some(1), Some(1)]);
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 0.5, 1),
];
items[1].position.y += 0.3;
assert_eq!(
merge(&mut items, 0),
[Some(1), Some(1)],
"no vertical growth"
);
}
#[test]
fn different_or_full_stacks_stay_apart() {
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:gravel", 1, 0.5, 1),
];
assert_eq!(merge(&mut items, 0), [Some(1), Some(1)]);
let mut items = [
item("minecraft:sand", 60, 0.5, 1),
item("minecraft:sand", 10, 0.5, 1),
];
assert_eq!(
merge(&mut items, 0),
[Some(60), Some(10)],
"70 is over a stack"
);
let mut items = [
item("minecraft:ender_pearl", 10, 0.5, 1),
item("minecraft:ender_pearl", 6, 0.5, 1),
];
assert_eq!(merge(&mut items, 0), [Some(16), None]);
let mut items = [
item("minecraft:ender_pearl", 16, 0.5, 1),
item("minecraft:ender_pearl", 1, 0.5, 1),
];
assert_eq!(
merge(&mut items, 0),
[Some(16), Some(1)],
"a full stack doesn't merge"
);
}
#[test]
fn unmergeable_items_are_skipped() {
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 0.5, 1),
];
items[1].pickup_delay = u32::MAX;
assert_eq!(merge(&mut items, 0), [Some(1), Some(1)]);
// At age 6000 the other item's lifetime is over; this one's tick
// started at 5999, so it may still merge into a live item.
let mut items = [
item("minecraft:sand", 1, 0.5, 6000),
item("minecraft:sand", 1, 0.5, 6000),
];
assert_eq!(merge(&mut items, 0), [Some(1), Some(1)]);
items[1].age = 10;
assert_eq!(merge(&mut items, 0), [None, Some(2)]);
assert_eq!(items[1].age, 10);
}
#[test]
fn a_stack_takes_its_neighbours_in_spawn_order_until_full() {
let mut items = [
item("minecraft:sand", 40, 0.5, 1),
item("minecraft:sand", 20, 0.7, 1),
item("minecraft:sand", 10, 0.3, 1),
item("minecraft:sand", 3, 0.4, 1),
];
// 40 + 20 = 60; 60 + 10 is over 64; then 3 more.
assert_eq!(merge(&mut items, 0), [Some(63), None, Some(10), None]);
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 0.5, 1),
];
items[1].pickup_delay = 30;
assert_eq!(merge(&mut items, 0), [None, Some(2)]);
assert_eq!(items[1].pickup_delay, 30, "the longer pickup delay stays");
}
#[test]
fn the_cells_follow_moves() {
let mut items = [
item("minecraft:sand", 1, 0.5, 1),
item("minecraft:sand", 1, 5.5, 1),
];
let mut removed = [false; 2];
let mut cells = ItemCells::new(&items);
let from = items[1].position;
items[1].position.x = 0.9;
cells.moved(1, from, items[1].position);
merge_with_neighbours(&mut items, &mut removed, &mut cells, 0, &max_stack);
assert_eq!(removed, [true, false]);
assert_eq!(items[1].stack.count, 2);
}
}
+165 -4
View File
@@ -14,11 +14,13 @@ struct Globals {
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
// y: Nether cardinal lighting.
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
time: vec4<f32>,
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -41,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 {
@@ -58,20 +61,33 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
}
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
// level less per block. time.z of them are set: none when the extra is off, so no loop runs.
fn dynamic_block_light(pos: vec3<f32>) -> f32 {
let count = min(u32(globals.time.z), 16u);
var level = 0.0;
for (var i = 0u; i < count; i++) {
let l = globals.dynamic_lights[i];
level = max(level, l.w - distance(pos, l.xyz));
}
return level * 16.0;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position, 1.0);
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
out.uv = uv;
let l = vec2(f32(light.y), f32(light.x));
let l = vec2(max(f32(light.y), dynamic_block_light(position)), f32(light.x));
let lm = textureSampleLevel(lightmap, lightmap_sampler,
clamp(l / 256.0 + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0);
// Celestial bodies, clouds, beacon beams, End portals and breaking cracks are unlit.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range.
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
// (cracks): the destroy stage sprite's origin.
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
@@ -94,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>,
@@ -147,7 +170,8 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
uv = globals.crumbling_sprite.xy + fract(in.uv) * globals.crumbling_sprite.zw;
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = in.wrap + fract(in.uv) * globals.crumbling_sprite.zw;
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
if in.overlay == 9u || in.overlay == 10u {
@@ -188,3 +212,140 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@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);
}
+125 -1
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;
@@ -914,9 +924,47 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
bob_offset,
ticks: 0,
})
}
/// `OminousItemSpawner.addAdditionalSaveData`: its item (left out when
/// empty) and `spawn_item_after_ticks`. Its tick count is the entity's
/// `tickCount`, which isn't saved: a reloaded spawner counts from 0 again.
pub fn ominous_item_spawner_to_nbt(
item: &crate::items::ItemStack,
position: Vec3,
spawn_item_after: i64,
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Tag {
let base = Base {
id: "minecraft:ominous_item_spawner",
uuid,
position,
motion: Vec3::ZERO,
rotation: [0.0, 0.0],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "item", item_to_nbt(item));
put(
&mut tag,
"spawn_item_after_ticks",
Tag::Long(spawn_item_after),
);
tag
}
/// `OminousItemSpawner.readAdditionalSaveData`: the item, where it floats
/// and `spawn_item_after_ticks` (0 when absent). None without an item:
/// such a spawner would spawn nothing.
pub fn ominous_item_spawner_from_nbt(tag: &Tag) -> Option<(crate::items::ItemStack, Vec3, i64)> {
let item = tag.get("item").and_then(item_from_nbt)?;
let after = number(tag.get("spawn_item_after_ticks")).unwrap_or(0);
Some((item, position(tag)?, after))
}
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
let base = Base {
@@ -1228,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.
@@ -1256,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);
@@ -1504,6 +1569,63 @@ mod tests {
use super::*;
use crate::items::ItemStack;
/// The item, the spawn delay and the fields Horizon doesn't model come
/// back; a compound without an item is no spawner.
#[test]
fn ominous_item_spawner_round_trip() {
let item = ItemStack {
item: Arc::from("minecraft:ominous_trial_key"),
count: 1,
damage: 0,
extra: None,
};
let raw = Tag::compound([("CustomName", string("kept"))]);
let at = Vec3::new(10.5, 64.25, -3.5);
let tag = ominous_item_spawner_to_nbt(&item, at, 97, [1, 2, 3, 4], Some(&raw));
assert_eq!(
kind(&tag).as_deref(),
Some("minecraft:ominous_item_spawner")
);
assert_eq!(uuid(&tag), Some([1, 2, 3, 4]));
assert!(matches!(
tag.get("spawn_item_after_ticks"),
Some(Tag::Long(97))
));
assert!(tag.get("CustomName").is_some());
let (back, pos, after) = ominous_item_spawner_from_nbt(&tag).unwrap();
assert_eq!(
(back.item.as_ref(), back.count),
("minecraft:ominous_trial_key", 1)
);
assert_eq!((pos, after), (at, 97));
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"))]);
@@ -1553,6 +1675,7 @@ mod tests {
on_ground: true,
age: 120,
pickup_delay: 10,
ticks: 0,
bob_offset: 0.0,
};
let tag = item_entity_to_nbt(&e, [1, 2, 3, 4], None);
@@ -1761,7 +1884,8 @@ mod axolotl_nbt_tests {
#[test]
fn bucket_cooldown_and_brain_memories_survive_and_expired_cooldown_is_removed() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
+14 -10
View File
@@ -230,16 +230,20 @@ impl Environment {
fn base(&self, name: &str) -> [f32; 4] {
self.base.get(name).copied().unwrap_or([0.0, 0.0, 0.0, 1.0])
}
/// Sky state for a time of day, with the biome's sky and fog colours as base values.
pub fn sample(&self, ticks: i64, biome_sky: Option<u32>, biome_fog: Option<u32>) -> Sky {
let rgb = |c: u32| {
[
(c >> 16 & 255) as f32 / 255.0,
(c >> 8 & 255) as f32 / 255.0,
(c & 255) as f32 / 255.0,
1.0,
]
};
/// The dimension's value of an RGB attribute (what biomes without it keep).
pub fn base_rgb(&self, name: &str) -> [f32; 3] {
let b = self.base(name);
[b[0], b[1], b[2]]
}
/// Sky state for a time of day, with the biomes' (blended) sky and fog
/// colours as base values.
pub fn sample(
&self,
ticks: i64,
biome_sky: Option<[f32; 3]>,
biome_fog: Option<[f32; 3]>,
) -> Sky {
let rgb = |c: [f32; 3]| [c[0], c[1], c[2], 1.0];
let v = |name: &str, base: [f32; 4]| self.value(name, base, ticks);
let three = |a: [f32; 4]| [a[0], a[1], a[2]];
let scalar = |name: &str| {
+36 -4
View File
@@ -69,6 +69,8 @@ impl Corner {
#[derive(Clone, Debug, PartialEq)]
pub struct Extras {
pub fullbright: bool,
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
pub dynamic_lights: bool,
pub zoom: bool,
/// A vanilla key name (`key.keyboard.z`).
pub zoom_key: String,
@@ -110,6 +112,7 @@ impl Default for Extras {
fn default() -> Self {
Self {
fullbright: false,
dynamic_lights: false,
zoom: false,
zoom_key: "key.keyboard.z".into(),
zoom_level: 4,
@@ -158,6 +161,7 @@ impl Extras {
};
match key.trim() {
"fullbright" => e.fullbright = flag(e.fullbright),
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
"zoom" => e.zoom = flag(e.zoom),
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
"zoomLevel" => {
@@ -201,8 +205,9 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\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",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
self.zoom_key,
self.zoom_level,
@@ -423,6 +428,7 @@ impl Cps {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Item {
Fullbright,
DynamicLights,
Zoom,
ZoomKey,
ZoomLevel,
@@ -452,6 +458,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
vec![
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
@@ -501,11 +508,12 @@ impl Item {
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::Zoom => "Zoom",
Item::ZoomKey => "Zoom key",
Item::ZoomLevel => "Starting zoom",
Item::ZoomSmooth => "Smooth zoom",
Item::ZoomSensitivity => "Lower sensitivity while zoomed",
Item::ZoomSensitivity => "Slower turning",
Item::Fps => "FPS counter",
Item::FpsCorner | Item::KeystrokesCorner | Item::MinimapCorner => "Corner",
Item::Minimap => "Minimap",
@@ -523,10 +531,29 @@ impl Item {
Item::KeystrokesCps => "Clicks per second",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
pub fn parent(self) -> Option<Item> {
use Item::*;
Some(match self {
ZoomKey | ZoomLevel | ZoomSmooth | ZoomSensitivity => Zoom,
MinimapCorner | MinimapSize | MinimapShape | MinimapZoom | MinimapRotate => Minimap,
FpsCorner | FpsLows => Fps,
KeystrokesCorner | KeystrokesCps => Keystrokes,
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect => {
return None;
}
})
}
/// What the row says under its caption.
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
Item::ZoomLevel => "The zoom the key starts at.",
@@ -566,7 +593,7 @@ impl Item {
use crate::graphics_style::Cost;
match self {
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Minimap => Some(Cost::Low),
Item::Minimap | Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
}
@@ -575,6 +602,7 @@ impl Item {
pub fn switch(self, e: &Extras) -> Option<bool> {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::Zoom => e.zoom,
Item::ZoomSmooth => e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity,
@@ -617,6 +645,7 @@ impl Item {
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::Zoom => e.zoom = !e.zoom,
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
@@ -662,7 +691,7 @@ mod tests {
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
assert!(!d.fullbright);
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert_eq!(Extras::parse(""), d);
@@ -670,6 +699,9 @@ mod tests {
let mut e = d.clone();
Item::Fullbright.cycle(&mut e, false);
assert_eq!(Item::Fullbright.switch(&e), Some(true));
Item::DynamicLights.cycle(&mut e, false);
assert_eq!(Item::DynamicLights.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
e.zoom = true;
e.zoom_key = "key.keyboard.c".into();
e.zoom_level = 6;
+100 -2
View File
@@ -1,9 +1,13 @@
//! FallingBlockEntity's impact damage, anvil wear and saved impact settings.
//! FallingBlockEntity's impact damage, anvil wear, saved impact settings and
//! the cobwebs that hold it back.
use crate::{
mobs::{Damage as MobDamage, DamageKind, Mob},
save::{float, number, put},
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_core::{
geometry::{Aabb, Axis, Direction, Vec3, sweep_intersected_blocks},
position::BlockPos,
};
use horizon_world::{chunk::BlockState, nbt::Tag};
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -97,6 +101,65 @@ pub fn bounds(position: Vec3) -> Aabb {
),
)
}
/// `WebBlock.entityInside`'s `makeStuckInBlock` multiplier: the next move
/// goes at a quarter of the speed across, a twentieth down.
pub fn web_multiplier() -> Vec3 {
Vec3::new(0.25, f64::from(0.05_f32), 0.25)
}
/// `Entity.applyEffectsFromBlocks` after a falling block's move this tick
/// (`from` to `to`; `original` the move it asked for, when the move was
/// recorded): whether `checkInsideBlocks` reaches a cobweb (`is_web`), whose
/// `entityInside` makes it stuck. Each movement sweeps its axes in
/// `Direction.axisStepOrder` within 16 iterations, then the end alone.
pub fn passes_cobweb(
from: Vec3,
to: Vec3,
original: Option<Vec3>,
mut is_web: impl FnMut(BlockPos) -> bool,
) -> bool {
let mut stuck = false;
// checkInsideBlocks(from, to, ..): the iterations used plus one.
let mut check = |from: Vec3, to: Vec3, max: i32| {
let d = f64::from(1.0e-5_f32);
let target = bounds(to).deflate(Vec3::new(d, d, d));
let mut iterations = 0;
let _ = sweep_intersected_blocks(from, to, target, 1_000_000, |p, iteration| {
if iteration >= max {
return false;
}
iterations = iteration;
// getEntityInsideCollisionShape is the whole block for cobwebs.
stuck |= is_web(p);
true
});
iterations + 1
};
let delta = to.subtract(from);
let mut budget = 16;
match original {
Some(original) if delta.length_squared() > 0.0 => {
let mut at = from;
for axis in Axis::step_order(original) {
let step = delta.component(axis);
if step != 0.0 {
let positive = match axis {
Axis::X => Direction::East,
Axis::Y => Direction::Up,
Axis::Z => Direction::South,
};
let next = at.relative(positive, step);
budget -= check(at, next, budget);
at = next;
}
}
}
_ => budget -= check(from, to, 16),
}
if budget <= 0 {
check(to, to, 1);
}
stuck
}
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
if amount <= 0.0 {
return;
@@ -163,6 +226,41 @@ mod tests {
}
}
#[test]
fn a_cobweb_on_the_path_or_at_the_end_holds_a_falling_block() {
let web = BlockPos::new(0, 5, 0);
let is_web = |p: BlockPos| p == web;
let at = |y: f64| Vec3::new(0.5, y, 0.5);
// Falling through the web's block in one move, landing in it, resting in it.
assert!(passes_cobweb(
at(7.2),
at(3.1),
Some(Vec3::new(0.0, -4.1, 0.0)),
is_web
));
assert!(passes_cobweb(
at(6.5),
at(5.5),
Some(Vec3::new(0.0, -1.0, 0.0)),
is_web
));
assert!(passes_cobweb(at(5.5), at(5.5), None, is_web));
// Above it, below it, or beside it.
assert!(!passes_cobweb(
at(8.0),
at(6.01),
Some(Vec3::new(0.0, -1.99, 0.0)),
is_web
));
assert!(!passes_cobweb(
at(4.0),
at(3.0),
Some(Vec3::new(0.0, -1.0, 0.0)),
is_web
));
let beside = Vec3::new(1.5, 5.5, 0.5);
assert!(!passes_cobweb(beside, beside, None, is_web));
}
#[test]
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
use crate::mobs::{Mob, MobTypes};
@@ -21,6 +21,8 @@ pub enum ItemPose {
Trident {
ticks_used: f32,
},
/// Riptide's spin (`isAutoSpinAttack`): the item held up like a fork.
SpinAttack,
Bow {
ticks_used: f32,
},
@@ -67,6 +69,12 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
* Mat4::from_rotation_y((invert * 65.0f32).to_radians())
* Mat4::from_rotation_z((invert * -85.0f32).to_radians());
}
let charging = match pose {
ItemPose::Bow { ticks_used } => {
let power = ticks_used / 20.0;
+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
File diff suppressed because it is too large. Load diff
+52 -7
View File
@@ -91,6 +91,8 @@ pub(super) struct AdvState {
/// The tree being read on another thread since the world opened
/// (`prefetch_advancement_tree`), taken by the first `advancement_tree`.
pub(super) pending_tree: Option<std::thread::JoinHandle<Tree>>,
/// `inventory_changed`'s criteria by item, built for `tree`.
inventory_index: Option<(Arc<Tree>, Arc<adv::InventoryIndex>)>,
}
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
@@ -229,9 +231,31 @@ impl Game<'_> {
_ => Tree::load(&self.options.resources.join("data")),
};
self.adv.tree = Arc::new(tree);
// With the tree, while the world loads: built on the first
// inventory change it cost that tick ~10 ms.
self.inventory_index();
}
self.adv.tree.clone()
}
/// `inventory_changed`'s criteria by item, for the tree in place.
fn inventory_index(&mut self) -> Arc<adv::InventoryIndex> {
let tree = self.adv.tree.clone();
if let Some((built, index)) = &self.adv.inventory_index
&& Arc::ptr_eq(built, &tree)
{
return index.clone();
}
let items = self.res.items();
let index = Arc::new(adv::InventoryIndex::build(&tree, |tag| {
items
.item_tags
.get(tag)
.map(|t| t.iter().map(String::as_str).collect())
.unwrap_or_default()
}));
self.adv.inventory_index = Some((tree, index.clone()));
index
}
/// Start reading the advancement files on another thread while the world
/// loads. The first tick's first criterion trigger used to read and parse
/// all of them on the game thread (about 60 ms).
@@ -394,6 +418,16 @@ impl Game<'_> {
let Some(list) = tree.by_trigger.get(name) else {
return;
};
self.trigger_criteria(&tree, list, test);
}
/// `trigger` for some of a trigger's criteria: (advancement, criterion)
/// indexes in `tree`, in tree order.
fn trigger_criteria(
&mut self,
tree: &Tree,
list: &[(usize, usize)],
test: impl Fn(&Value, &dyn adv::World) -> bool,
) {
let mut hits = Vec::new();
{
let view = View(self);
@@ -437,9 +471,14 @@ impl Game<'_> {
} else {
self.recipe_toast(unlocked);
}
if unlocked.is_empty() || self.remote.is_some() || self.no_player_here() {
return;
}
self.load_advancements();
let tree = self.adv.tree.clone();
for r in unlocked {
let r = r.clone();
self.trigger("minecraft:recipe_unlocked", move |c, _| {
let list = tree.recipe_criteria(r);
self.trigger_criteria(&tree, &list, |c, _| {
c["recipes"].as_str() == Some(r.as_str())
});
}
@@ -560,7 +599,15 @@ impl Game<'_> {
.is_some_and(|i| s.count >= i.max_stack)
})
.count() as f64;
self.trigger("minecraft:inventory_changed", move |c, w| {
// Only the criteria the items held could pass (`InventoryIndex`).
if self.remote.is_some() || self.no_player_here() {
return;
}
self.load_advancements();
let tree = self.adv.tree.clone();
let index = self.inventory_index();
let list = index.candidates(stacks.iter().map(|s| s.id.as_str()));
self.trigger_criteria(&tree, &list, move |c, w| {
let slots = &c["slots"];
let range = |k: &str, v: f64| {
let r = &slots[k];
@@ -965,10 +1012,8 @@ impl Game<'_> {
});
positions.push((a.x, a.y));
}
let mut tree = Tree {
list,
..Tree::default()
};
let mut tree = Tree::default();
tree.list = list;
for (i, a) in tree.list.iter().enumerate() {
tree.by_id.insert(a.id.clone(), i);
}
+12 -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",
}
}
@@ -1840,11 +1841,21 @@ impl Game<'_> {
if self.ticks < *report_tick && !timed_out {
return None;
}
let after = pos.map(|p| self.block_json(p));
let changed = match (*pos, *before) {
(Some(p), Some(b)) => self.world.block(p[0], p[1], p[2]) != b,
_ => false,
};
// A client of a server (single player's server thread, a LAN
// guest) sees the block once the server's answer comes.
if *place
&& !changed
&& self.remote.is_some()
&& self.ticks < *report_tick + 20
&& !timed_out
{
return None;
}
let after = pos.map(|p| self.block_json(p));
let used = delta(inventory, &self.inventory_counts());
let mut v = json!({
"ok": !*place || changed,
+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,
-256
View File
@@ -1,256 +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.
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;
#[derive(Default)]
pub(super) struct Backdrop {
pub active: 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()?;
}
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)
}
fn start_backdrop(&mut self) -> Result<()> {
log::info!(
"Horizon: starting title backdrop after {} ms",
self.started.elapsed().as_millis()
);
let seed = seed();
let root = std::env::current_dir()?
.join("saves")
.join(".horizon-title")
.join("backdrop");
std::fs::create_dir_all(&root)?;
let save = Arc::new(SaveDir::new(root.join(format!("{seed}"))));
let (res, pack) = (&*self.res, &self.pack);
let inner = Arc::new(
VanillaTerrain::new(
&self.options.resources.join("worldgen"),
pack.assets.registry,
res.biomes(),
seed,
)
.map_err(|e| format!("title backdrop: {e}"))?,
);
let mut streaming = Streaming::new(
Arc::new(Persistent {
inner: inner.clone(),
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;
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()
};
// Find (or recall) the spot off the main thread.
let spot = self.backdrop.spot.clone();
std::fs::create_dir_all(root.join(format!("{seed}")))?;
let cache = root.join(format!("{seed}")).join("spot.txt");
std::thread::Builder::new()
.name("horizon-backdrop".into())
.spawn(move || {
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 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();
}
}
+16 -6
View File
@@ -15,6 +15,7 @@ use horizon_client::{
mesher::Vertex,
mobs::render::{DYE, DYE_NAMES},
};
use horizon_protocol::packets::play::{self, Serverbound as S};
/// The beacon screen is 54 px wider than the 176-px menus it shares an origin with.
pub(super) const SHIFT: f32 = -27.0;
@@ -35,9 +36,6 @@ const PAYMENT: [&str; 5] = [
"minecraft:diamond",
"minecraft:netherite_ingot",
];
pub(super) fn is_payment(item: &str) -> bool {
PAYMENT.contains(&item)
}
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
Some(match i {
@@ -171,7 +169,7 @@ impl Game<'_> {
}
}
}
fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]) + 0.5,
@@ -255,9 +253,13 @@ impl Game<'_> {
}
}
fn beacon_levels(&self) -> i32 {
let Some(pos) = self.station.as_ref().map(|s| s.pos) else {
let Some(s) = self.station.as_ref() else {
return 0;
};
if let Some(levels) = s.beacon_levels {
return levels;
}
let pos = s.pos;
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Beacon(b)) => b.levels,
_ => 0,
@@ -346,8 +348,16 @@ impl Game<'_> {
self.screen = super::Screen::Playing;
self.capture(true);
}
/// `BeaconMenu.updateEffects`: spend the payment and set the powers.
/// `BeaconMenu.updateEffects`: spend the payment and set the powers. On
/// a server, `BeaconScreen`'s confirm button sends them
/// (`ServerboundSetBeaconPacket`) and the server's menu does it.
fn confirm_beacon(&mut self, sel: [Option<usize>; 2]) {
if self.remote.is_some() {
let id = |e: Option<usize>| e.and_then(|e| self.remote_effect_network_id(EFFECTS[e].0));
let (primary, secondary) = (id(sel[0]), id(sel[1]));
self.send(S::SetBeacon(play::SetBeacon { primary, secondary }));
return;
}
let Some(s) = self.station.as_mut() else {
return;
};
@@ -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
+30 -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 {
@@ -141,6 +141,8 @@ pub(super) fn boat_wood(item: &str) -> Option<&str> {
pub(super) struct BoatModels {
pub models: HashMap<String, Part>,
pub chest_models: HashMap<String, Part>,
/// `ModelLayers.BOAT_WATER_PATCH`, shared by every boat (not rafts).
pub water_patch: Option<Part>,
}
impl BoatModels {
/// Whether a boat of this wood (with or without a chest) can be drawn.
@@ -175,6 +177,7 @@ impl BoatModels {
Self {
models,
chest_models,
water_patch: document.and_then(|d| d.model("minecraft:boat#water_patch")),
}
}
}
@@ -292,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;
@@ -445,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;
@@ -656,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);
@@ -785,6 +795,23 @@ impl Game<'_> {
0,
out,
);
// BoatRenderer.submitTypeAdditions: the water patch hides the water
// inside the hull unless the boat is under water (RaftRenderer has none).
if let Some(patch) = self.boat_models.water_patch.as_ref()
&& &*b.wood != "bamboo"
&& !matches!(b.status, Status::UnderWater | Status::UnderFlowingWater)
{
horizon_client::mobs::render::draw(
patch,
base,
&Poses::new(patch),
uv,
0xFFFF_FFFF,
packed,
horizon_client::gfx::WATER_MASK_OVERLAY,
out,
);
}
self.cull_entity_vertices(out, entity_start);
}
}
+219 -47
View File
@@ -9,6 +9,7 @@ use horizon_client::{
hud::{HudBatch, rgba},
item_data::WrittenBook,
items::ItemStack,
menu::{Kind, Menu},
text::Text,
ui,
};
@@ -89,6 +90,17 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_book(&mut self) {
// A server's lectern: LecternScreen's menu closes (closeContainer).
if matches!(
self.book,
Some(BookScreen::View {
lectern: Some(_),
..
})
) && self.remote.is_some()
{
self.remote_close_container();
}
self.book = None;
self.capture(true);
}
@@ -241,7 +253,7 @@ impl Game<'_> {
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
/// the 1024 characters left, rejected when it overflows the 14 lines).
fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let font = &self.gui.atlas.font;
match self.book.as_mut() {
Some(BookScreen::Edit {
@@ -319,38 +331,25 @@ impl Game<'_> {
}
}
Some(BookScreen::View {
pages,
page,
lectern,
}) => {
let before = *page;
lectern: Some(key), ..
}) => lectern_page = Some(*key),
Some(BookScreen::View { pages, page, .. }) => {
if forward && *page + 1 < pages.len() {
*page += 1;
} else if !forward && *page > 0 {
*page -= 1;
}
if *page != before
&& let Some(pos) = *lectern
{
lectern_page = Some((pos, *page));
}
}
None => return,
}
// LecternScreen's page buttons click the menu's (1 back, 2 forward);
// the page turns with the server's data slot and level event 1043.
if let Some(key) = lectern_page {
self.lectern_button(key, if forward { 2 } else { 1 });
return;
}
self.audio
.play("minecraft:item.book.page_turn", None, 1.0, 1.0);
if let Some((pos, page)) = lectern_page {
self.set_lectern_page(pos, page as i32);
}
}
/// `LecternBlockEntity.setPage` and `LecternBlock.signalPageChange`.
fn set_lectern_page(&mut self, key: (i32, i32, i32), page: i32) {
if let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) {
l.page = page;
}
self.set_lectern_powered(key, true);
self.lectern_pulses.push((key, 2));
self.container_changed(key);
}
fn set_lectern_powered(&mut self, key: (i32, i32, i32), powered: bool) {
self.set_lectern_property(key, "powered", powered);
@@ -380,6 +379,7 @@ impl Game<'_> {
}
/// The 2-tick pulses of turned lecterns.
pub(super) fn tick_lecterns(&mut self) {
self.follow_lectern();
let mut done = Vec::new();
for (key, left) in &mut self.lectern_pulses {
*left -= 1;
@@ -452,6 +452,7 @@ impl Game<'_> {
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
self.stats.custom("interact_with_lectern", 1);
self.open_book(BookScreen::View {
pages,
page,
@@ -459,24 +460,189 @@ impl Game<'_> {
});
true
}
/// `LecternMenu` "Take Book".
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
let book = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Lectern(l)) => {
l.page = 0;
l.book.take()
/// A `LecternMenu` button of the open lectern screen: on a server it is
/// sent (`handleInventoryButtonClick`); here the lectern's menu takes it
/// with the player's inventory, and the screen follows the block entity.
fn lectern_button(&mut self, key: (i32, i32, i32), id: i32) {
if self.remote.is_some() {
if let Some((container, _)) = self.remote.as_ref().and_then(|r| r.menu.as_ref()) {
self.send(
horizon_protocol::packets::play::Serverbound::ContainerButtonClick(
horizon_protocol::packets::play::ContainerButtonClick {
container: *container,
button: id,
},
),
);
}
_ => None,
return;
}
let Some(mut menu) = self.lectern_menu(key) else {
return;
};
if let Some(book) = book
&& let Some(rest) = self.give(book)
&& let Some(eye) = self.eye_position()
for i in 0..36 {
menu.set_player_item(i, self.inventory[i].clone());
}
menu.set_player_item(40, self.offhand.clone());
menu.creative = self.creative;
menu.selected = self.selected;
menu.may_build = self.may_build();
if !menu.button(&self.menu_env(), id) {
return;
}
for i in 0..36 {
self.inventory[i] = menu.player_item(i).cloned();
}
self.offhand = menu.player_item(40).cloned();
for stack in std::mem::take(&mut menu.dropped) {
self.player_drop(stack);
}
self.lectern_menu_done(key, &menu);
self.follow_lectern();
}
/// The `book` control command: a button of the open book or lectern
/// screen (the page arrows, Take Book, Done), then its page.
pub(super) fn book_control(
&mut self,
action: Option<&str>,
) -> horizon_client::Result<serde_json::Value> {
let lectern = match &self.book {
Some(BookScreen::View { lectern, .. }) => *lectern,
Some(BookScreen::Edit { .. }) => None,
None => return Err("no book is open".into()),
};
match action {
None => {}
Some("next") => self.turn_page(true),
Some("back") => self.turn_page(false),
Some("take") => match lectern {
Some(key) => self.lectern_button(key, 3),
None => return Err("not a lectern".into()),
},
Some("done") => self.close_book(),
Some(other) => return Err(format!("unknown book button {other}").into()),
}
Ok(match &self.book {
Some(BookScreen::View {
pages,
page,
lectern,
}) => serde_json::json!({
"page": page, "pages": pages.len(), "lectern": lectern.map(|k| [k.0, k.1, k.2]),
}),
Some(BookScreen::Edit { pages, page, .. }) => {
serde_json::json!({ "page": page, "pages": pages.len(), "lectern": null })
}
None => serde_json::json!({ "closed": true }),
})
}
/// A server's `minecraft:lectern` menu: LecternScreen, its book and
/// page to come (`remote_lectern_book`, `remote_lectern_page`).
pub(super) fn remote_open_lectern(&mut self, key: (i32, i32, i32)) {
self.open_book(BookScreen::View {
pages: Vec::new(),
page: 0,
lectern: Some(key),
});
}
/// `LecternScreen.bookChanged`: the menu's book slot.
pub(super) fn remote_lectern_book(&mut self, book: Option<ItemStack>) {
if let Some(BookScreen::View {
lectern: Some(_),
pages,
page,
}) = self.book.as_mut()
{
let data = book.as_ref().and_then(ItemStack::data);
*pages = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
*page = (*page).min(pages.len().saturating_sub(1));
}
}
/// `LecternScreen.pageChanged`: the menu's page data slot.
pub(super) fn remote_lectern_page(&mut self, value: i32) {
if let Some(BookScreen::View {
lectern: Some(_),
pages,
page,
}) = self.book.as_mut()
{
self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40);
*page = (value.max(0) as usize).min(pages.len().saturating_sub(1));
}
}
/// The server closed its lectern menu; false when none is open.
pub(super) fn remote_lectern_closed(&mut self) -> bool {
if !matches!(
self.book,
Some(BookScreen::View {
lectern: Some(_),
..
})
) {
return false;
}
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
}
self.book = None;
self.capture(true);
true
}
/// `LecternMenu` over the lectern at `key` (the menu engine, for the
/// local player and LAN guests alike): its book and page.
pub(super) fn lectern_menu(&self, key: (i32, i32, i32)) -> Option<Menu> {
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get(&key) else {
return None;
};
let mut menu = Menu::new(Kind::Lectern);
menu.set_slot(&self.menu_env(), 0, l.book.clone());
menu.data[0] = l.page;
Some(menu)
}
/// The lectern menu's book and page back in the block entity: a new page
/// is `LecternBlock.signalPageChange` (the 2-tick pulse, level event
/// 1043), a book gone `onBookItemRemove` (`resetBookState`).
pub(super) fn lectern_menu_done(&mut self, key: (i32, i32, i32), menu: &Menu) {
let Some(BlockEntity::Lectern(l)) = self.world.block_entities.get_mut(&key) else {
return;
};
let (had, before) = (l.book.is_some(), l.page);
l.book = menu.slot(0).cloned();
l.page = menu.data[0];
if had && l.book.is_none() {
self.set_lectern_powered(key, false);
self.set_lectern_property(key, "has_book", false);
self.container_changed(key);
} else if menu.data[0] != before {
self.set_lectern_powered(key, true);
self.lectern_pulses.push((key, 2));
self.level_event_at(1043, [key.0, key.1, key.2], 0);
self.container_changed(key);
}
}
/// The open lectern screen follows its block entity (`LecternScreen`'s
/// menu listener): the page, and closing once the book is gone.
pub(super) fn follow_lectern(&mut self) {
let Some(BookScreen::View {
lectern: Some(key),
page,
pages,
}) = self.book.as_mut()
else {
return;
};
if self.remote.is_some() {
return;
}
match self.world.block_entities.get(key) {
Some(BlockEntity::Lectern(l)) if l.book.is_some() => {
*page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
}
_ => self.close_book(),
}
self.set_lectern_property(key, "has_book", false);
self.container_changed(key);
self.close_book();
}
pub(super) fn book_click(&mut self, size: [u32; 2]) {
let b = HudBatch::new(size);
@@ -537,7 +703,8 @@ impl Game<'_> {
self.close_book();
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
self.take_lectern_book(key);
// LecternScreen's Take Book: the menu's button 3.
self.lectern_button(key, 3);
}
} else if inside((w - 200.0) / 2.0, 196.0, 200.0, 20.0) {
click(self);
@@ -616,16 +783,21 @@ impl Game<'_> {
BLACK,
false,
);
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
let shown = format!("{title}{}", if blink { "_" } else { " " });
let tw = HudBatch::text_width(atlas, &shown);
batch.text(
// The title box: an unbordered, centred EditBox of 114 with
// black text and no shadow; the value alone is centred, the
// cursor follows it.
let x = left + 36.0;
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
horizon_client::ui::append_field(
batch,
atlas,
&shown,
left + 36.0 + (114.0 - tw) / 2.0,
50.0,
BLACK,
false,
title,
(tx, 50.0, x + 114.0 - tx),
(BLACK, false),
15,
self.text_selection.covers(BOOK_TITLE, title),
true,
opened.elapsed().as_millis(),
);
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
let tw = HudBatch::text_width(atlas, &by);
+4 -2
View File
@@ -39,11 +39,13 @@ impl Game<'_> {
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let mut at = [x, y, z];
let mut state = self.world.block(x, y, z);
if self.res.facts().is_air(state) {
state = self.world.block(x, (m.position.y - 0.2).floor() as i32, z);
at[1] = (m.position.y - 0.2).floor() as i32;
state = self.world.block(x, at[1], z);
}
let Some((sprite, tint)) = self.particle_sprite(state) else {
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let at = if jump_trail {
+68 -58
View File
@@ -1,70 +1,80 @@
//! The cartography table (`CartographyTableMenu`, `CartographyTableScreen`):
//! zooming a map out with paper, cloning it with an empty map, locking it
//! with a glass pane, the post-processing on take (`MapItem.scaleMap`,
//! `lockMap`) and the preview with its error and lock sprites.
//! The cartography table (`CartographyTableMenu` on the menu engine,
//! `CartographyTableScreen`): zooming a map out with paper, cloning it with
//! an empty map, locking it with a glass pane. The engine reads the level's
//! maps through `LevelMaps`; the scaled and locked copies a take makes
//! (`MapItem.scaleMap`, `lockMap`) are registered after the click. The
//! screen draws the preview with its error and lock sprites.
use super::{
Game,
maps::{MapData, map_id},
stations::Station,
};
use horizon_client::{hud::HudBatch, items::ItemStack};
use horizon_client::{hud::HudBatch, items::ItemStack, menu::Maps};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
};
/// The level's maps as a menu sees them (`ContainerLevelAccess`): the
/// states of the maps the click can reach, and the copies it makes.
pub(super) struct LevelMaps {
states: RefCell<HashMap<i32, (bool, u8)>>,
next_id: Cell<i32>,
/// (new id, source id, scaled) for each copy made.
made: RefCell<Vec<(i32, i32, bool)>>,
}
impl Maps for LevelMaps {
fn state(&self, id: i32) -> Option<(bool, u8)> {
self.states.borrow().get(&id).copied()
}
fn derive(&self, id: i32, scale: bool) -> Option<i32> {
let (_, s) = self.state(id)?;
let new = self.next_id.get();
self.next_id.set(new + 1);
let made = if scale {
(false, (s + 1).min(4))
} else {
(true, s)
};
self.states.borrow_mut().insert(new, made);
self.made.borrow_mut().push((new, id, scale));
Some(new)
}
}
impl Game<'_> {
/// `setupResultSlot`.
pub(super) fn cartography_result(&self, s: &Station) -> Option<ItemStack> {
let (map, extra) = (s.inputs[0].as_ref()?, s.inputs[1].as_ref()?);
let data = self.maps.data.get(&map_id(map)?)?;
match &*extra.item {
"minecraft:paper" if !data.locked && data.scale < 4 => Some(ItemStack {
count: 1,
..map.clone()
}),
"minecraft:glass_pane" if !data.locked => Some(ItemStack {
count: 1,
..map.clone()
}),
"minecraft:map" => Some(ItemStack {
count: 2,
..map.clone()
}),
_ => None,
/// The maps of `stacks` (loaded as needed) for a menu over this level.
pub(super) fn level_maps<'s>(
&mut self,
stacks: impl IntoIterator<Item = &'s ItemStack>,
) -> LevelMaps {
let ids: Vec<i32> = stacks.into_iter().filter_map(map_id).collect();
let mut states = HashMap::new();
for id in ids {
if let Some(m) = self.map(id) {
states.insert(id, (m.locked, m.scale));
}
}
LevelMaps {
states: RefCell::new(states),
next_id: Cell::new(self.peek_map_id()),
made: RefCell::new(Vec::new()),
}
}
/// `onCraftedPostProcess` for the taken result: a scaled or locked copy
/// under a new map id. Also spends the inputs' extra item kind check.
pub(super) fn cartography_post(&mut self, mut result: ItemStack) -> ItemStack {
let extra = self
.station
.as_ref()
.and_then(|s| s.inputs[1].as_ref())
.map(|e| e.item.clone());
let Some(id) = map_id(&result) else {
return result;
};
let new = match extra.as_deref() {
Some("minecraft:paper") => self.map(id).map(|m| m.scaled()),
Some("minecraft:glass_pane") => self.map(id).map(|m| m.locked_copy()),
_ => None,
};
if let Some(data) = new {
let new_id = self.new_map_id(data);
result.data_mut().map_id = Some(new_id);
/// The scaled and locked copies a menu made (`level.setMapData`).
pub(super) fn register_made_maps(&mut self, maps: LevelMaps) {
for (id, source, scale) in maps.made.into_inner() {
let Some(data) = self
.map(source)
.map(|m| if scale { m.scaled() } else { m.locked_copy() })
else {
continue;
};
let new = self.new_map_id(data);
debug_assert_eq!(new, id, "map ids follow peek_map_id");
}
result
}
/// The take sound (once per tick).
pub(super) fn cartography_taken(&mut self, pos: [i32; 3]) {
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
"minecraft:ui.cartography_table.take_result",
Some(at),
1.0,
1.0,
);
}
/// `CartographyTableScreen.extractBackground` after the background.
pub(super) fn draw_cartography(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
+3
View File
@@ -25,6 +25,9 @@ pub(super) struct CombatTracker {
pub(super) last_hurt_mob: Option<(u32, i64)>,
/// The mob whose projectile is hitting the player now.
pub(super) projectile_owner: Option<u32>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
+27 -7
View File
@@ -1612,11 +1612,16 @@ impl Game<'_> {
{
return Err(err("commands.summon.failed.peaceful", &[]));
}
let Some(target) = self.summon_with_nbt(&format!("minecraft:{short}"), pos, &nbt)?
else {
return Err(err("commands.summon.failed", &[]));
let before = self.spawned_mark();
let name = match self.summon_with_nbt(&format!("minecraft:{short}"), pos, &nbt)? {
Some(target) => self.target_name(target),
// Spawned as a kind no command target stands for (primed
// TNT, falling blocks, projectiles): its type's name.
None if self.spawned_mark() != before => {
Text::translate(&format!("entity.minecraft.{short}"), &[])
}
None => return Err(err("commands.summon.failed", &[])),
};
let name = self.target_name(target);
self.feedback(Text::translate("commands.summon.success", &[name]));
return Ok(1);
}
@@ -1669,16 +1674,31 @@ impl Game<'_> {
}
// SummonCommand.createEntity: any other type from an empty compound.
_ => {
let Some(target) = self.summon_with_nbt(&kind, pos, "{}")? else {
return Err(err("commands.summon.failed", &[]));
let before = self.spawned_mark();
name = match self.summon_with_nbt(&kind, pos, "{}")? {
Some(target) => self.target_name(target),
// Spawned as a kind no command target stands for
// (primed TNT, falling blocks, projectiles).
None if self.spawned_mark() != before => {
Text::translate(&format!("entity.minecraft.{short}"), &[])
}
None => return Err(err("commands.summon.failed", &[])),
};
name = self.target_name(target);
}
}
}
self.feedback(Text::translate("commands.summon.success", &[name]));
Ok(1)
}
/// What changes when an entity spawns, kinds that take their id only
/// when first tracked (primed TNT, falling blocks, projectiles)
/// included: a summon compares it before and after.
fn spawned_mark(&self) -> (u32, usize) {
(
self.next_entity,
self.tnts.len() + self.falling.len() + self.creatures.projectiles.len(),
)
}
/// BlockStateArgument -> registry state.
pub(super) fn block_state(&self, b: &BlockInput) -> R<BlockStateId> {
let state = horizon_world::chunk::BlockState {
@@ -59,12 +59,7 @@ pub(in crate::game) struct CommandExtras {
pub results: Option<Vec<(bool, i32)>>,
}
/// `version.json` of the 26.3 jars.
const VERSION_ID: &str = "26.3";
const DATA_VERSION: i32 = 5023;
const STABLE: bool = true;
/// `Date.toString()` of the build time in UTC.
const BUILD_TIME: &str = "Tue Sep 15 11:20:48 UTC 2026";
use horizon_core::version::{BUILD_TIME, DATA_VERSION, STABLE, VANILLA_VERSION, VERSION_ID};
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
@@ -974,7 +969,10 @@ impl Game<'_> {
let protocol = horizon_protocol::PROTOCOL_VERSION;
let mut lines: Vec<Text> = [
("commands.version.id", vec![Text::literal(VERSION_ID)]),
("commands.version.name", vec![Text::literal(VERSION_ID)]),
(
"commands.version.name",
vec![Text::literal(VANILLA_VERSION)],
),
("commands.version.data", vec![num(DATA_VERSION)]),
("commands.version.series", vec![Text::literal("main")]),
(
@@ -470,8 +470,12 @@ impl Game<'_> {
match block {
Ok(Arg::Block(b)) => {
let state = self.block_state(&b)?;
let at = self
.position_arg(ctx, "pos")
.unwrap_or_else(|| self.source_position())
.map(|c| c.floor() as i32);
let (sprite, tint) = self
.particle_sprite(state)
.particle_sprite(state, at)
.unwrap_or_else(|| ("block/stone".into(), 0xFFFFFF));
ParticleOptions::block(ty, sprite, tint)
}
+113 -6
View File
@@ -24,6 +24,7 @@ const HELP: &[&str] = &[
"time TICKS set the time of day",
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
"mobs [RADIUS] list mobs near the player with their AI state",
"hurtlog the hurts taken and melee attacks made since the last call, per player (host and guests)",
"fill X1 Y1 Z1 X2 Y2 Z2 BLOCK set a box of loaded blocks",
"resume leave the pause menu",
"screenshot PATH write the next frame as PNG",
@@ -117,6 +118,9 @@ impl Game<'_> {
}
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
self.answer(request, event_loop);
if self.pending_save.is_some() {
break;
}
}
if self.control.is_some() {
self.watch_events();
@@ -140,6 +144,9 @@ impl Game<'_> {
event_loop: &ActiveEventLoop,
) -> Result<Option<Value>> {
let words: Vec<&str> = line.split_whitespace().collect();
if self.pending_save.is_some() && words.first() != Some(&"quit") {
return Err("a world save is in progress".into());
}
let arg = |i: usize| {
words
.get(i)
@@ -214,6 +221,25 @@ impl Game<'_> {
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
self.require_world()?;
// A held edit modifier (`key super down`) and A/C/V/X: the
// open book's Select All, Copy, Paste or Cut.
if self.book.is_some() && arg(2)? != "up" {
let held = |k: [KeyCode; 2]| k.iter().any(|k| self.keys.contains(k));
let edit = if cfg!(target_os = "macos") {
held([KeyCode::SuperLeft, KeyCode::SuperRight])
} else {
held([KeyCode::ControlLeft, KeyCode::ControlRight])
};
let name = arg(1)?;
let letter = name.chars().next().filter(|_| name.len() == 1);
let shift = held([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
if let Some(shortcut) =
horizon_client::clipboard::edit_shortcut(letter, edit, shift, false)
{
self.book_shortcut(shortcut);
return ok();
}
}
// KeyboardHandler.keyPress: the debug keys see the physical key first.
let physical = key;
{
@@ -444,6 +470,10 @@ impl Game<'_> {
let radius: f64 = words.get(1).map_or(Ok(64.0), |r| r.parse())?;
Ok(Some(self.control_mobs(radius)))
}
"hurtlog" => {
self.require_world()?;
Ok(Some(self.control_hurt_log()))
}
"resume" => {
if self.screen == Screen::Paused {
self.screen = Screen::Playing;
@@ -493,13 +523,22 @@ impl Game<'_> {
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box.
Screen::Inventory
if self
.station
.as_ref()
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
{
self.station_text(Some(text), false)
}
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text), false)
}
_ => {
return Err(
"type needs the chat screen (key t tap), the language search, the creative search or World Options".into(),
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
);
}
}
@@ -525,6 +564,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() {
@@ -625,6 +666,9 @@ impl Game<'_> {
server
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
.ok_or("the integrated server stopped")??;
} else if self.remote.is_none() {
self.begin_control_save(all, reply.clone())?;
return Ok(None);
} else {
self.control_save(all)?;
}
@@ -633,12 +677,11 @@ impl Game<'_> {
"dragon-hurt" => {
// Test hook: damage the dragon's head as the player would.
let amount: f32 = arg(1)?.parse()?;
self.hit_special(super::dragon::SPECIAL, amount, true);
self.hit_special(super::dragon::SPECIAL, amount, true, false);
ok()
}
"quit" => {
self.save_for_exit()?;
event_loop.exit();
self.begin_closing_save(true)?;
ok()
}
other => match self.station_command(&words)? {
@@ -695,10 +738,59 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
}
}
/// The hurts and melee attacks each player logged since the last call
/// (`PlayerMotion::hurt_log`, `attack_log`), taken out.
fn control_hurt_log(&mut self) -> Value {
fn drain(
name: &str,
hurt_log: &mut Vec<horizon_client::health::HurtRecord>,
attack_log: &mut Vec<horizon_client::health::AttackRecord>,
) -> Value {
let hurts: Vec<Value> = hurt_log
.drain(..)
.map(|r| {
json!({
"t": r.game_time, "source": r.source, "amount": r.amount,
"blocked": r.blocked, "taken": r.taken, "hurt": r.hurt,
"armor": r.armor, "toughness": r.toughness, "health": r.health,
"fire_ticks": r.fire_ticks, "knockback": r.knockback,
})
})
.collect();
let attacks: Vec<Value> = attack_log
.drain(..)
.map(|r| {
json!({
"t": r.game_time, "target": r.target, "id": r.id, "killed": r.killed, "amount": r.amount,
"scale": r.scale, "crit": r.crit, "landed": r.landed, "dealt": r.dealt,
})
})
.collect();
json!({ "name": name, "hurts": hurts, "attacks": attacks })
}
let mut players = Vec::new();
if let Some(p) = self.player.as_mut() {
players.push(drain("host", &mut p.hurt_log, &mut p.attack_log));
}
if let Some(host) = self.lan.as_mut() {
for g in &mut host.hub.guests {
let name = g.name.clone();
if let Some(m) = g.motion.as_mut() {
players.push(drain(&name, &mut m.hurt_log, &mut m.attack_log));
} else if let Some((mut hurts, mut attacks)) = g.logs_kept.take() {
// Dead, not yet respawned: what its last body logged.
players.push(drain(&name, &mut hurts, &mut attacks));
}
}
}
json!({ "ok": true, "players": players })
}
fn control_status(&self) -> Value {
let player = self.player.as_ref().map(|p| {
json!({
@@ -761,6 +853,12 @@ impl Game<'_> {
"pending_observations": self.lod_observe.len(),
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
});
// The other players' cracks this client draws (LevelRenderer.destroyingBlocks).
let destroying: Vec<_> = self
.destroying
.iter()
.map(|(id, (pos, stage, _))| json!({ "id": id, "pos": pos, "stage": stage }))
.collect();
let mut status = json!({
"screen": self.screen_name(),
"debug": {
@@ -793,14 +891,16 @@ impl Game<'_> {
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
"fight": self.dragon_status(),
"carts": self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>(),
// A client of a server (single player's server thread) knows the
// carts it was sent.
"carts": self.remote_carts().unwrap_or_else(|| self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>()),
"riding": self.riding,
"using": self.using,
"render_distance": self.streaming.radius,
"chunks": self.world.chunks.len(),
"mobs": self.mobs.len(),
"block_entities": self.world.block_entities.len(),
"pending_chunks": self.streaming.pending() + self.pending_uploads.len(),
"pending_chunks": self.streaming.pending() + self.pending_uploads.len() + self.remote_backlog(),
"generated_chunks": self.streaming.generated_total,
"meshed_sections": self.streaming.meshed_total,
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
@@ -824,9 +924,13 @@ impl Game<'_> {
status["lod"]["target_tiles"] = target.into();
status["lod"]["sampled_target_tiles"] = sampled.into();
status["pending_uploads"] = self.pending_uploads.len().into();
status["destroying"] = json!(destroying);
status["physical_keys"] = json!(physical_keys);
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
@@ -901,6 +1005,8 @@ fn key_code(name: &str) -> Option<KeyCode> {
"space" => KeyCode::Space,
"shift" | "lshift" => KeyCode::ShiftLeft,
"ctrl" | "control" | "lctrl" => KeyCode::ControlLeft,
// The edit modifier on macOS (Cmd+A selects all there).
"super" | "cmd" | "meta" => KeyCode::SuperLeft,
"esc" | "escape" => KeyCode::Escape,
"tab" => KeyCode::Tab,
"enter" | "return" => KeyCode::Enter,
@@ -909,6 +1015,7 @@ fn key_code(name: &str) -> Option<KeyCode> {
"f3" => KeyCode::F3,
"f4" => KeyCode::F4,
"f5" => KeyCode::F5,
"f6" => KeyCode::F6,
"f11" => KeyCode::F11,
"slash" | "/" => KeyCode::Slash,
"backspace" => KeyCode::Backspace,
+2 -1
View File
@@ -191,7 +191,8 @@ impl Game<'_> {
let Ok(id) = registry.resolve(&state) else {
return;
};
let Some((sprite, tint)) = this.particle_sprite(id) else {
let Some((sprite, tint)) = this.particle_sprite(id, center.map(|c| c.floor() as i32))
else {
return;
};
for _ in 0..count {
+172 -34
View File
@@ -3,6 +3,7 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{Dim, Game, view_vector};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result,
arrows::EntityHit,
@@ -329,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],
@@ -389,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,
@@ -401,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
@@ -498,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,
@@ -588,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);
@@ -622,7 +645,8 @@ impl Game<'_> {
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
let by_player = self.last_hurt_by_player.remove(&m.id) || m.ai.last_hurt_by_player > 0;
let killer = self.last_hurt_by_player.remove(&m.id);
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
// takes the experience (before the loot is dropped).
@@ -636,11 +660,22 @@ impl Game<'_> {
}
});
if by_player {
self.stats.add("killed", &m.kind.id, 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
// A guest's kill is theirs: their statistics, scores and
// advancements (LivingEntity.die credits lastHurtByPlayer).
let guest = killer.flatten().filter(|_| self.lan_acting.is_none());
let credited = guest.is_some_and(|conn| {
let Some(mut host) = self.lan.take() else {
return false;
};
let done = self
.as_guest(&mut host, conn, |g| g.credit_kill(m))
.is_some();
self.lan = Some(host);
done
});
if !credited {
self.credit_kill(m);
}
}
self.score_entity_removed(m.id);
self.raider_died(m, by_player);
@@ -675,6 +710,15 @@ impl Game<'_> {
}
}
}
/// The killer's side of a mob's death: `ServerPlayer.awardKillScore`'s
/// statistics and scores, and the `player_killed_entity` trigger.
fn credit_kill(&mut self, m: &Mob) {
self.stats.add("killed", &m.kind.id, 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
self.trigger_entity("minecraft:player_killed_entity", "entity", &view);
}
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
/// world's random sequence, equipment by drop chance, species extras and
/// the experience orbs, spawned in order.
@@ -1393,7 +1437,8 @@ impl Game<'_> {
from: [f64; 3],
damage_type: &str,
) -> bool {
if self.creative {
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
return false;
}
let projectile = self
@@ -1440,13 +1485,13 @@ impl Game<'_> {
o.hurt
}
{
let (dx, dz) = (player.position.x - from[0], player.position.z - from[2]);
let l = (dx * dx + dz * dz).sqrt().max(1e-4);
player.velocity = Vec3::new(
player.velocity.x / 2.0 + dx / l * 0.4,
(player.velocity.y / 2.0 + 0.4).min(0.4),
player.velocity.z / 2.0 + dz / l * 0.4,
);
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
{
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
}
self.hurt_armor(amount);
self.thorns(from);
}
@@ -1455,6 +1500,35 @@ impl Game<'_> {
}
outcome.hurt
}
/// `LivingEntity.knockback` on the player: pushed `power` away from the
/// direction (`xd`, `zd`), less its knockback resistance, and up only from
/// the ground.
pub(super) fn knock_back_player(&mut self, power: f64, mut xd: f64, mut zd: f64) {
let Some(p) = self.player.as_mut() else {
return;
};
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
let power = power * (1.0 - resistance);
if power <= 0.0 {
return;
}
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
let r = &mut self.world.random;
xd = (r.next_double() - r.next_double()) * 0.01;
zd = (r.next_double() - r.next_double()) * 0.01;
}
let l = (xd * xd + zd * zd).sqrt();
let v = p.velocity;
p.velocity = Vec3::new(
v.x / 2.0 - xd / l * power,
if p.ground.on_ground {
(v.y / 2.0 + power).min(0.4)
} else {
v.y
},
v.z / 2.0 - zd / l * power,
);
}
/// A status effect from a mob, arrow or splash potion (`addEffect`).
pub(super) fn give_player_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
if self.creative || self.spectator() {
@@ -1602,14 +1676,30 @@ impl Game<'_> {
w: &w,
targets: targets_guests,
};
let guest_entities = self.lan_guest_entities();
projectiles.retain_mut(|p| {
let mob_shot = !matches!(p.owner, Some(Who::Player));
let (keep, hit) = match (p.arrow.is_some(), mob_shot) {
// A player's arrows, snowballs and eggs meet every player; a
// guest's flies from that guest (Projectile.getOwner), not
// from the host, until it has left them.
let pvp = !mob_shot && super::lan_pvp::meets_players(&p.kind);
let shooter = p
.guest
.filter(|_| pvp)
.and_then(|c| guest_entities.iter().find(|(g, _)| *g == c))
.map(|&(_, id)| Who::Mob(id));
if shooter.is_some() {
p.owner = shooter;
}
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp) {
(true, false) => horizon_client::arrows::tick(p, &scene, &arrow_targets),
(true, true) => horizon_client::arrows::tick(p, &scene, &arrow_targets_guests),
(false, false) => p.tick(&pw),
(false, true) => p.tick(&pw_guests),
};
if shooter.is_some() {
p.owner = Some(Who::Player);
}
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
@@ -1661,6 +1751,11 @@ impl Game<'_> {
}
self.creatures.projectiles.append(&mut projectiles);
for (p, hit) in hits {
// A player's arrow on another player lands as the victim's hurt.
if let Some(victim) = self.projectile_pvp_victim(&p, hit) {
self.projectile_hits_player(&p, hit, victim)?;
continue;
}
// A guest's projectile lands as theirs: a pearl takes them, what
// it hurts answers to them. One hitting the host (`Who::Player`
// here) stays the host's hit.
@@ -1855,12 +1950,30 @@ impl Game<'_> {
} = hit
&& id >= super::dragon::SPECIAL
{
let by_player = p.owner == Some(Who::Player);
if let ProjectileKind::Arrow { .. } = p.kind {
// AbstractArrow.onHitEntity: spent if the part or crystal took
// it, else turned back (a perched dragon sets it alight first).
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let outcome = match self.hit_special(id, amount, by_player, true) {
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
super::dragon::Hurt::Refused => EntityHit::Refused,
super::dragon::Hurt::Shrugged => {
if let Some(a) = arrow.arrow.as_mut() {
a.ignite_for_seconds(1.0);
}
EntityHit::Refused
}
};
self.finish_arrow_hit(arrow, outcome);
return Ok(());
}
let amount = match p.kind {
ProjectileKind::Arrow { .. } => p.arrow_damage(&mut self.world.random),
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => 6.0,
_ => 0.0,
};
self.hit_special(id, amount, p.owner == Some(Who::Player));
self.hit_special(id, amount, by_player, false);
return Ok(());
}
if let (
@@ -1926,7 +2039,7 @@ impl Game<'_> {
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, source));
if p.owner == Some(Who::Player) {
self.last_hurt_by_player.insert(id);
self.last_hurt_by_player.insert(id, self.lan_acting);
}
hurt
}
@@ -2016,15 +2129,22 @@ impl Game<'_> {
let from = pl.position;
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
}
// To where the pearl started its last tick (oldPosition).
let to = p.previous;
if let Some(pl) = self.player.as_mut() {
pl.position = at;
pl.position = to;
pl.velocity = Vec3::ZERO;
pl.fall_distance = 0.0;
pl.teleported = true;
}
self.previous_position = at;
self.previous_position = to;
self.hurt_player_by("minecraft:ender_pearl", 5.0);
self.audio
.play("minecraft:entity.player.teleport", Some(atp), 1.0, 1.0);
self.audio.play(
"minecraft:entity.player.teleport",
Some([to.x, to.y, to.z]),
1.0,
1.0,
);
}
}
(ProjectileKind::SmallFireball, hit) => match hit {
@@ -2051,15 +2171,21 @@ impl Game<'_> {
}
},
Hit::Block { pos, face, .. } => {
// SmallFireball.onHitBlock: a mob's only with mobGriefing;
// BaseFireBlock.getState placed with updates (the fire
// ticks, ages and goes out where it can't stay).
let fire = [pos[0] + face[0], pos[1] + face[1], pos[2] + face[2]];
if self
.res
.facts()
.is_air(self.world.block(fire[0], fire[1], fire[2]))
&& let Some(state) =
self.pack.assets.registry.default_state("minecraft:fire")
let mob = matches!(p.owner, Some(Who::Mob(_)));
if (!mob || self.ui.rules.bool("mob_griefing"))
&& self
.res
.facts()
.is_air(self.world.block(fire[0], fire[1], fire[2]))
{
self.world.set(fire[0], fire[1], fire[2], state)?;
let at = BlockPos::new(fire[0], fire[1], fire[2]);
let state =
self.with_level(|b, level, ticks| b.fire_state_for(level, ticks, at));
self.level_set(fire, state)?;
}
}
Hit::None => {}
@@ -2825,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.
@@ -3011,6 +3141,14 @@ impl Game<'_> {
}
continue;
}
if let Some(stack) = a.potion_item() {
let owner = super::item_draw::owner_at(at, 0.0, "minecraft:splash_potion");
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
+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 = [
+8 -9
View File
@@ -484,7 +484,13 @@ impl Game<'_> {
})
},
));
log("INFO", "Starting minecraft server version 26.3");
log(
"INFO",
&format!(
"Starting minecraft server version {}",
horizon_core::VANILLA_VERSION
),
);
log("INFO", "Loading properties");
let game_mode = props.game_mode;
log(
@@ -689,14 +695,7 @@ impl Game<'_> {
// MinecraftServer.tickServer records each tick's time, for /tick query (and the status).
self.clock.record(tick_start.elapsed());
state.ticked(Instant::now());
let rate = self.clock.rate().max(1.0);
next += Duration::from_secs_f64(1.0 / f64::from(rate));
let now = Instant::now();
if next > now {
std::thread::sleep(next - now);
} else if now - next > Duration::from_secs(2) {
next = now;
}
self.wait_for_next_server_tick(&mut next, paused);
}
self.stop_dedicated()
}
+155
View File
@@ -0,0 +1,155 @@
//! A player's blow at a ghast's fireball or a wind charge
//! (`#minecraft:redirectable_projectile`): `Player.attack` turns it with
//! `ProjectileDeflection.AIM_DEFLECT` instead of hurting anything. It flies on
//! along the player's view and is the player's now (`Projectile.deflect`
//! sets the owner), so a ghast it meets dies of it (`Ghast.isReflectedFireball`).
use super::{Game, ray_box, view_vector};
use horizon_client::{
mobs::Who,
projectiles::{Projectile, ProjectileKind},
};
use horizon_core::geometry::{Aabb, Vec3};
/// `AbstractHurtingProjectile.getPickRadius`: the crosshair finds it a block
/// round its box.
const PICK_RADIUS: f64 = 1.0;
/// `#minecraft:redirectable_projectile`: a ghast's fireball and both wind charges.
pub(super) fn redirectable(p: &Projectile) -> bool {
!p.stuck
&& matches!(
p.kind,
ProjectileKind::LargeFireball { .. }
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
)
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
/// 0.3125, from its feet up) grown by the pick radius.
fn pick_box(p: &Projectile) -> Aabb {
let size = match p.kind {
ProjectileKind::LargeFireball { .. } => 1.0,
_ => 0.3125,
};
let half = size / 2.0 + PICK_RADIUS;
Aabb::new(
Vec3::new(
p.position.x - half,
p.position.y - PICK_RADIUS,
p.position.z - half,
),
Vec3::new(
p.position.x + half,
p.position.y + size + PICK_RADIUS,
p.position.z + half,
),
)
}
impl Game<'_> {
/// The redirectable projectile under the crosshair within the attack
/// reach, and how far along the view it is.
pub(super) fn redirectable_hit(&self) -> Option<(usize, f64)> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
self.creatures
.projectiles
.iter()
.enumerate()
.filter(|(_, p)| redirectable(p))
.filter_map(|(i, p)| ray_box(eye, dir, reach, pick_box(p)).map(|t| (i, t)))
.min_by(|a, b| a.1.total_cmp(&b.1))
}
/// `Player.attack` on a redirectable projectile nearer than any mob or
/// block in view: `AIM_DEFLECT` (its movement the unit view vector), the
/// player its owner, its acceleration power back to 0.1
/// (`onDeflection` by an attack), and the no-damage sound. True when a
/// projectile was turned.
pub(super) fn attack_redirectable(&mut self) -> bool {
let Some((i, t)) = self.redirectable_hit() else {
return false;
};
if self.mob_hit().is_some_and(|(_, d)| d < t) {
return false;
}
if let (Some(hit), Some(eye)) = (self.pick(), self.eye_position()) {
let d = (0..3)
.map(|a| (hit.point[a] - eye[a]).powi(2))
.sum::<f64>()
.sqrt();
if d < t {
return false;
}
}
self.deflect(i);
true
}
/// `Player.canInteractWithEntity(box, 3.0)` as the server checks an
/// attack packet: the projectile's box within the reach plus 3 of the eyes.
pub(super) fn within_interaction_range(&self, i: usize) -> bool {
let (Some(eye), Some(p)) = (self.eye_position(), self.creatures.projectiles.get(i)) else {
return false;
};
let b = pick_box(p);
let (lo, hi) = (b.min_position(), b.max_position());
let (lo, hi) = (
[lo.x + PICK_RADIUS, lo.y + PICK_RADIUS, lo.z + PICK_RADIUS],
[hi.x - PICK_RADIUS, hi.y - PICK_RADIUS, hi.z - PICK_RADIUS],
);
let d2: f64 = (0..3)
.map(|a| (eye[a] - eye[a].clamp(lo[a], hi[a])).powi(2))
.sum();
let range = if self.creative { 5.0 } else { 3.0 } + 3.0;
d2 < range * range
}
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
let guest = self.lan_acting;
let p = &mut self.creatures.projectiles[i];
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
p.owner = Some(Who::Player);
// On a server, the guest who struck it (their projectile now).
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
self.reset_attack_strength();
self.swing_arm();
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_ghast_fireballs_and_wind_charges_are_redirectable() {
let at = Vec3::new(0.0, 64.0, 0.0);
let large = Projectile::fireball(
ProjectileKind::LargeFireball { power: 1 },
at,
None,
[1.0, 0.0, 0.0],
);
let small = Projectile::fireball(ProjectileKind::SmallFireball, at, None, [1.0, 0.0, 0.0]);
assert!(redirectable(&large));
assert!(!redirectable(&small));
// The pick box: a block round the fireball's 1 x 1 box.
let b = pick_box(&large);
assert_eq!(b.min_position().x, -1.5);
assert_eq!(b.max_position().y, 66.0);
}
}
@@ -484,7 +484,8 @@ impl Game<'_> {
Ok(out)
}
/// A player three blocks south of `pos` right-clicks its top face holding
/// A player three blocks south of `pos`, turned to the case's `yaw`,
/// right-clicks its top face (or the case's `face`) holding
/// the case's item (`use_item_as_player` on that hit): the statistics,
/// the held stack and the item it started using, as
/// `VanillaDispenserOracle.useItem` prints them.
@@ -516,7 +517,7 @@ impl Game<'_> {
}
player.health.food.food = op["food"].as_i64().unwrap_or(20) as i32;
self.player = Some(player);
self.yaw = 0.0;
self.yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
self.pitch = 90.0;
self.keys.clear();
if op["sneaking"].as_bool().unwrap_or(false) {
@@ -533,13 +534,23 @@ impl Game<'_> {
.collect();
items.dedup();
let used_before: Vec<_> = items.iter().map(|i| self.stats.get("used", i)).collect();
// The top face, or the case's `face`, clicked at its centre.
let face = match op["face"].as_str() {
Some("down") => [0, -1, 0],
Some("north") => [0, 0, -1],
Some("south") => [0, 0, 1],
Some("west") => [-1, 0, 0],
Some("east") => [1, 0, 0],
_ => [0, 1, 0],
};
let centre = |c: i32, f: i32| f64::from(c) + 0.5 + f64::from(f) * 0.5;
self.forced_hit = Some(super::Hit {
pos: [pos.x, pos.y, pos.z],
face: [0, 1, 0],
face,
point: [
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 1.0,
f64::from(pos.z) + 0.5,
centre(pos.x, face[0]),
centre(pos.y, face[1]),
centre(pos.z, face[2]),
],
});
self.chat_capture = Some(Vec::new());
+8 -15
View File
@@ -317,7 +317,11 @@ impl Game<'_> {
.cloned()
}
/// The same block with other properties (`withProperty`).
fn with_props(&self, state: BlockStateId, props: &[(&str, &str)]) -> Option<BlockStateId> {
pub(super) fn with_props(
&self,
state: BlockStateId,
props: &[(&str, &str)],
) -> Option<BlockStateId> {
let mut s = self.pack.assets.registry.state(state)?.clone();
for (k, v) in props {
s.properties.insert((*k).to_owned(), (*v).to_owned());
@@ -1494,20 +1498,7 @@ impl Game<'_> {
.default_state("minecraft:carved_pumpkin")
{
let head = [target.0, target.1, target.2];
if self.completes_golem(head) {
// CarvedPumpkinBlock.onPlace builds the golem inside
// setBlock; the spot is air again by the time setBlock
// would update neighbours, so it skips them.
let at = Self::pos_of(target);
self.with_level(|b, level, ticks| {
b.set_block(level, ticks, at, pumpkin, 2 | 16)
});
self.process_level_events()?;
self.check_golem_spawn(head)?;
} else {
self.level_set(head, pumpkin)?;
self.check_golem_spawn(head)?;
}
self.set_golem_head(head, pumpkin)?;
self.post_block_game_event("minecraft:block_place", head, None, None);
}
return Ok((shrink(stack), CLICK));
@@ -1745,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;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,67 @@
//! Horizon Extras' dynamic lights (`horizon_client::dynamic_light`): the light sources near the
//! camera this frame. Nothing is gathered while the extra is off.
use super::{Game, shield};
use glam::DVec3;
use horizon_client::dynamic_light::{self, BURNING, Luminance, Source};
impl Game<'_> {
/// Fills `dynamic_lights` for the frame: the player's and mobs' held items and fire, and
/// glowing dropped items, the nearest [`dynamic_light::MAX`] of them.
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
let mut out = std::mem::take(&mut self.dynamic_lights);
out.clear();
if !self.settings.extras.dynamic_lights {
self.dynamic_lights = out;
return;
}
if self.luminance.is_none() {
self.luminance = Some(Luminance::new(self.res.facts(), self.pack.assets.registry));
}
let lum = self.luminance.as_ref().expect("built above");
let held = |item: Option<&str>| item.map_or(0, |i| lum.of(i));
let mut sources = Vec::new();
if let Some(p) = &self.player
&& !p.spectator
{
let feet = self
.previous_position
.lerp(p.position, self.clock.camera_fraction());
let level = held(self.stack_in(self.selected).map(|s| &*s.item))
.max(held(self.stack_in(shield::OFFHAND_SLOT).map(|s| &*s.item)))
.max(if p.fire_ticks > 0 { BURNING } else { 0 });
// About where the hands are.
sources.push(Source {
at: DVec3::new(feet.x, feet.y + 1.2, feet.z),
level,
});
}
for m in self.mobs.iter().filter(|m| !m.removed) {
let level = held(m.extra.main_hand.as_deref())
.max(held(m.extra.off_hand.as_deref()))
.max(if m.fire_ticks > 0 { BURNING } else { 0 });
if level == 0 {
continue;
}
let at = m.previous.lerp(m.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + f64::from(m.height()) * 0.6, at.z),
level,
});
}
for item in &self.dropped {
let level = lum
.of(&item.stack.item)
.max(if item.fire_ticks > 0 { BURNING } else { 0 });
if level == 0 {
continue;
}
let at = item.previous.lerp(item.position, alpha);
sources.push(Source {
at: DVec3::new(at.x, at.y + 0.25, at.z),
level,
});
}
dynamic_light::select(&sources, camera, &mut out);
self.dynamic_lights = out;
}
}
@@ -6,7 +6,7 @@
//! the armor attribute effects (respiration, depth strider, ...).
use super::Game;
use horizon_client::{enchant::EffectContext, items::ItemStack};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::random::RandomSource;
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
@@ -84,7 +84,50 @@ impl Game<'_> {
- fx.amplifier("weakness")
.map_or(0.0, |a| 4.0 * (a + 1) as f32)
}
/// After a successful melee hit: knockback enchantment and fire aspect.
/// `Player.attack`'s extra knockback: `getKnockback` (the weapon's knockback
/// enchantment, halved) plus 0.5 for a sprinting full-strength hit, and
/// whether the hit is that knockback attack.
pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) {
let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting);
let enchanted = match self.inventory[self.selected].clone() {
Some(held) => {
let ctx = EffectContext {
this: Some(target_kind),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:player"),
is_direct: true,
items: Some(self.res.items()),
..Default::default()
};
self.res.stations().enchantments.modify_value(
Some(&held),
"minecraft:knockback",
&ctx,
&mut self.world.random,
0.0,
)
}
None => 0.0,
};
let bonus = if knockback_attack { 0.5 } else { 0.0 };
(enchanted / 2.0 + bonus, knockback_attack)
}
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)`: the target
/// flies where the attacker looks, not away from it.
pub(super) fn extra_knockback_direction(&self) -> (f64, f64) {
let yaw = self.yaw.to_radians();
(f64::from(yaw.sin()), -f64::from(yaw.cos()))
}
/// The attacker's side of `causeExtraKnockback` as the attacking client
/// sees it: the server's player stops sprinting, and its flag reaches the
/// client. (The server's 0.6 speed cut never does: the client's own
/// `Player.attack` simulates no hurt, so it skips the knockback.)
pub(super) fn stop_sprint_after_extra_knockback(&mut self) {
if let Some(p) = self.player.as_mut() {
let _ = p.set_sprinting(false);
}
}
/// After a successful melee hit: fire aspect and wind burst.
pub(super) fn post_attack(&mut self, mob: usize) {
let Some(held) = self.inventory[self.selected].clone() else {
return;
@@ -99,34 +142,6 @@ impl Game<'_> {
items: Some(self.res.items()),
..Default::default()
};
let knockback = self.res.stations().enchantments.modify_value(
Some(&held),
"minecraft:knockback",
&ctx,
&mut self.world.random,
0.0,
);
if knockback > 0.0 {
// LivingEntity.knockback(strength * 0.5, sin(yRot), -cos(yRot)).
let strength = f64::from(knockback) * 0.5;
let (s, c) = (
f64::from(self.yaw.to_radians().sin()),
f64::from(self.yaw.to_radians().cos()),
);
let (dx, dz) = (s, -c);
let l = (dx * dx + dz * dz).sqrt().max(1e-5);
let m = &mut self.mobs[mob];
let v = m.velocity;
m.velocity = Vec3::new(
v.x / 2.0 - dx / l * strength,
if m.on_ground {
(v.y / 2.0 + strength).min(0.4)
} else {
v.y
},
v.z / 2.0 - dz / l * strength,
);
}
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
let mut ignite = 0.0f32;
for (id, level) in held.enchantments() {
@@ -433,10 +448,15 @@ impl Game<'_> {
ignite
}
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
/// so the same enchantment on two pieces stacks instead of replacing itself.
pub(super) fn apply_enchantment_attributes(&mut self) {
let mut wanted: Vec<(String, String, f64, String)> = Vec::new();
for stack in self.armor.iter().flatten() {
wanted.extend(self.res.stations().enchantments.attributes(stack));
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
let Some(stack) = stack else { continue };
for (attr, id, amount, op) in self.res.stations().enchantments.attributes(stack) {
wanted.push((attr, format!("{id}/{slot}"), amount, op));
}
}
let Some(p) = self.player.as_mut() else {
return;
+6 -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);
@@ -350,6 +315,7 @@ impl Game<'_> {
self.tick_fight();
self.tick_dragon_fireballs();
}
self.tick_crystals();
self.tick_eyes();
if self.dim == Dim::End {
self.end.flash.tick(self.world.game_time);
+115 -42
View File
@@ -226,6 +226,102 @@ impl Game<'_> {
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT);
/// the player takes `damage_type`.
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
/// distance and exposure, through a raised shield (`hurtServer` blocks
/// explosions like any attack from where the blast is), then the push
/// (less with explosion knockback resistance; none for spectators or a
/// creative player flying).
pub(super) fn explosion_hurt_player(
&mut self,
center: [f64; 3],
radius: f32,
damage_type: &str,
exploding: Option<u32>,
) {
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
// piece, so two Blast Protection IV pieces cancel it).
let resistance = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok())
.unwrap_or(0.0);
let Some(p) = self.player.as_ref() else {
return;
};
let feet = [p.position.x, p.position.y, p.position.z];
let dist = ((feet[0] - center[0]).powi(2)
+ (feet[1] - center[1]).powi(2)
+ (feet[2] - center[2]).powi(2))
.sqrt()
/ diameter;
if dist > 1.0 || p.health.dead_or_dying() {
return;
}
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
let eye = [
feet[0],
feet[1] + f64::from(p.dimensions.eye_height),
feet[2],
];
let exposure = self.seen_percent(
center,
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
let pow = (1.0 - dist) * f64::from(exposure);
let amount = ((pow * pow + pow) / 2.0 * 7.0 * diameter + 1.0) as f32;
let d = [eye[0] - center[0], eye[1] - center[1], eye[2] - center[2]];
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let dir = if l < 1.0e-4 {
[0.0; 3]
} else {
[d[0] / l, d[1] / l, d[2] / l]
};
let power = pow * (1.0 - resistance);
// `source.doTeamsAllowDamage(entity)`.
let hurtable = exploding.is_none_or(|id| {
horizon_client::scoreboard::teams_allow_damage(self.mob_team(id), self.player_team())
});
let blocking = self.player_blocking(Some(center), damage_type, false);
let raised = blocking.is_some();
let (creative, spectator) = (self.creative, self.spectator());
let flying = self.player.as_ref().is_some_and(|p| p.flying);
let mut outcome = horizon_client::health::BlockOutcome::default();
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
{
// Creative players are invulnerable to explosions.
if !creative
&& !spectator
&& hurtable
&& let Ok(o) = p.hurt_blocking(
&self.motion.health,
self.world.health_rules,
id,
amount,
blocking,
)
{
outcome = o;
}
if !spectator && !(creative && flying) {
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
);
}
}
if outcome.hurt && !creative {
self.hurt_armor(amount);
}
if raised {
self.after_block(outcome, amount, None, false);
}
}
fn explosion_hurt_entities(&mut self, center: [f64; 3], radius: f32, damage_type: &str) {
if radius < 1.0e-5 {
return;
@@ -261,48 +357,23 @@ impl Game<'_> {
horizon_client::scoreboard::teams_allow_damage(g.mob_team(id), victim)
})
};
let player_hurtable = teams_allow(self, self.player_team());
// Player.
if let Some(p) = self.player.as_ref() {
let feet = [p.position.x, p.position.y, p.position.z];
let dist = distance(feet) / diameter;
if dist <= 1.0 && !p.health.dead_or_dying() {
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
let eye = [
feet[0],
feet[1] + f64::from(p.dimensions.eye_height),
feet[2],
];
let exposure = self.seen_percent(
center,
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
let amount = damage(dist, exposure);
let dir = direction(eye);
let power = (1.0 - dist) * f64::from(exposure);
let mut hurt = false;
let creative = self.creative;
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
{
// Creative players are invulnerable to explosions.
if !creative && player_hurtable {
hurt = p
.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false);
}
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
);
}
if hurt && !self.creative {
self.hurt_armor(amount);
}
// The player, then every other player in the level (LAN guests),
// each swapped in as the player.
self.explosion_hurt_player(center, radius, damage_type, exploding);
if let Some(mut host) = self.lan.take() {
let guests: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| g.conn)
.collect();
for conn in guests {
self.guest_hit_by(&mut host, conn, |g| {
g.explosion_hurt_player(center, radius, damage_type, exploding);
});
}
self.lan = Some(host);
}
// Mobs.
let mut mobs = std::mem::take(&mut self.mobs);
@@ -328,8 +399,10 @@ impl Game<'_> {
if hurtable && !m.damage(damage(dist, exposure), source) {
continue;
}
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`: its
// base is 0 for every mob type, so iron golems and ravagers are pushed.
let dir = direction(eye);
let power = (1.0 - dist) * f64::from(exposure) * (1.0 - m.kind.knockback_resistance);
let power = (1.0 - dist) * f64::from(exposure);
m.velocity = Vec3::new(
m.velocity.x + dir[0] * power,
m.velocity.y + dir[1] * power,
+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()
};
};
@@ -53,6 +53,9 @@ impl Game<'_> {
}
} else if let Some(p) = self.player.as_mut() {
p.spectator = false;
if was_spectator {
p.flying_speed = 0.05;
}
if !self.creative {
p.flying = false;
} else if was_spectator {
+18
View File
@@ -85,6 +85,24 @@ impl Game<'_> {
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
self.find_golem(head).is_some() || self.copper_golem_base(head)
}
/// `Level.setBlock` of the golem head `state` at `head`, then
/// `CarvedPumpkinBlock.onPlace`. When the head completes a golem, onPlace
/// builds it inside setBlock and the spot is air again by the time
/// setBlock would update neighbours, so it skips them.
pub(super) fn set_golem_head(
&mut self,
head: [i32; 3],
state: horizon_world::registry::BlockStateId,
) -> Result<()> {
if self.completes_golem(head) {
let at = horizon_core::position::BlockPos::new(head[0], head[1], head[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, state, 2 | 16));
self.process_level_events()?;
} else {
self.level_set(head, state)?;
}
self.check_golem_spawn(head)
}
/// `EntityType.create(level, TRIGGERED)` and `snapTo`: a built golem or
/// wither has no spawn finalization and draws nothing from the level.
pub(super) fn create_triggered(&mut self, kind: &str, at: [f64; 3], yaw: f32) -> Option<u32> {
+27 -18
View File
@@ -10,24 +10,7 @@ impl Game<'_> {
/// Load the configured/placed feature registry used by saplings, fungi
/// and bone meal (once per process; shares the generator's bundle).
pub(super) fn install_live_features(&self) {
if self.res.behaviours().live_features().is_some() {
return;
}
let dir = self.options.resources.join("worldgen");
let started = std::time::Instant::now();
let features = vanillagen::shared_data(&dir, self.pack.assets.registry)
.map_err(|e| e.to_string())
.and_then(|data| LiveFeatures::new(&data).map_err(|e| e.to_string()));
match features {
Ok(Some(features)) => {
self.res.behaviours().set_live_features(Arc::new(features));
log::info!("Horizon: live features ready in {:?}", started.elapsed());
}
Ok(None) => {
log::warn!("Horizon: the worldgen bundle has no features; saplings will not grow")
}
Err(e) => log::warn!("Horizon: live features unavailable: {e}"),
}
install_live_features(&self.res, &self.pack, &self.options);
}
/// `BoneMealItem.useOn` for the targeted block.
pub(super) fn use_bone_meal(&mut self, stack: &ItemStack) -> Result<bool> {
@@ -66,3 +49,29 @@ impl Game<'_> {
Ok(used)
}
}
/// Shared by the initialization worker and fixture setup.
pub(super) fn install_live_features(
res: &super::Resources,
pack: &super::PackResources,
options: &super::Options,
) {
if res.behaviours().live_features().is_some() {
return;
}
let dir = options.resources.join("worldgen");
let started = std::time::Instant::now();
let features = vanillagen::shared_data(&dir, pack.assets.registry)
.map_err(|e| e.to_string())
.and_then(|data| LiveFeatures::new(&data).map_err(|e| e.to_string()));
match features {
Ok(Some(features)) => {
res.behaviours().set_live_features(Arc::new(features));
log::info!("Horizon: live features ready in {:?}", started.elapsed());
}
Ok(None) => {
log::warn!("Horizon: the worldgen bundle has no features; saplings will not grow")
}
Err(e) => log::warn!("Horizon: live features unavailable: {e}"),
}
}
+13 -4
View File
@@ -156,6 +156,7 @@ impl Game<'_> {
|| before(self.dragon_hit().map(|h| h.1))
|| before(self.mob_hit().map(|h| h.1))
|| before(self.guest_hit().map(|h| h.1))
|| before(self.redirectable_hit().map(|h| h.1))
}
/// `LocalPlayer.isHandsBusy`: paddling a boat.
fn hands_busy(&self) -> bool {
@@ -209,10 +210,15 @@ impl Game<'_> {
}
self.swing.tick();
let main = self.inventory[self.selected].clone();
self.hand_strength.tick(main.as_ref().map(|s| HeldStack {
// Player.tick: a different item resets the attack strength, and
// detectEquipmentUpdates gives the player its modifiers.
if self.hand_strength.tick(main.as_ref().map(|s| HeldStack {
item: s.item.to_string(),
rest: format!("{} {} {:?}", s.count, s.damage, s.extra),
}));
})) {
self.attack_ticks = 0;
}
self.attack_item = main.as_ref().map(|s| s.item.clone());
let next = [(self.selected, main), (OFFHAND, self.offhand.clone())];
// shouldInstantlyReplaceVisibleItem: the same stack, or an equal one
// (matchesIgnoringComponents; every item plays the swap animation).
@@ -286,10 +292,13 @@ impl Game<'_> {
pub(super) fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
+ self
.held()
.and_then(|h| self.res.items().get(&h.item))
.attack_item_info()
.map_or(0.0, |i| f64::from(i.attack_speed))
}
/// The item whose attack damage and speed modifiers the player has (`attack_item`).
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
self.res.items().get(self.attack_item.as_deref()?)
}
/// `evaluateWhichHandsToRender`: which of the main and off hand show.
pub(super) fn hands_to_render(&self) -> [bool; 2] {
let held = |slot| {
+95 -29
View File
@@ -6,9 +6,10 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! Opt-in for now (`--server-thread` or `HORIZON_SERVER_THREAD=1`): the
//! player joins as a network player, and the server side of a network player
//! does not do everything single player does yet.
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`): it was the
//! default for one day, but a server's players can't board, ride or hit
//! boats and minecarts yet (#1222), and single player's window is one of
//! them here.
use super::{
Game, Options, PackResources, Resources, UiResources,
lan::{LanSettings, Owner},
@@ -27,7 +28,7 @@ use std::{
net::TcpListener,
path::Path,
sync::{
Arc,
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
mpsc::{Receiver, Sender, channel, sync_channel},
},
@@ -41,6 +42,7 @@ struct Flags {
/// `Minecraft.pause`: a single-player pause screen is open.
paused: AtomicBool,
stop: AtomicBool,
failure: Mutex<Option<String>>,
/// `IntegratedServer.getPort`: the port it is open to the LAN on, 0
/// while it is not.
published: AtomicU16,
@@ -96,19 +98,42 @@ impl Integrated {
rx.recv_timeout(Duration::from_secs(10)).ok()
}
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
pub fn stop(mut self) {
self.halt();
pub fn stop(mut self) -> Result<()> {
self.halt()
}
fn halt(&mut self) {
/// Stop on the server thread; the window polls completion while drawing.
pub fn request_stop(&self) {
self.flags.stop.store(true, Ordering::Relaxed);
}
pub fn stopped(&self) -> bool {
self.thread
.as_ref()
.is_none_or(|thread| thread.is_finished())
}
fn halt(&mut self) -> Result<()> {
self.request_stop();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
thread
.join()
.map_err(|_| "the integrated server panicked")?;
}
match self
.flags
.failure
.lock()
.map_err(|_| "server status poisoned")?
.as_ref()
{
Some(error) => Err(error.clone().into()),
None => Ok(()),
}
}
}
impl Drop for Integrated {
fn drop(&mut self) {
self.halt();
if let Err(error) = self.halt() {
log::error!("Horizon: integrated server shutdown: {error}");
}
}
}
@@ -224,12 +249,13 @@ impl Game<'_> {
options,
settings,
launch,
server_flags,
server_flags.clone(),
server_tasks,
&ready_tx,
);
if let Err(e) = result {
log::error!("Horizon: integrated server: {e}");
*server_flags.failure.lock().unwrap() = Some(e.to_string());
let _ = ready_tx.send(Err(e.to_string()));
}
})?;
@@ -337,13 +363,16 @@ impl Game<'_> {
let mut next = Instant::now();
while !flags.stop.load(Ordering::Relaxed) {
self.run_tasks();
if flags.paused.load(Ordering::Relaxed) {
let paused = flags.paused.load(Ordering::Relaxed);
if paused {
// Only the connections run.
self.tick_server_timed(true)?;
} else {
self.follow_distances(&flags);
let started = Instant::now();
self.tick_server_timed(false)?;
// MinecraftServer.tickServer's tick times, for /tick query.
self.clock.record(started.elapsed());
self.debug_charts.log_tick(started, started.elapsed());
flags.smoothed_tick_ms.store(
self.debug_charts.smoothed_tick_ms.to_bits(),
@@ -351,14 +380,7 @@ impl Game<'_> {
);
}
self.apply_publish();
let rate = self.clock.rate().max(1.0);
next += Duration::from_secs_f64(1.0 / f64::from(rate));
let now = Instant::now();
if next > now {
std::thread::sleep(next - now);
} else if now - next > Duration::from_secs(2) {
next = now;
}
self.wait_for_next_server_tick(&mut next, paused);
}
self.stop_integrated()
}
@@ -386,7 +408,7 @@ impl Game<'_> {
std::thread::sleep(Duration::from_millis(100));
self.save_game()?;
self.lan_close(false);
self.save.flush();
self.save.flush()?;
self.session_lock = None;
Ok(())
}
@@ -430,18 +452,23 @@ impl Game<'_> {
// Commands.performCommand runs in the player's level.
let (back, theirs) = (self.dim, super::Dim::from_id(&g.dimension));
let entered = theirs == back || self.enter_level(Some(&mut host), theirs).is_ok();
if entered {
if entered && let Some(swap) = self.enter_guest(&mut host, conn) {
let command = command.trim_start_matches('/').to_owned();
let lines = self.as_guest(&mut host, conn, |g| {
g.as_console(|g| {
g.chat_capture = Some(Vec::new());
g.run_command(&command);
g.chat_capture.take().unwrap_or_default()
})
// The hub is back in place while the command runs, as for a
// guest's own (`run_guest_commands`): selectors see every
// player (`team join … @a`), and `defaultgamemode` reaches it.
self.lan = Some(host);
let lines = self.as_console(|g| {
g.chat_capture = Some(Vec::new());
g.run_command(&command);
g.chat_capture.take().unwrap_or_default()
});
host = self.lan.take().expect("a command puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
// A teleport into another level happens before its feedback is sent.
self.run_level_teleports(&mut host);
for line in lines.unwrap_or_default() {
for line in lines {
out.push(line.plain());
host.hub.send_to(
conn,
@@ -660,6 +687,45 @@ fn serve(
}),
};
let audio = Audio::silent(&options.resources);
let mut server = Game::new(res, pack, gui, options, settings, audio)?;
let mut server = Game::new(res, pack, gui, options, settings, audio, None)?;
server.run_integrated(launch, flags, tasks, ready)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shutdown_request_does_not_join_a_server_still_saving() {
let flags = Arc::new(Flags::default());
let (release, wait) = channel();
let (tasks, _) = channel();
let server = Integrated {
flags: flags.clone(),
thread: Some(std::thread::spawn(move || wait.recv().unwrap())),
tasks,
world_name: String::new(),
};
server.request_stop();
assert!(flags.stop.load(Ordering::Relaxed));
assert!(!server.stopped());
release.send(()).unwrap();
server.stop().unwrap();
}
#[test]
fn shutdown_reports_a_failed_server_save() {
let flags = Arc::new(Flags::default());
let saving = flags.clone();
let (tasks, _) = channel();
let server = Integrated {
flags,
thread: Some(std::thread::spawn(move || {
*saving.failure.lock().unwrap() = Some("disk write failed".into());
})),
tasks,
world_name: String::new(),
};
assert_eq!(server.stop().unwrap_err().to_string(), "disk write failed");
}
}
+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.
+94 -79
View File
@@ -10,7 +10,7 @@ use horizon_client::{
self, Lan, MAX_PLAYERS, NetConfig,
chunks::ChunkEncoder,
data::SyncData,
entity::EntityState,
entity::{EntityState, States},
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
@@ -44,6 +44,8 @@ pub(super) struct LanHost {
pub host_entity: i32,
/// The host's swings the guests were told of (`Game::swings_started`).
pub host_swings: u32,
/// The host's crack the guests were told of: the block and its stage.
pub host_crack: Option<([i32; 3], i32)>,
pub host_uuid: u128,
/// The host's profile name (the account's, or the offline name).
pub host_name: String,
@@ -59,13 +61,17 @@ pub(super) struct LanHost {
pub mob_players: HashMap<u32, u32>,
/// An integrated server's owner (game/integrated.rs).
pub owner: Option<Owner>,
/// The players' locator bars (game/lan_waypoints.rs), and the guests who
/// are dead, whose respawn replaces their waypoint.
pub waypoints: lan::waypoints::Waypoints,
/// The players' locator bars, one waypoint manager per level by
/// dimension id (game/lan_waypoints.rs), and the guests who are dead,
/// whose respawn replaces their waypoint.
pub waypoints: std::collections::HashMap<String, lan::waypoints::Waypoints>,
pub waypoint_dead: std::collections::HashSet<u128>,
/// Guests' commands waiting for the hub to be back in `self.lan`, where
/// a command can reach every player (game/lan_actions.rs).
pub commands: Vec<super::lan_actions::GuestCommand>,
/// The entities the guests track, described again every tick
/// (`lan_entities`) over last tick's states.
states: lan::entity::States,
}
/// The player who owns an integrated server's world: a guest whose state is
@@ -133,8 +139,7 @@ fn baby_index(kind: &str) -> Option<u8> {
/// A mob's synched data as ServerEntity sends it (only the values that differ
/// from the defaults the client constructs), with vanilla's entry indices:
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
let mut meta = Vec::new();
fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
let kind = m.kind.id.as_str();
if m.fire_ticks > 0 {
meta.push((0, serializer::BYTE, Value::Byte(1)));
@@ -277,14 +282,16 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
}
_ => {}
}
meta
}
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
/// off hand, feet, legs, chest, head, body, saddle).
fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
fn mob_equipment(
m: &Mob,
hub: &mut Hub,
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
let e = &m.extra;
let mut out = Vec::new();
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
@@ -313,7 +320,6 @@ fn mob_equipment(m: &Mob, hub: &mut Hub) -> Vec<(u8, Option<horizon_protocol::it
});
out.push((7, hub.stack(&Some(stack))));
}
out
}
pub const HELP: &[&str] = &[
@@ -395,6 +401,8 @@ impl Game<'_> {
],
"chunks": g.sent_chunks.len(),
"tracked": g.tracker.len(),
// Whether the host draws this guest lying in a bed.
"model_lying": h.models.get(&g.conn).map(|m| m.extra.ints[1] != 0),
// The armour the host draws on this guest, head to feet.
"model_armor": h.models.get(&g.conn).map(|m| {
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
@@ -519,6 +527,7 @@ impl Game<'_> {
settings: LanSettings { port, ..settings },
host_entity,
host_swings: self.swings_started,
host_crack: None,
host_uuid,
host_name: self.options.player_name(),
models: HashMap::new(),
@@ -531,6 +540,7 @@ impl Game<'_> {
waypoints: Default::default(),
waypoint_dead: Default::default(),
commands: Vec::new(),
states: Default::default(),
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -734,76 +744,44 @@ impl Game<'_> {
equipment,
health: p.health.health(),
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
living_flags: living_flags(self.using),
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
living_flags: living_flags(self.using)
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
bed: self.sleeping,
})
}
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub) -> Vec<EntityState> {
let mut out = Vec::with_capacity(self.mobs.len() + self.dropped.len() + self.orbs.len());
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
let kind = m.kind.id.as_str();
let meta = mob_meta(m, hub);
let equipment = mob_equipment(m, hub);
let attributes = self.mob_attributes(m);
let (yaw, pitch) = (m.yaw, m.head_pitch);
out.push(EntityState {
id: m.id as i32,
uuid: entity_uuid(m.id),
kind: kind.to_owned(),
position: [m.position.x, m.position.y, m.position.z],
yaw,
pitch,
head_yaw: m.head_yaw,
on_ground: m.on_ground,
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
data: 0,
meta,
equipment,
attributes,
precise: false,
});
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
e.position = [m.position.x, m.position.y, m.position.z];
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
mob_meta(m, hub, &mut e.meta);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
}
for d in &self.dropped {
let stack = hub.stack(&Some(d.stack.clone()));
out.push(EntityState {
id: d.id as i32,
uuid: entity_uuid(d.id),
kind: "minecraft:item".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
yaw: 0.0,
pitch: 0.0,
head_yaw: 0.0,
on_ground: d.on_ground,
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
data: 0,
meta: vec![(8, serializer::ITEM_STACK, Value::Item(stack))],
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
});
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
e.position = [d.position.x, d.position.y, d.position.z];
e.on_ground = d.on_ground;
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
}
for o in &self.orbs {
out.push(EntityState {
id: o.id as i32,
uuid: entity_uuid(o.id),
kind: "minecraft:experience_orb".to_owned(),
position: [o.position.x, o.position.y, o.position.z],
yaw: 0.0,
pitch: 0.0,
head_yaw: 0.0,
on_ground: o.on_ground,
velocity: [o.velocity.x, o.velocity.y, o.velocity.z],
data: 0,
// ExperienceOrb.DATA_VALUE.
meta: vec![(8, serializer::INT, Value::Int(o.value as i32))],
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
});
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
e.position = [o.position.x, o.position.y, o.position.z];
e.on_ground = o.on_ground;
e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z];
// ExperienceOrb.DATA_VALUE.
e.meta
.push((8, serializer::INT, Value::Int(o.value as i32)));
}
self.lan_object_states(host_entity, hub, &mut out);
out
self.lan_object_states(host_entity, hub, out);
}
/// Run the hub for this tick and apply what the guests did.
@@ -822,24 +800,42 @@ 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();
let entities = if has_guests {
let mut states = std::mem::take(&mut host.states);
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);
self.lan_entities(host_entity, &mut host.hub)
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 {
Vec::new()
};
states.clear();
}
let entities = states.as_slice();
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state.as_ref(),
entities: &entities,
entities,
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
@@ -847,6 +843,7 @@ impl Game<'_> {
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
};
host.hub.movement_checks = (
@@ -879,7 +876,7 @@ impl Game<'_> {
self.lan_log_event(event);
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), &entities, spawn);
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
{
@@ -944,6 +941,7 @@ impl Game<'_> {
}
}
}
host.states = states;
}
self.lan_flush_effects(&mut host);
self.tick_guests(&mut host);
@@ -1003,15 +1001,15 @@ impl Game<'_> {
g.dimension = in_place;
}
self.load_guest_records(host, conn);
let theirs;
let mut theirs = States::default();
let entities = if elsewhere {
if let Err(e) = self.enter_level(Some(host), level) {
log::error!("could not open {}: {e}", level.id());
return;
}
// A dedicated server has no host player.
theirs = self.lan_entities(None, &mut host.hub);
&theirs[..]
self.lan_entities(None, &mut host.hub, &mut theirs);
theirs.as_slice()
} else {
entities
};
@@ -1027,6 +1025,7 @@ impl Game<'_> {
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
};
host.hub.place_new_player(conn, &mut ctx);
@@ -1205,6 +1204,8 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
// Lying in its bed (LivingEntity.getBedOrientation).
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
m.anim.attack_anim_o = m.anim.attack_anim;
horizon_client::mobs::ai::tick_swing(m);
@@ -1216,6 +1217,20 @@ impl Game<'_> {
}
}
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
/// `villager::sleep_pose`): its `facing`.
pub(super) fn bed_facing(&self, head: [i32; 3]) -> i32 {
let state = self.world.block(head[0], head[1], head[2]);
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str);
horizon_client::mobs::villager::bed_facing_code(facing)
}
/// The guests' models for the entity pass.
pub(super) fn guest_model_geometry(
&self,
+324 -35
View File
@@ -35,6 +35,8 @@ pub(super) struct GuestPlay {
using: Option<(usize, u32)>,
use_delay: u32,
attack_ticks: u32,
/// The main hand's item at the guest's last tick (`Game::attack_item`).
attack_item: Option<std::sync::Arc<str>>,
swing: horizon_client::first_person::SwingState,
/// The block being dug and the face the client aimed at.
digging: Option<([i32; 3], [i32; 3])>,
@@ -47,10 +49,18 @@ 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.
potions: super::potions::BodyPotions,
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
@@ -62,6 +72,12 @@ pub(super) struct GuestPlay {
xp_delay: u32,
/// What last killed the guest outright (`/kill`): the death message's key.
death_cause: Option<String>,
/// The equipment modifiers on the guest's attributes (`apply_equipment`)
/// and the armour and main hand they were put on for: the hub's player
/// is a `ServerPlayer` too, whose worn armour counts
/// (`LivingEntity.collectEquipmentChanges`).
modifiers: Vec<(String, String)>,
equipped: Option<Vec<Option<ItemStack>>>,
}
/// A guest's command waiting for the hub (`LanHost::commands`).
@@ -102,6 +118,7 @@ pub(super) struct GuestSwap {
using: Option<(usize, u32)>,
use_delay: u32,
attack_ticks: u32,
attack_item: Option<std::sync::Arc<str>>,
swing: horizon_client::first_person::SwingState,
left: bool,
right: bool,
@@ -127,6 +144,8 @@ pub(super) struct GuestSwap {
swings: u32,
/// How many projectiles there were: those added since are the guest's.
projectiles: usize,
/// The host's equipment modifiers.
modifiers: Vec<(String, String)>,
}
impl GuestPlay {
@@ -190,15 +209,25 @@ impl Game<'_> {
return None;
}
let i = host.hub.guests.iter().position(|g| g.conn == conn)?;
if host.hub.guests[i].motion.is_none() {
let fresh = host.hub.guests[i].motion.is_none();
if fresh {
let p = Vec3::new(
host.hub.guests[i].position[0],
host.hub.guests[i].position[1],
host.hub.guests[i].position[2],
);
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
let kept = host.hub.guests[i].logs_kept.take();
if let (Some(m), Some((hurts, attacks))) = (host.hub.guests[i].motion.as_mut(), kept) {
m.hurt_log = hurts;
m.attack_log = attacks;
}
}
let mut play = host.play.remove(&conn).unwrap_or_default();
if fresh {
// A new body (joined, respawned, changed level): nothing on it yet.
play.equipped = None;
}
let g = &mut host.hub.guests[i];
// The guest's body.
let Some(mut motion) = g.motion.take() else {
@@ -262,11 +291,14 @@ impl Game<'_> {
let using = std::mem::replace(&mut self.using, play.using.take());
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
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.
@@ -301,6 +333,17 @@ impl Game<'_> {
let packets = std::mem::take(&mut self.guest_packets);
let swings = self.swings_started;
let projectiles = self.creatures.projectiles.len();
let modifiers = std::mem::replace(
&mut self.applied_modifiers,
std::mem::take(&mut play.modifiers),
);
// Its worn armour on its attributes, when it changed.
let mut equipped: Vec<Option<ItemStack>> = self.armor.to_vec();
equipped.push(self.inventory.get(self.selected).cloned().flatten());
if play.equipped.as_ref() != Some(&equipped) {
self.apply_equipment();
play.equipped = Some(equipped);
}
Some(GuestSwap {
conn,
i,
@@ -327,6 +370,7 @@ impl Game<'_> {
using,
use_delay,
attack_ticks,
attack_item,
swing,
left,
right,
@@ -345,6 +389,7 @@ impl Game<'_> {
packets,
swings,
projectiles,
modifiers,
})
}
@@ -377,6 +422,7 @@ impl Game<'_> {
using,
use_delay,
attack_ticks,
attack_item,
swing,
left,
right,
@@ -395,7 +441,9 @@ impl Game<'_> {
packets,
swings,
projectiles,
modifiers,
} = swap;
play.modifiers = std::mem::replace(&mut self.applied_modifiers, modifiers);
// What the guest shot is theirs (Projectile.setOwner).
for p in self.creatures.projectiles.iter_mut().skip(projectiles) {
if p.owner == Some(horizon_client::mobs::Who::Player) && p.guest.is_none() {
@@ -425,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);
@@ -433,15 +481,30 @@ impl Game<'_> {
play.using = std::mem::replace(&mut self.using, using);
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
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;
self.book = host_book;
// A lectern the guest's use opened (LecternBlock.openScreen).
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
Some(super::books::BookScreen::View {
lectern: Some(key), ..
}) => Some(key),
_ => 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();
@@ -493,12 +556,31 @@ impl Game<'_> {
g.health = m.health.health();
g.food = m.health.food.food;
g.saturation = m.health.food.saturation;
// A teleport (commands) moves the guest.
// A teleport (commands, a pearl) moves the guest; a pearl's is
// sent even to where they stand (ServerPlayer.teleport).
let at = [m.position.x, m.position.y, m.position.z];
m.sprinting = false;
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 {
// 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));
}
}
@@ -532,9 +614,15 @@ impl Game<'_> {
if let Some(station) = opened_station {
self.lan_station_opened(host, conn, station);
}
if let Some(key) = guest_lectern {
self.lan_open_lectern(host, conn, key);
}
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
@@ -598,6 +686,13 @@ impl Game<'_> {
_ => ("minecraft:furnace", "container.furnace"),
};
(menu, title, Slots::Block(pos))
} else if let Some(BlockEntity::Crafter(_)) = self.world.block_entities.get(&pos) {
// CrafterBlock.useWithoutItem: CrafterMenu over the block entity.
(
"minecraft:crafter_3x3",
"container.crafter",
Slots::Block(pos),
)
} else if let Some(BlockEntity::Brewing(_)) = self.world.block_entities.get(&pos) {
// BrewingStandBlock.useWithoutItem: BrewingStandMenu.
(
@@ -640,6 +735,7 @@ impl Game<'_> {
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
host.hub.open_container(
conn,
menu,
@@ -710,6 +806,7 @@ impl Game<'_> {
conn,
&C::BlockUpdate(play::BlockUpdate { pos: a.pos, state }),
);
self.destroy_block_progress(host.hub.guests[gi].entity_id, a.pos, -1);
host.hub.broadcast_near(
[
f64::from(a.pos[0]),
@@ -792,6 +889,8 @@ impl Game<'_> {
],
};
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let swung = self.as_guest(host, conn, |g| {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
@@ -802,6 +901,7 @@ impl Game<'_> {
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_use_sounds(host, conn, logged, heard);
// The client predicted a result: correct it with what really is.
for pos in [
p.pos,
@@ -834,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) {
@@ -870,6 +976,24 @@ impl Game<'_> {
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
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);
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
}
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
let g = &mut host.hub.guests[gi];
@@ -877,7 +1001,11 @@ impl Game<'_> {
self.drop_guest_items(host, gi, vec![c]);
}
}
S::ContainerClose(p) if self.lan_crafting_closed(host, conn, Some(p.container)) => {
S::ContainerClose(p)
if self.lan_crafting_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() {
self.drop_guest_items(host, gi, vec![c]);
@@ -1068,19 +1196,50 @@ 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 m = self.mobs.iter().find(|m| m.id as i32 == id)?;
let centre = [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
];
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)?;
let (lo, hi) = (b.min_position(), b.max_position());
[
(lo.x + hi.x) / 2.0,
(lo.y + hi.y) / 2.0,
(lo.z + hi.z) / 2.0,
]
}
};
let g = &mut host.hub.guests[gi];
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let d = [centre[0] - eye[0], centre[1] - eye[1], centre[2] - eye[2]];
@@ -1103,8 +1262,24 @@ impl Game<'_> {
}
return;
}
// A ghast's fireball or a wind charge: turned along the guest's own
// view (ServerGamePacketListenerImpl.handleInteract, then
// Player.attack's AIM_DEFLECT).
if let Some(i) = self
.creatures
.projectiles
.iter()
.position(|p| p.id != 0 && p.id as i32 == id && super::deflect::redirectable(p))
{
self.as_guest(host, conn, |g| {
if g.within_interaction_range(i) {
g.deflect(i);
}
});
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| {
@@ -1329,7 +1504,10 @@ impl Game<'_> {
g.food = 20;
g.saturation = 5.0;
g.sent_health = None;
g.motion = None;
if let Some(m) = g.motion.take() {
let m = *m;
g.logs_kept = Some((m.hurt_log, m.attack_log));
}
let mut xp = Experience {
level: g.xp_level,
progress: g.xp_progress,
@@ -1353,6 +1531,7 @@ impl Game<'_> {
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
};
host.hub.respawn(i, spawn, &ctx);
@@ -1364,6 +1543,8 @@ impl Game<'_> {
/// Per tick, for every guest: dig, use items, pick up drops, die.
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
@@ -1407,6 +1588,20 @@ impl Game<'_> {
}
});
}
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
let g = &host.hub.guests[gi];
let held = g
.inventory
.get(g.selected)
.cloned()
.flatten()
.map(|s| s.item);
let play = host.play.entry(conn).or_default();
if play.attack_item != held {
play.attack_ticks = 0;
play.attack_item = held;
}
self.guest_delayed_destroy(host, conn);
self.guest_server_tick(host, conn);
// Player.tick's statistics and ServerPlayer.tick's triggers, then
@@ -1551,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;
};
@@ -1592,9 +1788,7 @@ impl Game<'_> {
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
let sprinting = w.player.sprinting;
w.player
.health
.food
.add_exhaustion(if sprinting { 0.2 } else { 0.05 });
.cause_food_exhaustion(if sprinting { 0.2 } else { 0.05 });
}
let landed = (|| -> horizon_client::Result<()> {
w.set_on_ground_with_movement(on_ground, false, movement)?;
@@ -1607,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);
@@ -1664,7 +1865,12 @@ impl Game<'_> {
play.mining = None;
let start = play.dig_start;
let state = self.world.block(pos[0], pos[1], pos[2]);
if !dug || self.res.facts().is_air(state) {
if !dug {
return;
}
if self.res.facts().is_air(state) {
// Already broken (the guest's own tick dug it out): only the crack goes.
self.guest_crack_gone(host, conn, pos);
return;
}
let progress = self
@@ -1713,18 +1919,7 @@ impl Game<'_> {
self.as_guest(host, conn, |g| {
let _ = g.break_block(pos);
});
if let Some(g) = host.hub.guest(conn) {
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&C::BlockDestruction(play::BlockDestruction {
id: g.entity_id,
pos,
progress: 255,
}),
Some(conn),
);
}
self.guest_crack_gone(host, conn, pos);
}
/// The crack animation other players see.
@@ -1736,6 +1931,18 @@ impl Game<'_> {
let Some((pos, _)) = play.digging else {
return;
};
// ServerPlayerGameMode.tick: a block gone while it is dug (the guest's own tick broke
// it, or something else did) clears the crack.
if play.last_progress >= 0
&& self
.res
.facts()
.is_air(self.world.block(pos[0], pos[1], pos[2]))
{
play.last_progress = -1;
self.guest_crack_gone(host, conn, pos);
return;
}
let progress = play
.mining
.as_ref()
@@ -1743,6 +1950,8 @@ impl Game<'_> {
let swinging = play.swing.swinging();
if progress != play.last_progress && progress >= 0 {
play.last_progress = progress;
// The host player sees it too (on the server thread its window is a guest).
self.destroy_block_progress(host.hub.guests[gi].entity_id, pos, progress);
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
@@ -1757,6 +1966,25 @@ impl Game<'_> {
let _ = swinging;
}
/// `level.destroyBlockProgress(id, pos, -1)` for a guest: its crack on `pos` is gone, for the
/// host and the other guests.
fn guest_crack_gone(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) else {
return;
};
self.destroy_block_progress(id, pos, -1);
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&C::BlockDestruction(play::BlockDestruction {
id,
pos,
progress: 255,
}),
Some(conn),
);
}
/// Dropped items touching a guest go into their inventory.
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
let (conn, mode, position, id) = {
@@ -1895,6 +2123,33 @@ impl Game<'_> {
}
}
/// `level.playSound(player, ...)` of a guest's use it predicted (`remote::hand_operated`
/// blocks only, as the guest decides): the others hear it now, the guest already did.
fn guest_use_sounds(&mut self, host: &mut LanHost, conn: u32, logged: usize, heard: usize) {
let changed: Vec<(i32, i32, i32)> = self
.world
.block_log
.as_ref()
.map_or(&[][..], |log| log.get(logged..).unwrap_or(&[]))
.to_vec();
let facts = self.res.facts();
if changed.is_empty()
|| !changed.iter().all(|&(x, y, z)| {
super::remote::hand_operated(&facts.get(self.world.block(x, y, z)).name)
})
{
return;
}
let Some(capture) = self.audio.capture.as_mut() else {
return;
};
let sounds: Vec<_> = capture.drain(heard.min(capture.len())..).collect();
for (name, at, volume, pitch) in sounds {
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
}
}
/// Positional sounds and level events of the tick, for the guests.
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
let sounds = self.audio.capture.take().unwrap_or_default();
@@ -1961,6 +2216,7 @@ impl Game<'_> {
}
play.swing.tick();
}
self.lan_host_crack(host);
if self.swings_started != host.host_swings {
host.host_swings = self.swings_started;
if let (Some(p), Some(c)) = (self.player.as_ref(), self.swing.current) {
@@ -1972,6 +2228,39 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The host player's crack, for the guests (`ServerLevel.destroyBlockProgress` from the
/// host's mining): each new stage, and -1 once it stops or breaks the block.
fn lan_host_crack(&mut self, host: &mut LanHost) {
let now = self
.mining
.as_ref()
.and_then(|m| (m.progress > 0.0).then(|| (m.pos, ((m.progress * 10.0) as i32).min(9))));
if now == host.host_crack {
return;
}
let packet = |pos: [i32; 3], progress: u8| {
C::BlockDestruction(play::BlockDestruction {
id: host.host_entity,
pos,
progress,
})
};
let near = |pos: [i32; 3]| [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])];
if let Some((pos, _)) = host.host_crack
&& now.is_none_or(|(p, _)| p != pos)
{
host.hub
.broadcast_near(near(pos), 64.0, &packet(pos, 255), None);
}
if let Some((pos, stage)) = now {
host.hub
.broadcast_near(near(pos), 64.0, &packet(pos, stage as u8), None);
}
host.host_crack = now;
}
}
/// ClientboundSwingAnimationPacket: the hand and the animation (its type and
/// base duration; each client applies the swinger's haste and fatigue).
fn swing_packet(id: i32, hand: Hand, animation: SwingAnimation) -> C {
Loaded 100 of 643 files, more files were not shown because too many files have changed in this diff. Show more