Compare commits

...
2536 Commits
Author SHA1 Message Date
Ludovic Marie 88b27070bd Merge pull request #1476 from ludovic111/fix/st-held-use
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:43:11 +02:00
Ludovic Marie 499f8bddfc Merge pull request #1475 from ludovic111/fix/st-elytra
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:41:32 +02:00
Ludovic Marie 5904922865 Merge pull request #1466 from ludovic111/fix/st-books
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:39:56 +02:00
Ludovic Marie 49e706e8be Merge pull request #1465 from ludovic111/fix/st-signs
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:38:08 +02:00
Ludovic Marie 5e63b0512d Merge pull request #1464 from ludovic111/fix/st-keep-inventory
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:36:22 +02:00
Ludovic Marie 22b2711cf9 Merge pull request #1277 from ludovic111/feat/java-gbuffers-terrain
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-24 03:18:46 +02:00
Ludovic Marie d8dd2023dd Merge pull request #1447 from ludovic111/chore/client-mods-feature
🤖 Agent: merged after verify and CI parity passed, built merged with current main. Code mods become horizon-client's `mods` feature: CI's two test commands run with `--features horizon-client/mods,horizon-mods/pack`; tests pass with and without it.
2026-10-24 03:01:51 +02:00
Ludovic Marie d84d7a7e61 Merge pull request #1522 from ludovic111/fix/prepare-netty-natives
🤖 Agent: merged after verify and CI parity passed, built merged with current main (launcher-ship-1). #1521: generated docs keep LF line endings on Windows, so the launcher builds there again (dry run 40035909909 green on all three OSes). #1522: Prepare no longer requires Netty's optional native transport jar, which Prism never downloads (fixes #1487; test plus a real Prism-layout prepare).
2026-10-24 02:53:32 +02:00
Ludovic Marie 1cd0dd8c5d Merge pull request #1529 from ludovic111/fix/ds-parity-flake
🤖 Agent: merged after verify and CI parity passed, built merged with current main. The dedicated_server flake was the harness client never resending its position, so a villager whose chunk went out after its only move was never shown to it (0/21 → 21/21 with the server's tick thread stalled after the join). Failing harnesses now print the reason in CI.
2026-10-24 02:52:06 +02:00
Ludovic Marie 7f60a55ca3 Merge pull request #1468 from ludovic111/fix/one-handed-maps
🤖 Agent: merged after verify and CI parity passed, built merged with current main (reports-gate-2: each claim checked against vanilla 26.3, in-frame and --server-thread; evidence on the issue and in the PR body).
2026-10-24 02:40:52 +02:00
Ludovic Marie 16db2f44ae Merge pull request #1420 from ludovic111/fix/st2-chat
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st2: entity use with either hand and server chat components; lan_e2e checks pass in both modes. The hands check's only flake was a vanilla crit arrow killing the cow, fixed in c89695dd9: 40 of 40 runs pass).
2026-10-24 02:36:33 +02:00
Ludovic Marie 0b56d62273 Merge pull request #1521 from ludovic111/fix/launcher-doc-crlf
🤖 Agent: merged after verify and CI parity passed, built merged with current main (launcher-ship-1). #1521: generated docs keep LF line endings on Windows, so the launcher builds there again (dry run 40035909909 green on all three OSes). #1522: Prepare no longer requires Netty's optional native transport jar, which Prism never downloads (fixes #1487; test plus a real Prism-layout prepare).
2026-10-24 02:16:48 +02:00
Ludovic MarieandClaude Opus 5.5 574e3ca567 dedicated_server parity: the client re-sends its position as vanilla's does
The harness failed about 1 run in 3 on CI with "play-trade villager false" on
Horizon's side: the protocol client never saw the villager it trades with.

A server pairs a player with an entity only while the entity's chunk has been
sent to it, and checks again when either moves (ChunkMap.move / updatePlayers).
VanillaProtocolClient sent its position once, at the spawn teleport, and never
again. Horizon's first chunk batch goes out two ticks after the player is placed
(after the first tick's packets, as vanilla orders them); when a busy machine
held the tick thread back long enough for the client's move to be handled one
tick after placement, the villager's chunk wasn't sent yet, and with no later
move the villager was never paired.

The 26.3 client re-sends its position every 20 ticks standing still
(LocalPlayer.sendPosition, after a ServerboundClientTickEnd each tick). The
parity client now does the same from the time it is loaded until its death, so
an entity whose chunk comes late is paired at the next reminder, on either
server.

On failure the harness now adds the servers' and clients' warnings and errors to
its message and the client log's tail when the client fails, and parity.py's
summary prints the end of the first failing harnesses' logs, so CI shows why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 01:33:16 +02:00
Ludovic Marie 2a2b4acb5c Merge remote-tracking branch 'origin/main' into fix/st2-chat 2026-10-24 01:07:00 +02:00
Ludovic MarieandClaude Opus 5.5 52bc81ca5b Launcher: the command reference is the same on Windows
launcher.yml's Windows build has failed since the reference test landed: Git for Windows checks
text out with CRLF (core.autocrlf), so docs/launcher-commands.md never equalled what markdown()
generates. .gitattributes now pins LF for the generated files tests compare byte for byte, and
the launcher's check reads the file whatever line endings a clone gave it, with a test of a
CRLF checkout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 00:48:08 +02:00
Ludovic MarieandClaude Opus 5.5 e1fff77a48 Prepare: Netty's native transports are optional, as in the game
Prism Launcher never downloads io.netty:netty-transport-native-kqueue (or the epoll one on
Linux), so preparing from a Prism install stopped on a jar the game runs without: vanilla's
EventLoopGroupHolder.remote takes KQueue/Epoll only when isAvailable(), else Netty NIO.
Preparing now imports a native transport when the launcher has it (checked against the
version JSON's sha1, as before) and goes on without it otherwise; every other library stays
required. Fixes #1487.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 00:38:13 +02:00
Ludovic Marie 265461f2eb Merge pull request #1467 from ludovic111/fix/bundle-clicks
🤖 Agent: merged after verify and CI parity passed, built merged with current main (reports-gate-2: each claim checked against vanilla 26.3, in-frame and --server-thread; evidence on the issue and in the PR body).
2026-10-24 00:32:56 +02:00
Ludovic Marie d6dc08098a Merge pull request #1419 from ludovic111/fix/st2-entity-hands
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st2: entity use with either hand and server chat components; lan_e2e checks pass in both modes. The hands check's only flake was a vanilla crit arrow killing the cow, fixed in c89695dd9: 40 of 40 runs pass).
2026-10-24 00:08:06 +02:00
Ludovic Marie c924894624 Merge remote-tracking branch 'origin/main' into feat/java-gbuffers-terrain 2026-10-23 23:08:59 +02:00
Ludovic Marie ba8bdc7179 Merge remote-tracking branch 'origin/main' into fix/st2-chat 2026-10-23 23:07:44 +02:00
Ludovic Marie 455a9996d2 Merge pull request #1462 from ludovic111/fix/amd-regress-1
🤖 Agent: merged after verify and CI parity passed. A stopgap while in-frame is the default: an overloaded world slows down instead of dropping to 2 FPS (2,500 pigs: 1.7 → 16 FPS). It is removed with the in-frame path (FEATURES 4zg) once the server thread is the default again (#1463–#1466 and following).
2026-10-23 23:07:17 +02:00
Ludovic Marie cbc469249e Merge remote-tracking branch 'origin/main' into fix/bundle-clicks 2026-10-23 23:06:24 +02:00
Ludovic Marie e436116e38 Merge pull request #1463 from ludovic111/fix/st-totem
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-23 22:33:17 +02:00
Ludovic Marie 35d2781e9d Merge pull request #1454 from ludovic111/feat/client-probe-binary
🤖 Agent: merged after verify passed, with local proof: all 100 client harnesses ran on main and on the branch and their transcripts were diffed (89 byte-identical, the rest only timings or stamps). 81 client examples become one probe binary.
2026-10-23 22:16:12 +02:00
Ludovic Marie 66d1292d10 Merge pull request #1453 from ludovic111/feat/world-probe-binary
🤖 Agent: merged after verify passed, with local proof: all 217 world harnesses ran on main and on the branch and their transcripts were diffed (203 byte-identical, the rest only timings or known run-to-run counts). 168 world examples become one probe binary, with 1,097 duplicated helper lines and 11 dead examples gone.
2026-10-23 22:15:44 +02:00
Ludovic Marie d3de3cd0a9 Merge remote-tracking branch 'origin/main' into fix/amd-regress-1 2026-10-23 22:10:55 +02:00
Ludovic Marie e3b168688e Merge remote-tracking branch 'origin/feat/world-probe-binary' into feat/client-probe-binary 2026-10-23 22:08:17 +02:00
Ludovic Marie 974e18e52a Merge remote-tracking branch 'origin/main' into feat/world-probe-binary
# Conflicts:
#	crates/horizon-client/examples/lan_flat.rs
2026-10-23 22:05:48 +02:00
Ludovic MarieandClaude Opus 5.5 c89695dd93 lan_e2e hands: shoot the cow with a half drawn bow so it lives
A fully drawn bow fires a crit arrow: AbstractArrow.onHitEntity adds
random.nextInt(damage / 2 + 2) to the 6 damage, so one shot in five does 10
and kills the 10 health cow. The off hand bucket then had nothing to milk
("the host's off hand holds minecraft:bucket"), on either side, in any mode.
Under 20 ticks of draw the arrow is no crit and does at most 6. The check
now also asserts the cow is still there after the shot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 22:01:47 +02:00
Ludovic Marie 4d9baf60d4 Merge remote-tracking branch 'origin/fix/st2-entity-hands' into mm1-1420
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 21:29:13 +02:00
Ludovic Marie 0a36a6d8c6 Merge remote-tracking branch 'origin/main' into mm1-1419
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 21:20:14 +02:00
Ludovic Marie b9501fc826 Merge remote-tracking branch 'origin/feat/world-probe-binary' into feat/client-probe-binary 2026-10-23 21:09:59 +02:00
Ludovic Marie 9dd9a83699 Merge pull request #1427 from ludovic111/fix/st-particles
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st1: /particle and explosion particles from a server reach its players; lan_e2e evidence in the PR body).
2026-10-23 21:06:12 +02:00
Ludovic Marie 341576a084 Merge remote-tracking branch 'origin/main' into feat/world-probe-binary 2026-10-23 21:06:03 +02:00
Ludovic Marie 0b0e4a8aae Merge remote-tracking branch 'origin/main' into fix/bundle-clicks 2026-10-23 20:53:33 +02:00
Ludovic Marie c19418b55d Merge remote-tracking branch 'origin/main' into fix/one-handed-maps 2026-10-23 20:53:25 +02:00
Ludovic MarieandClaude Opus 5.5 8acb46756b Bundle clicks: the open menu's slotsChanged for the player's inventory
BundleItem.broadcastChangesOnContainerMenu calls slotsChanged(player
inventory) after a bundle click. The menus that don't check which container
changed recompute from their inputs (crafting grids, loom, stonecutter,
cartography, merchant, smithing's hasRecipeError). The menu click harness
found it on a loom with a fixture result: 8000/8000 cases now match (seeds 7
and 11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 20:52:41 +02:00
Ludovic Marie ee79b8f0cd Merge remote-tracking branch 'origin/main' into mm1-1447 2026-10-23 20:38:10 +02:00
Ludovic Marie 8667cef3a1 Merge remote-tracking branch 'origin/main' into mm1-1420 2026-10-23 20:37:55 +02:00
Ludovic Marie fe1d544d02 Merge remote-tracking branch 'origin/fix/st2-entity-hands' into mm1-1420
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 20:37:55 +02:00
Ludovic Marie 33e35d37c0 Merge pull request #1267 from ludovic111/feat/java-packs-photon-chocapic
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-23 20:32:04 +02:00
Ludovic Marie 0cc3b4a057 Merge remote-tracking branch 'origin/main' into mm1-1447
# Conflicts:
#	.github/workflows/ci.yml
2026-10-23 20:21:46 +02:00
Ludovic Marie 81fb4c7371 Merge remote-tracking branch 'origin/main' into mm1-1419
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/lan/entity.rs
#	tools/lan_e2e.py
2026-10-23 20:18:12 +02:00
Ludovic Marie e247790c83 Merge pull request #1472 from ludovic111/fix/main-build-1432
🤖 Agent: merged after verify passed; main builds again. Local proof on the Mac mini: cargo check -p horizon-client --bins --tests fails on 8a4ebcc55 with the two E0599 errors and passes here.
2026-10-23 20:15:12 +02:00
Ludovic Marie ceb12718cb Merge pull request #1448 from ludovic111/chore/python-helpers-lab
🤖 Agent: merged after verify and CI parity passed, built merged with current main (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-23 20:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 f74d2fd5f4 Restore Guest::send_all and Behaviours::bone_meal_spreads: main builds again
#1432 deleted both as unused; #1424 (level events) and #1425 (scoreboard)
merged after it and call them, so horizon-client's binary failed with
E0599 at level_particles.rs:54 and scoreboard.rs:338. Both functions come
back unchanged from 4d7f30f14^1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 20:05:12 +02:00
Ludovic MarieandClaude Opus 5.5 7dac46ec3c Bundles take and give items in inventories and containers
A carried bundle clicked on a stack took its place instead of taking it in
(#1456): Horizon's menus had no BundleItem click overrides. The PICKUP click
now runs them first, as AbstractContainerMenu.tryItemClickBehaviourOverride:

- a carried bundle takes as much of a clicked stack as its weight allows
  (tryTransfer), and a right click on an empty slot puts its first stack
  there (removeOne, the rest going back in);
- a stack clicked onto a bundle in a slot goes in as far as it fits, and a
  right click with an empty hand takes the bundle's first stack out.

BundleContents.Mutable is in horizon_client::bundle (exact Fraction
weights: 1/max stack, 1 for a beehive with bees, a nested bundle's contents
plus 1/16; shulker boxes don't fit), used by the menu engine and the
LAN/server-thread container engine. The insert, insert-fail and remove-one
sounds play at the player.

In single player the inventory screen's empty-hand press went to an older
click path instead of the menu engine; it now goes to the engine, as on a
server, so the right click on a bundle works there too.

The menu click harness now has bundles (empty, loaded, nested; often in
hand) and compares the player's sounds: 3000/3000 bundle-heavy cases match,
2796/3000 without the overrides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:48 +02:00
Ludovic MarieandClaude Opus 5.5 8ef418e223 Maps in one hand: the off hand, or the main hand beside an item, draws the map
FirstPersonHandsAndItemsRenderer.submitArmWithItem draws a stack with a
map_id two-handed only from the main hand with an empty off hand; any other
map is renderOneHandedMap in its own hand. Horizon only had the two-handed
case and drew every other held map as its item model (#1459).

MapHold picks the case from the hand renderer's visible items, and
one_handed_map_pose is renderOneHandedMap's transform, checked against
vanilla's PoseStack. renderMap (background, map, decorations) is shared by
both cases. The arms under held maps are still not drawn, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:47 +02:00
Ludovic MarieandClaude Opus 5.5 ad76f911bd Shields, tridents, crossbows and spyglasses end their use on a server: the client starts it too
Item.use runs on the client as well (MultiPlayerGameMode.useItem), and
only a use the client started sends RELEASE_USE_ITEM when the button
lets go. A server's client started one for bows and food only, so a
shield kept blocking after release, a charged trident never left the
hand, a loaded crossbow stayed "in use" on the server and never fired,
and a spyglass never scoped. The client now starts what the window's
own Item.use does: a bow or crossbow with ammunition, a trident, a
spyglass, a shield off cooldown, and keeps it until the button lets go.

The server's RELEASE_USE_ITEM let go of a main-hand bow only. It now
runs ServerPlayer.releaseUsingItem with the used item's hand, through
the release the window's own tick shares (bow, trident, spyglass).

A loaded crossbow reached its client without its ammunition: the
network stack gets minecraft:charged_projectiles (registry id 51,
ItemStackTemplate list), and a server's client decodes it.

lan_e2e gets a helduse check, for the host and the guest, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:50:56 +02:00
Ludovic Marie 8a4ebcc553 Merge pull request #1425 from ludovic111/fix/st-hud
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 19:38:45 +02:00
Ludovic Marie 9ff9055b4f Merge pull request #1424 from ludovic111/fix/st-level-events
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 19:37:19 +02:00
Ludovic Marie 0495d3cbdd Merge pull request #1423 from ludovic111/fix/st-thrown
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 19:35:52 +02:00
Ludovic Marie 4d7f30f145 Merge pull request #1432 from ludovic111/chore/delete-unused-pub-items
🤖 Agent: merged after verify and CI parity passed (review-0-survey quick win: dead pub items; the generated version.rs is kept as the generator writes it).
2026-10-23 19:17:10 +02:00
Ludovic MarieandClaude Opus 5.5 5ed8181adc Elytra glides on a server's players: the client sends START_FALL_FLYING and the server checks it
A server's client never took the chest slot's Elytra into its own
movement (player.glider) and never told the server it started gliding,
so on the server thread and as a LAN guest the player just fell. The
client now sets the glider as the window's own tick does and sends
ServerboundPlayerCommandPacket START_FALL_FLYING when a glide starts.

The hub used to accept that command blindly and never clear it. The game
now answers it as handlePlayerCommand does (Player.tryToStartFallFlying,
else stopFallFlying), and each tick runs LivingEntity.updateFallFlying
for a gliding guest: the glide ends once canGlide fails (landed, riding,
flying, levitation, a glider about to break), every 20th tick wears the
Elytra, every 10th is an elytra_glide game event, and a glide that isn't
diving keeps the fall distance at 1 (checkFallDistanceAccumulation).

lan_e2e gets an elytra check, for the host and the guest, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:12:44 +02:00
Ludovic MarieandClaude Opus 5.5 5617d702cc Books open on a server's players, and what they write and sign reaches the server
A server's player (a LAN guest, or single player's window on its server
thread) could neither write in a book and quill nor read a written book:
the server opened the screens in the guest's turn and dropped them (#1366).
As in vanilla, the client opens a book and quill's editor itself when it
uses one (LocalPlayer.openItemGui) and sends ServerboundEditBookPacket
when it saves or signs; the server writes the pages or makes the written
book, signed with the player's name, in that hotbar slot or the off hand.
A written book opens on the client when the server sends
ClientboundOpenBookPacket for its hand (ServerPlayer.openItemGui).

horizon-protocol: OpenBook and EditBook (pages a list of at most 100).
horizon-ctl: book sign presses Sign, then Sign and Close; book done on a
book and quill saves it, as its Done button does.

lan_e2e books: host and guest each write, sign, read and drop a book,
both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 52d8ed6721 Signs open their editor on a server's players, and what they write reaches the server
A sign a server's player placed (a LAN guest, or single player's window on
its server thread) opened no editor: its turn's editor was dropped with
the rest of the guest's screens (#1380). The server now does as
ServerPlayer.openTextEdit: the block, then ClientboundOpenSignEditorPacket,
and the player may write on that sign. The client opens its editor on the
sign it has and sends ServerboundSignUpdatePacket when it closes; the
server takes the lines (formatting codes stripped) unless the sign is
waxed, the player was not the one allowed or went out of reach, and sends
the sign to everyone. The window's own sign now reaches its guests too
(sendBlockUpdated, not only a save).

horizon-protocol: OpenSignEditor and SignUpdate, with SignTextSlot as a
VarInt and the lines as a fixed list of four (26.3's stream codecs).
horizon-ctl: type and key reach an open sign editor; block shows a sign's
lines.

lan_e2e signs: host and guest each place, write and see, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 de358cd721 A server's players drop their inventory on death unless keep_inventory is on
The guest death path (LAN guests, dedicated players and single player's
window on its server thread) gave the experience reward but never ran
Player.dropEquipment, so a player died with keep_inventory off and kept
everything (#1284). Both paths now share one drop_inventory:
destroyVanishingCursedItems, then Inventory.dropAll from the eyes less 0.3.

ServerPlayer.die skips the loot for spectators only; the window's own
death skipped it in creative too, which vanilla doesn't.

lan_e2e keepinv: host and guest, keep_inventory off and on, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 b8e729b950 Totems save a server's players: the guest's turn uses it up, and entity event 35 reaches every client
A server's players (LAN guests, and single player's window on its server
thread) never had a totem ready, and nothing applied one: lethal damage
killed them with the totem still in hand (#1292). Each guest turn now
readies the totem from the guest's hands and, when its hurt set it off,
uses it up at the end of the turn (LivingEntity.checkTotemDeathProtection):
1 health, the item_used stat, the used_totem trigger, the use vibration,
the totem's effects.

Entity event 35 now goes to every client around, the holder's own too,
and a client plays it as ClientPacketListener.handleEntityEvent does: the
totem particles, the sound heard locally (not sent on as a sound packet,
so no client hears it twice) and the holder's rumble.

lan_e2e totem: host and guest, off hand and main hand, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic Marie b65e082fd7 Merge pull request #1450 from ludovic111/chore/client-zip-crate
🤖 Agent: merged after verify and CI parity passed (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-23 18:02:49 +02:00
Ludovic MarieandClaude Opus 5.5 55264459ce In-frame ticks stop at a tick's length per frame: an overloaded world slows down instead of drawing at 2 FPS
Single player's world ticks in the frame by default (#1428). When its ticks
take longer than 50 ms, every frame fell behind and ran the client timer's
ten, so the picture stalled at 1-2 FPS (a player's report on an AMD 680M,
in-frame tick at 36.7 ms). Vanilla's world ticks on the integrated server's
thread, which falls behind without costing frames and logs "Can't keep up!".

A frame now stops running world ticks once they took a tick's length and
drops the rest, logging vanilla's overload warning (1 s + 20 ticks behind,
at most every 10 s + 100 ticks). Ticks of a few milliseconds still get the
full ten per frame; sprints and server clients are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:47:22 +02:00
Ludovic MarieandClaude Opus 5.5 0f3a92ac2b Merge feat/java-packs-photon-chocapic into feat/java-gbuffers-terrain
Brings main's shader api 2 (#1414) up the stack. One path for blending:
the terrain programs' blend.* now go through Pass.blend/buffer_blends like
composite and final (java_pack::pass_blends). depthtex1/depthtex2 are one
source, DepthSolid: Horizon's depth before translucent geometry, then the
pack's solid terrain's. Horizon's world goes to the pack's first terrain
target (colortex1 for MakeUp).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:46:12 +02:00
Ludovic Marie 036e6bd8d6 Merge pull request #1444 from ludovic111/fix/parity-shard-world-spawn
🤖 Agent: merged after verify and CI parity passed. world_spawn runs as 4 shards, so the job's tail drops from 1,436 s to about 465 s.
2026-10-23 17:40:37 +02:00
Ludovic Marie 80d6252734 Merge pull request #1443 from ludovic111/fix/parity-prebuild-selection
🤖 Agent: merged after verify passed. The parity probe prebuild repeats each harness's own -p and features selection, so harness builds after the prebuild compile nothing (0 of 10, was 10 of 10). The full harness set ran on it locally in #1453's before/after diff.
2026-10-23 17:39:09 +02:00
Ludovic Marie f7144ccd19 Merge pull request #1435 from ludovic111/fix/ci-verify-no-examples
🤖 Agent: merged after verify passed. CI verify no longer builds the ~275 examples: the job goes from 7.8 to 5.6 min (review-1-ci).
2026-10-23 17:37:42 +02:00
Ludovic Marie a74d6e49e6 Merge pull request #1458 from ludovic111/fix/high-res-pack-gui
🤖 Agent: merged after verify passed, with local parity proof (103/103 on this head). GUI sprites are measured in GUI units whatever the pack's resolution, as in vanilla: inventories, tooltips and toasts look right with Faithful 32x and 64x.
2026-10-23 17:31:27 +02:00
Ludovic Marie ea426bd53d Merge pull request #1457 from ludovic111/fix/resource-pack-crash
🤖 Agent: merged after verify passed, with local parity proof: parity --changed passed 103/103 on #1458's head, which contains this PR. Faithful 64x reproduced a startup panic on main (a 32768 px shared atlas over the 16384 GPU limit); with the fix it reaches the world. A pack that can't fit now falls back to the default resources, as vanilla does.
2026-10-23 17:29:56 +02:00
Ludovic Marie ab2bc22515 Merge origin/main into feat/java-packs-photon-chocapic
# Conflicts:
#	crates/horizon-shaderpack/src/translate.rs
#	docs/shader-packs.md
2026-10-23 17:20:17 +02:00
Ludovic Marie ce3284a516 Merge pull request #1429 from ludovic111/feat/launcher-single-channel
🤖 Agent: merged after verify and CI parity passed; release dry run 39935111815 was green (the smoke test drew a world, no blocker, it would publish as the release and latest). One channel, as Ludovic decided on 2026-10-23.
2026-10-23 17:11:04 +02:00
Ludovic Marie 9137279e10 Merge pull request #1418 from ludovic111/fix/st2-saved-state
🤖 Agent: merged after verify and CI parity passed; lan_e2e (hands, styles, feedback, chat, frames, riding, mounts, beds, contacts, worldspawn) passes on the server thread and in-frame (bugs-st-2).
2026-10-23 17:09:33 +02:00
Ludovic MarieandClaude Opus 5.5 441774c229 held_drop: pass its probe name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:51:55 +02:00
Ludovic MarieandClaude Opus 5.5 693556ce32 Point the particle harnesses' source lists at the probe modules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:51:55 +02:00
Ludovic Marie dfbb106c69 Merge feat/world-probe-binary (harness fixes) 2026-10-23 16:51:54 +02:00
Ludovic MarieandClaude Opus 5.5 38bce65c21 Pass the probe name where the harness codemod missed it
block_survival and state_provider hashed the name as an artifact instead of
passing it; decorated_region runs chunk_dump through chunk_generation's binary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:50:01 +02:00
Ludovic Marie 55034fcce1 Merge pull request #1446 from ludovic111/fix/macos-guest-cdylib-link
🤖 Agent: merged after verify passed. No runtime change for players: #1445 deletes 10 tools/ scripts that nothing references (no harness depends on them); #1446 exports the mod-SDK symbols only on wasm32, so the guest crates link on macOS, and the mod sandbox tests pass.
2026-10-23 16:37:13 +02:00
Ludovic Marie 9a8003df0b Merge pull request #1445 from ludovic111/chore/delete-dead-tools
🤖 Agent: merged after verify passed. No runtime change for players: #1445 deletes 10 tools/ scripts that nothing references (no harness depends on them); #1446 exports the mod-SDK symbols only on wasm32, so the guest crates link on macOS, and the mod sandbox tests pass.
2026-10-23 16:35:43 +02:00
Ludovic Marie d759a89cf0 Merge pull request #1451 from ludovic111/docs/agent-map
🤖 Agent: merged after verify passed; docs only (AGENTS.md, CLAUDE.md symlink, docs/architecture.md). Every path and harness name was checked against the repo.
2026-10-23 16:31:47 +02:00
Ludovic MarieandClaude Opus 5.5 82d6d27567 Point the audio harnesses at audio_common's new place
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:39 +02:00
Ludovic MarieandClaude Opus 5.5 a6da925837 Split the client probe list from its dispatch the same way
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:39 +02:00
Ludovic MarieandClaude Opus 5.5 4a922a5772 Document how the runner tracks probe-binary dependencies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:38 +02:00
Ludovic MarieandClaude Opus 5.5 858f701ee8 Run the horizon-client parity probes from one binary
81 client examples that harnesses run become modules of examples/probe:
harnesses run `probe <name> ...` (or `cargo run --example probe -- <name>`),
and the shared helpers (audio_common, hud_fixture, chat_screen_fixture)
move with them. The benchmarks and manual tools stay standalone examples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:38 +02:00
Ludovic MarieandClaude Opus 5.5 ed847d43dc Keep the probe list out of the harnesses' dependencies
main.rs now only lists the probes; dispatch.rs runs them. The runner leaves
main.rs out of a probe-binary harness's dependencies and keeps dispatch.rs,
so adding a probe no longer reruns (or flake-checks) every harness of its
crate. It also follows crate::<probe> references between probes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:38 +02:00
Ludovic MarieandClaude Opus 5.5 ab4c246cda Delete the 11 examples nothing references
Eight benches (block_facts, chunk, entity_geometry, json_readers, load,
save, streaming, tick_chunk_membership), lan_flat, save_parity and
vanilla_diff: no harness, tool, doc or workflow names them (review plan,
section 1, dead code). -2,222 lines; Git history keeps them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:38 +02:00
Ludovic MarieandClaude Opus 5.5 7092f16a06 horizon-world probes: one copy of the helpers they shared
Now that the probes are modules of one binary, the helpers copied between
them live once in examples/probe/support: the collision probes' shape
printer (emit, values, box_values, bounds, 8 copies each), the canonical
NaN bits of f64 and f32 (50 copies), the registry JSON-to-NBT reader (15)
and the JSON block position (4). -1,097 lines of copies.

f64::NAN.to_bits() is 0x7ff8000000000000, the literal some copies used,
so every probe prints the same bytes.

tools/parity.py follows include!: three probes include another probe's
source, and stay dependent on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:38 +02:00
Ludovic MarieandClaude Opus 5.5 fdae4b895c horizon-world probes: one binary, probe <name>
The 168 horizon-world examples the parity harnesses run become modules of
one example, examples/probe, that dispatches on its first argument;
crate::args() gives each the command line its example saw. Linking ~170
binaries, each instantiating horizon-world's generics again, was most of
the parity job's probe build (review plan, section 1).

The harnesses build `--example probe` and run `probe <name> ...`.
tools/parity.py counts only the probes a harness ran, and the support
modules they name, as its dependencies, so editing one probe still reruns
only its own harnesses (and PARITY_RUST_SCOPE keeps working).

The six manual tools (benches, loot_fill, structure_blocks, vanilla_diff,
attribute_memory, behaviour_load_profile) stay separate examples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:38 +02:00
Ludovic Marie c1f8ec81d8 Merge #1444 (world_spawn shards), which touches the same harness 2026-10-23 16:30:37 +02:00
Ludovic Marie 2e953c625b Merge #1443 (parity prebuild selection), which the probe binaries rely on 2026-10-23 16:30:37 +02:00
Ludovic MarieandClaude Opus 5.5 1960dc6eb2 AGENTS.md: agents never add release-now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:31 +02:00
Ludovic MarieandClaude Opus 5.5 7c42cc63ce parity: leave out of the prebuild probes that no longer exist
A record naming an example since renamed or deleted made the whole Cargo
run fail ("no example target named ..."), so no probe was prebuilt and
every harness built its own: the probe binaries rename ~250 of them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:27 +02:00
Ludovic MarieandClaude Opus 5.5 79ee670d79 High-resolution resource packs draw the GUI at its size
With Faithful 32x the inventory showed the top-left quarter of its
background stretched over the screen, tooltips lost their background and
toasts doubled in size (#1442): the GUI code drew every atlas sprite in
texels, where vanilla draws in GUI units whatever the pack's resolution.

- Atlas sprites carry their GUI size: the size of the default resources'
  texture of that name (what vanilla's `blit(..., 256, 256)` calls and
  sprite sizes assume), their texels only for what copies pixels (font
  sheets, fire frames). Textures only a pack has keep their own size.
  The texture size filter reads the GUI size too, so Faithful 64x's
  1024x1024 container backgrounds are no longer dropped.
- Nine-slice sprites follow `GuiGraphicsExtractor.blitNineSlicedSprite`:
  their `gui.scaling` width, height and borders (Faithful's tooltip
  background is 50x50 with a 10-pixel border), `stretch_inner`, the
  single-quad and three-part cases, and borders capped at half the
  drawn size by integer division.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:28:50 +02:00
Ludovic MarieandClaude Opus 5.5 22ad65611e High-resolution resource packs no longer crash the game at launch
The shared GUI atlas was always 4096 texels wide, so Faithful 64x needed
4096x32768: past the 16384 texels a side GPUs allow, and wgpu panicked
in create_texture at every launch until the pack was removed (#1442).

- The atlas widens (4096, 8192, 16384) until it fits within 16384 a
  side, so Faithful 64x loads. Its height rounds up to a multiple of
  1024 instead of a power of two: the default resources' atlas drops
  from 4096x8192 to 4096x5120, Faithful 32x's from 4096x16384 to
  4096x12288.
- Textures that still don't fit are an error, not a GPU panic. At
  startup it rolls the packs back as Minecraft.rollbackResourcePacks
  does: the game starts with its own resources, and the selection is
  cleared and saved (clearResourcePacksOnError), so the next launch
  doesn't fail again. The existing "Resource reload failed" screen
  shows the reason.
- The renderer refuses an atlas larger than the device allows with an
  error instead of a wgpu validation panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:28:50 +02:00
Ludovic Marie 172ae241f1 Merge pull request #1430 from ludovic111/feat/release-single-channel
🤖 Agent: merged after verify and CI parity passed; release dry run 39935111815 was green (the smoke test drew a world, no blocker, it would publish as the release and latest). One channel, as Ludovic decided on 2026-10-23.
2026-10-23 16:24:35 +02:00
Ludovic MarieandClaude Opus 5.5 2eb699228a AGENTS.md and docs/architecture.md: a map of the code for agents
AGENTS.md (CLAUDE.md links to it): the crates and what each owns, how to
build and test fast, where each subsystem of horizon-client lives, how Game
is organised, the two single-player paths and how to test both, the vanilla
reference, and the rules that bite. docs/architecture.md: feature -> files ->
checks for the 40 areas changed most in the last 30 days.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:18:44 +02:00
Ludovic Marie 1487a1bff7 Merge pull request #1434 from ludovic111/perf/metal-cull-flat
🤖 Agent: merged after verify and CI parity passed. Metal GPU occlusion culling: on the M4 Pro at RD 32, FPS is +8% to +48% (interleaved A/B against main); the hidden sections match Vulkan's; parity --changed passed 104/104.
2026-10-23 16:03:14 +02:00
Ludovic MarieandClaude Opus 5.5 60bad91a50 Client zip.rs reads archives with the zip crate it already links
The 120-line hand-written central-directory and local-header parser goes:
the zip crate (already the client's dependency for world backups) indexes
the archive, zip64 and odd offsets included. Entries are still read raw and
inflated here, so as with Java's ZipFile (vanilla's FilePackResources)
their CRC isn't checked. New tests: folder entries, a nested pack root, a
Windows-style name, one broken entry among good ones, and files that aren't
zips.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:31:51 +02:00
Ludovic MarieandClaude Opus 5.5 3960c09159 tools: one copy of the helpers harnesses share, in lab.py
free_port (7 copies), offline_uuid (4), varint and parse_varint (3 and 2),
dbits (6), fbits (4) and settled (2) move into lab.py; every caller uses
lab's. The other server harnesses keep importing free_port from
test_dedicated_server_parity, which now takes lab's. Imports the moved
code left unused go too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:20:06 +02:00
Ludovic MarieandClaude Opus 5.5 b15e5e893e Code mods are the client's mods feature: tests and probes skip wasmtime
horizon-mods' runtime is no longer a default feature, and its sandbox test
requires runtime and pack instead of a dev-dependency on itself (that
dev-dependency turned the runtime on in every workspace build with examples,
the parity probe build included). The client gets a mods feature that turns
the runtime on; without it game/mods.rs keeps content mods and logs why code
mods don't start, and every hook does nothing.

Release builds (client and server) pass the feature, CI's clippy and tests
pass it (and pack, for the sandbox test), and pr_ready does where horizon-mods
is checked. horizon_mods::find moves to manifest.rs: content mods use it
without the runtime.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:18:01 +02:00
Ludovic MarieandClaude Opus 5.5 5ca16b97fc Keep version.rs as tools/gen_version_consts.py writes it
The file is generated from the pinned version lock, and test_gen_version_consts
checks JAVA_VERSION against it; deleting constants by hand breaks the next
regeneration and that test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:07:38 +02:00
Ludovic MarieandClaude Opus 5.5 eeb4cdb680 Mod SDK: export_mod! exports only in WebAssembly, so the guests link on macOS
A host build of a mod (cargo build or test on the workspace) made a cdylib
whose component exports macOS's ld couldn't take: its exported-symbols list
cuts `horizon:mods/[email protected]#init` at the `#`, so coords-hud and
horizon-mods-test-guest failed to link. The exports now exist only for
wasm32; on the host the macro still checks the mod and keeps it used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:06:50 +02:00
Ludovic Marie 2439475716 Merge pull request #1414 from kerneldroid/feat/shader-api2-pipeline
🤖 Agent: merged after the contribution gate. The claims were checked against Iris and OptiFine, and the weak parts were rewritten on the branch: the stage kinds, the Java shadow feed and the fake uniforms were removed; settings now apply on Done; packs are imported by drag and drop; memory is only allocated while a pack reads it; the MakeUp target is fixed. Tested on Vulkan and Metal with 6 packs, profiles, imports and broken packs; local parity passed 96/96. Frame times before and after are in the PR comment. Thanks @kerneldroid.
2026-10-23 15:05:34 +02:00
Ludovic Marie 1959c397a0 Merge pull request #1433 from ludovic111/chore/unify-naga-features
🤖 Agent: merged after verify and CI parity passed (review-0-survey quick win; see horizon-parity reports/review-plan.md).
2026-10-23 14:44:50 +02:00
Ludovic MarieandClaude Opus 5.5 7cc47e2b55 Delete ten tools/ scripts nothing references
command_parse_summary, structure_start_oracle, fetch_client_assets,
compare_structure_starts, profile_behaviour_cache, command_tree_diff,
compare_loot_fill, compare_structure_blocks, test_lan_lifecycle and
PROFILE_SHARED_JSON.md: no workflow, doc, harness, test or other tool
names them (review-plan.md, Dead code). History keeps them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:41:40 +02:00
Ludovic MarieandClaude Opus 5.5 c9c53629d4 parity: run world_spawn in four shards
world_spawn starts a vanilla and a Horizon server for each of 60 seeds,
one at a time: 1,144-1,548 s on the CI runner, the parity job's longest
harness and its floor whenever a PR's changes reach it. With
PARITY_SHARDS = 4 each shard creates every fourth seed's worlds, in its
own build directory.

The shard helper datafix had moves to tools/parity_shard.py, which both
use. It stays out of lab.py, which every harness imports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:36:58 +02:00
Ludovic Marie 6fe88978a7 Merge origin/main into feat/shader-api2-pipeline 2026-10-23 14:17:13 +02:00
Ludovic MarieandClaude Opus 5.5 d9591047fa Shader pack docs: MakeUp, Chocapic13' and Photon on the Java path, and where the world goes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:17:13 +02:00
Ludovic MarieandClaude Opus 5.5 fe3a3af568 Shader Packs page: the pack's settings button sits beside the folder button
As in Iris's ShaderPackScreen: at the end of the list it scrolled out
of sight with a few packs. It's greyed out while the picked pack has no
options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 ac54227465 parity: prebuild probes with each harness's own package selection
The probe prebuild ran `cargo build --workspace --examples --bins` (or
named targets with --workspace). The harnesses build with `-p <crate>`, and
Cargo unifies features over the selected packages: under --workspace,
flate2, libc, memchr, syn, thiserror, postcard and digest get other
features for horizon-world, and horizon-mods (pack) and horizon-telemetry
(upload) for horizon-client. So every probe the prebuild made was a
variant no harness used, and each harness rebuilt its crate and its probe
again while the others waited on Cargo's lock.

Record each probe with its command's package and feature flags and
whether that command builds examples (which turns dev-dependencies on),
and prebuild one Cargo run per such selection. --bins and --examples are
recorded too, so the fast-profile horizon-client build that
save_roundtrip and world_reopen run is prebuilt as well. A harness that
never ran here takes its probes from the shared dependency lists instead
of a build of all ~275 examples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:11:22 +02:00
Ludovic MarieandClaude Opus 5.5 4a61134edd CI verify: test without building the examples
cargo test --workspace built and linked all ~275 examples, 59% of the
test build (review-plan section 1), though none of their tests run.
Test the libraries, binaries and integration tests, then the doc tests,
as tools/gate.py and tools/pr_ready.py already do. Clippy --all-targets
still type-checks every example, and the parity job builds and runs them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:06:25 +02:00
Ludovic MarieandClaude Opus 5.5 39ae543796 Shader packs: test importing zips and folders, and refusing what isn't a pack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 963d5cedd3 Shader packs: keep the opaque depth and G-buffer only for packs that read them
The world target made the opaque depth, normal and material textures
for every scaled or anti-aliased frame, packs or not: 12 bytes a pixel
nobody read. They now follow what the pack reads, and Post::generation
tells the stages to bind again when its targets change (a recreated
target of the same size, or a shadow map made after the stages bound).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 fe7bb3a34b Java packs: the world goes where the pack's terrain program draws
MakeUp's gbuffers write colortex1, and its composites read the world
from there: with the world in colortex0, it showed nothing but fog.
Iris's fallbacks for terrain pick the program (gbuffers_terrain, then
textured_lit, textured, basic); its first draw buffer takes the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 eafe97da74 Shader packs: keep what Iris does, drop what doesn't work, apply settings on Done
Review fixes for #1414 (contribution gate):

- Java packs no longer get Horizon's shadow map: it was mirrored in y and
  reversed in depth against the GL conventions the packs sample with, had
  no distortion and only the camera's sections, so BSL drew no shadows
  while the pass cost 0.65 ms (MakeUp, M4 Pro). shadowtex* stays a far
  depth, as on main; the packs' own shadow programs are #1326.
- Invented uniforms removed (centerDepthSmooth fixed at 0.5,
  ambientOcclusionLevel and shadowMapResolution as uniforms, constant
  temp/rainfall/biome/held items); an unknown uniform stays zero as
  before. wetness keeps its Iris smoothing. Unused directives removed.
- texture.<stage>.<sampler> is scoped to Iris's stages (final reads the
  composite ones) instead of overriding the sampler in every pass;
  texture.noise replaces noisetex. One noise generator and one RGBA
  upload for both pack kinds.
- The G-buffer pass, the opaque-depth copy and the shadow pass run only
  for a pack that reads them (naga's per-entry-point binding use, not a
  text search): every pack paid all three before.
- Api 2's prelude additions go in their own file after api 1's, so api 1
  packs keep the names they declare (aces_tonemap, material_at...).
- Stage kinds shadow, gbuffer, deferred and final ran as composite: only
  composite is accepted; api 2 adds formats (validated), profiles,
  option descriptions and a noise texture.
- Shader Pack Settings applies once when the page closes, as Iris's Done
  (each click or slider step rebuilt the pack: 2.7 s per click, 12.7 s
  for one drag with BSL). Labels follow Iris (.lang, else the name).
  A pack whose options can't be read isn't read again every frame.
- Saved option values an option doesn't list are ignored, as OptiFine's
  ShaderOption.setValue (a stale file broke BSL's compile).
- Import: the blocking file dialog (osascript/zenity/kdialog/PowerShell
  on the event loop) is gone; a pack dropped on the Shader Packs page is
  copied and picked, as Iris's onFilesDrop. Drops elsewhere do nothing,
  an existing pack isn't overwritten, links aren't followed.
- Docs and the example say what the code does (SSAO, not GTAO).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic Marie d95932e62c Merge origin/main into feat/shader-api2-pipeline 2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 660407fa58 Metal: cull only frames with 4,096 section draws or more
On Apple's GPUs the passes the test adds cost a fixed share of the frame
(it is stored and reloaded at each), which the hidden sections repay only
at high render distances: at 12 culling cost an M4 Pro 0.05 ms of GPU time
a frame, at 32 it saved 0.25 to 1.6 ms. Other backends keep culling every
frame; HORIZON_GPU_CULL=all culls every frame on Metal too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:22:10 +02:00
Ludovic Marie 6be7daebc8 Merge pull request #1417 from ludovic111/fix/st2-rules
🤖 Agent: merged after verify and CI parity passed; lan_e2e (hands, styles, feedback, chat, frames, riding, mounts, beds, contacts, worldspawn) passes on the server thread and in-frame with all four merged on main (bugs-st-2).
2026-10-23 13:20:42 +02:00
Ludovic Marie 2639e9e1d7 Merge pull request #1422 from ludovic111/fix/st-leads
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 13:20:40 +02:00
Ludovic MarieandClaude Opus 5.5 1d81c67b7b Build naga once for the game, its tests and its examples
horizon-client's dev-dependency on naga added the hlsl-out feature for the
gfx.rs test that translates every shader for DirectX 12. With resolver 2 a
dev-dependency's features apply only to tests and examples, so Cargo kept
two builds of naga, wgpu-hal, wgpu-core, wgpu, horizon-shaderpack and the
horizon-client lib, and rebuilt them on the first example or test after a
game build.

Measured on the Mac mini (cargo build of the game, then one example):
the example took 37.3 s and recompiled that whole stack before, 1.2 s
and only its own crate after; the target dir is 1 GB smaller (4.9 to
3.9 GB). The game binary is the same size: nothing calls the HLSL writer,
so the linker drops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:13:58 +02:00
Ludovic Marie 9c09d2897d Merge pull request #1428 from ludovic111/fix/alpha-regress-1
🤖 Agent: merged after verify and CI parity passed. In-frame single player is the default again until the server thread reaches parity (FEATURES 4zg); release builds were compared with v0.2.0 and v0.2.11 on the Mac mini. The server thread stays available with --server-thread or HORIZON_SERVER_THREAD=1.
2026-10-23 12:11:36 +02:00
Ludovic MarieandClaude Opus 5.5 173a55e608 Delete pub items nothing uses, and the stubs that hid unused code
93 pub items (78 functions and methods, 14 constants, 1 struct) in horizon-client,
horizon-world, horizon-core, horizon-friends and horizon-server-files had
no caller anywhere in the repository: not in the crate, the other crates,
the examples, the tests or the binaries. rustc can't flag them because
horizon-client and the others are libraries, where a pub item counts as
used. The imports and the private helpers only they used go with them
(title.rs date_string, goals.rs flag_index).

Also drops the #[allow(dead_code)]/#[allow(unused)] items that only hid
dead code: chat_debug_fill, merchant_title and Goals::locked_by had no
caller, and four stubs (_keep, _unused, _rule_kind, const _) existed only
to keep an import used. LightTables::from_parts is used by its tests, so
it is #[cfg(test)] instead of allowed.

Protocol id tables and entity data serializer ids stay: they mirror
vanilla's registries in full. The launcher's three unused items are left
for a launcher PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:08:44 +02:00
Ludovic MarieandClaude Opus 5.5 14be6ca394 Metal: occlusion culling with flat draws too
Flat draws (one index list per frame, the default on Apple GPUs at high
render distances) turned occlusion culling off. Now phase 2's draws are
flattened after the test, by a compute pass that copies only the sections
the test saw, and each page run is drawn indirectly with the count it
reached.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:03:39 +02:00
Ludovic MarieandClaude Opus 5.5 9fcf91e4bc Metal: occlusion culling of solid sections, the depth test in the shader
GPU occlusion culling ran on Vulkan only: its test needs early fragment
tests, which naga's Metal backend does not emit. On Metal the boxes are
tested in a pass of their own: phase 1's depth is bound read-only as the
attachment and as a texture, and the fragment shader compares its own
depth with it (reversed Z, greater or equal), as the fixed-function test
would. HORIZON_GPU_CULL=shader takes this path on Vulkan too, for checks.

Frames drawn flat (the default on Apple GPUs at high render distances)
still skip culling here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:03:38 +02:00
Ludovic MarieandClaude Opus 5.5 630f521072 Launcher: one update channel, and the menu entry is never taken over
Every release is now published as the release (one channel, Ludovic's
decision of 2026-10-23), so the launcher drops its Stable/Alpha choice:
the Updates row in Settings, `horizon-launcher update-channel` and its MCP
tool, the channel on the Versions page, in `versions` and in the footer.
It updates to the newest game release that is not a pre-release (GitHub's
latest) and looks every 30 minutes. The pre-releases of the old Alpha
channel stay listed and installable by hand, never as an update. Old
`update_channel` / `beta_builds` keys in settings.json are ignored.

On Linux a release build that starts no longer rewrites the
applications-menu entry to point at itself. It writes the entry only when
there is none, or when the program it starts (TryExec, else Exec) no longer
exists; turning the Settings switch on or `desktop-entry` still points it
at this launcher, and HORIZON_LAUNCHER_DESKTOP_EXEC is still honored. A
test build started with the real HOME took over a working entry and then
left it broken when its target/ was deleted (2026-10-23).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:53:00 +02:00
Ludovic MarieandClaude Opus 5.5 0e65e85878 Releases: one channel, every release is the latest once its checks pass
Ludovic's decision (2026-10-23): no more Alpha and Stable. release.yml
publishes every game release as the release and GitHub's latest instead of
a pre-release that promote.yml made stable once a day. The checks that
gated a promotion run in the release itself: `decide` holds the train while
an issue labelled release-blocker is open (forced runs too), and the publish
job runs `release_train.py gate` right before publishing: the Mac mini's
smoke-test.json must be there and show a world was drawn, and no blocker may
be open. Otherwise nothing is published.

Notes cover everything since the last release that is not a pre-release, so
the first single-channel release lists all of it since v0.2.0. promote.yml,
tools/release_channels.py and its tests are deleted. The cadence is
unchanged (5 minutes of settling, 20 at most, release-now at once).

Docs and site: one channel in release-flow, versioning, security, the FAQ,
install, the home page and the Releases page (Game / Launcher filter); no
player-facing Alpha wording left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:53:00 +02:00
Ludovic Marie 62a6b3a620 Merge pull request #1421 from ludovic111/fix/st-entity-data
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 11:12:06 +02:00
Ludovic MarieandClaude Opus 5.5 6049012b52 Single player runs in the frame by default again; the server thread is opt-in
Players on the Alpha channel report single player as broken since v0.2.1
(#1265 made the integrated server thread the default again). On v0.2.11's
server thread, placed signs open no editor (#1380), held books don't open
(#1366), a totem doesn't save its holder (#1292) and death keeps the
inventory with keep_inventory off (#1284); the in-frame world and Stable
v0.2.0 do all four right. About 45 more [server-thread] issues are open.

`--server-thread` or HORIZON_SERVER_THREAD=1 turns it on, as before #1114;
`--no-server-thread` and HORIZON_SERVER_THREAD=0 still work. LAN guests and
dedicated servers are untouched. Temporary until the server thread reaches
parity (FEATURES 4zg).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:54:42 +02:00
Ludovic MarieandClaude Opus 5.5 4b8fecfe58 Particles from a server: /particle and explosions reach its players
A server never sent particles: /particle on single player's server thread
drew into the server's own engine, and TNT exploded without its burst for
anyone but an old-mode host. The protocol gains
ClientboundLevelParticlesPacket and ClientboundExplodePacket; /particle
sends the particles to each viewer in range (every player without
viewers, as PlayerList.getPlayers) and an explosion sends its packet to
the players within 64 blocks; their clients draw them as
handleParticleEvent and handleExplosion do.

/particle's spread was measured from the command's source as if it were a
position; it is the spread as given (Vec3Argument.getVec3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 b7f4cba355 A server's scoreboard reaches its players: sidebar, teams, scores
The server never sent its scoreboard, so a sidebar shown on single
player's server thread or to a LAN guest stayed empty. Each tick a placed
player is now sent what changed since it was last sent the board, as
ServerScoreboard's callbacks do: every team, and the objectives in a
display slot with their slots and scores (all of it on joining).

A server's client also finds its team's sidebar by its profile name rather
than single player's "Player".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 13772b4e5b Level events on a server's clients: spawner bursts, bone meal sparks, waxing
A client of a server (single player on its server thread, a LAN guest, a
dedicated server's player) played only three of the level events the server
sent (a block broken, the dragon egg's and a consumable's teleport): a
spawner's burst, waxing and scraping copper, a composter filling and the rest
showed nothing. Every one now goes through the handler the in-frame game uses
(LevelEventHandler).

Bone meal's level event (1505) played its sound but never its growth
particles, in either mode: it now spawns them as BoneMealItem.
addGrowthParticles does (spread over a grass block's, nylium's, netherrack's
or moss's neighbourhood and water, inside a growing block, below rooted dirt
and mangrove leaves). The server also sends it to the player who used the
bone meal, as vanilla's levelEvent names no player.

Waxing copper with honeycomb and an axe or hoe's transforms (wax off, scrape,
strip, till) make their particles and sound on the clicker's own client, as
vanilla's client runs Item.useOn; the server sends them to the others only.
The transform's preview is shared with the in-frame use.

`horizon-ctl status` counts the particles made by type. lan_e2e's new
`sparks` check: the host and the guest each use bone meal on wheat, wax
copper and take the wax off, and a spawner spawns a zombie; both sides make
the particles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 c5fe5d4bcf Thrown potions and eyes of ender fly on a server's clients
A splash or lingering potion, an experience bottle or an eye of ender a
player threw was never an entity a server tracked: on single player's server
thread, for LAN guests and on a dedicated server the item was used up and
nothing flew.

The server now numbers them like its other projectiles and tracks them as
vanilla's entities: splash_potion, lingering_potion and experience_bottle
with ThrowableItemProjectile.DATA_ITEM_STACK (the potion, for its colour),
and eye_of_ender. A client draws them where the server puts them and drops
them when the server removes them.

lan_e2e's new `thrown` check: the host and the guest each throw a splash
potion and an eye of ender, and both sides see them fly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 0d8249e124 Leads: a server's clients draw the rope (ClientboundSetEntityLinkPacket)
A server never told its clients who holds a mob's lead, so on single
player's server thread, a LAN guest's or a dedicated server's client the
lead was used up and the mob tied, but no rope showed.

The tracker now sends ClientboundSetEntityLinkPacket as vanilla does: in
ServerEntity.sendPairingData for a leashed entity, and when the lead is tied
or comes off (Leashable.setLeashedTo / dropLeash, the holder 0). The holder
is the player the mob sees (a guest, else the host) or a mob; a fence knot
isn't an entity on Horizon's server yet. The client sets the mob's holder
from it: itself, another player or a mob.

lan_e2e's new `leads` check ties and unties a cow on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 c35ea9568f Server's mobs: babies, wool, collars, fire, slime sizes and health reach its clients
A client of a server (single player on its server thread, a LAN guest, a
dedicated server's player) only applied a rabbit's baby flag and a creeper's
swell from SetEntityData. It now reads the synched data vanilla's entities
read in onSyncedDataUpdated: Entity's on-fire flag, LivingEntity's health
(the wither's boss bar follows it), the baby flag of every ageable mob,
Sheep.DATA_WOOL_ID, TamableAnimal's sitting and tame flags, the wolf's and
the cat's collar colour, and the cube mobs' size. A remote wolf or cat starts
with vanilla's red collar.

The server sends the wolf's and cat's collar colour. Pairing still carries
only the values that differ from the defaults; a mob's state now lists the
defaults of the entries that can go back to theirs (health, the baby flag,
the wool, the collar), and the tracker sends that default when one does:
a calf growing up or a sheep's white wool growing back reach the clients.

Sheep saves wrote the colour and shearing they were loaded with (0 unless
the save had them) and loads ignored both: they now save and load the wool
as it is (Sheep.add/readAdditionalSaveData).

lan_e2e's new `looks` check summons the mobs and changes them on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 4aa1f8e14a World border on a server's clients: its state, its walls, its damage
The server told joining players of a default border and never of a
change, so on single player's server thread and for LAN guests the wall
neither showed nor stopped anyone. Players now get the real border on
entering a level and each change as BorderChangeListener sends it.

The player's movement collides with the border's walls
(CollisionGetter.borderCollision) instead of being clamped after moving,
which a sprint-jump got past. A guest (and single player's own player on
its server thread) outside the border takes its damage on the server, as
the host's player did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 f88fba8d05 A server's attribute updates reach its player; new boss bars start visible
The client of a server (single player on its server thread, LAN guests)
ignored ClientboundUpdateAttributesPacket for its own player, so a new
maximum health never reached it: its hearts stayed at ten and the health it
was sent was clamped to 20. It now applies each synced instance's base and
modifiers (handleUpdateAttributes) and re-applies the received health.

BossBar::new started hidden; ServerBossEvent starts visible.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic Marie e325fa6587 Merge pull request #1397 from ludovic111/fix/metal-gpu-timer
🤖 Agent: merged after verify and CI parity passed; the change only touches the GPU timer (gfx/timing.rs), proven on the M4 at RD 32.
2026-10-23 09:57:36 +02:00
Ludovic Marie 63baec3ac8 Merge branch 'fix/st2-entity-hands' into fix/st2-chat
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 09:45:17 +02:00
Ludovic Marie 0b7fa14030 Merge remote-tracking branch 'origin/main' into fix/st2-entity-hands
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 09:44:52 +02:00
Ludovic Marie 2423e9da00 Merge remote-tracking branch 'origin/main' into fix/st2-saved-state
# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
2026-10-23 09:44:30 +02:00
Ludovic Marie cb81f645da Merge remote-tracking branch 'origin/main' into fix/st2-rules
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-23 09:44:12 +02:00
Ludovic MarieandClaude Opus 5.5 7e5e469783 InLove only for vanilla's Animal subclasses (not allays)
save_roundtrip found an allay saved with InLove 0: Horizon's `animal`
flag is broader than vanilla's Animal class.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 09:19:48 +02:00
Ludovic Marie 4680a73f51 Merge pull request #1413 from ludovic111/feat/bench-1080p
🤖 Agent: merged after verify passed; #1190 has interleaved GPU A/B against main on the HD 620 and M4 (within noise) and 103 parity harnesses passing; #1413 was proven with a 1080p run on the desktop.
2026-10-23 09:06:57 +02:00
Ludovic Marie e0cb831dbf Merge pull request #1190 from ludovic111/fix/texture-mcmeta-blur-clamp
🤖 Agent: merged after verify passed; #1190 has interleaved GPU A/B against main on the HD 620 and M4 (within noise) and 103 parity harnesses passing; #1413 was proven with a 1080p run on the desktop.
2026-10-23 09:05:27 +02:00
Ludovic Marie 6b35c35ebf Merge pull request #1416 from ludovic111/fix/bed-respawn
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 09:00:44 +02:00
Ludovic Marie 48863fcf84 Merge pull request #1412 from ludovic111/fix/mob-knockback-view
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:59:16 +02:00
Ludovic Marie 982f7f12be Merge pull request #1411 from ludovic111/fix/items-out-of-blocks
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:58:50 +02:00
Ludovic Marie 12f2ed9ff7 Merge pull request #1410 from ludovic111/fix/setworldspawn
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:57:20 +02:00
Ludovic Marie 10db017c7c Merge pull request #1409 from ludovic111/fix/server-player-shield-mining
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:54:51 +02:00
Ludovic Marie e6acebc23d Merge pull request #1408 from ludovic111/fix/contact-damage
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:53:24 +02:00
Ludovic Marie 69df5671e7 Merge pull request #1407 from ludovic111/fix/server-air-absorption
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:48:53 +02:00
Ludovic MarieandClaude Opus 5.5 06b4c7faf5 Rules: crits, mending, phantoms, per-level health rules, /item with air, frozen potions
- Critical hits also need the attacker out of water, not blind and not
  riding (Player.canCriticalAttack) (#1393).
- Mending repairs the off hand too: candidates in EquipmentSlot order,
  main hand, off hand, then the armour (#1349).
- Phantoms spawn only in the Overworld with spawn_mobs, spawn_monsters and
  spawn_phantoms on, near every player (the local one and each guest in
  the level, with their own time since rest), with vanilla's draw order
  (#1388, #1387).
- A level entered by a portal or a dimension change keeps the world's
  natural_health_regeneration (#1339).
- /item replace ... with air empties the slot, and counts it as changed
  as vanilla's SlotAccess.set does (#1332).
- Thrown potions stay where they are on frozen ticks (/tick freeze) and
  land once the game runs again (#1364).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:48:41 +02:00
Ludovic Marie 5d2f1804e7 Merge branch 'fix/st2-entity-hands' into fix/st2-chat
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 08:39:57 +02:00
Ludovic MarieandClaude Opus 5.5 c7abc8c718 Chat: styled server components, click and hover events, legacy codes, message order
- A connection's chat keeps the server's component: SystemChat goes
  through the same translation and styles as the action bar, and player
  and disguised chat are decorated from their chat type's translation,
  parameters and style (ChatType.Bound.decorate), so colours, bold,
  hex colours, and the client's language are kept (#1372, #1389).
- Components parse `click_event` (suggest_command, copy_to_clipboard; never
  open_file from a server) and `hover_event` show_text (#1377), and the
  chat screen draws the hovered part's tooltip (#1381).
- `§` codes in chat colour the text with Chat Colors on and are stripped
  with it off (ComponentRenderUtils.wrapComponents) (#1379).
- A command's operator broadcast skips the player whose command it was on
  the integrated server too (#1373), and /give fires InventoryChangeTrigger
  before its message, whose announcement is sent first (#1375).
- The control socket's `messages` gives the chat lines as components;
  lan_e2e `styles` checks a styled tellraw, the advancement order and the
  missing echo for the host and the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:39:46 +02:00
Ludovic MarieandClaude Opus 5.5 337d7d7a7a Entity use with either hand: bows and food past a mob, off-hand items on mobs, emptied frames
- The remote client sends Interact for a targeted mob only for a hand
  vanilla's client-side Mob.interact would accept with it (leads, name
  tags, food, buckets, shears, saddled mounts, tamed pets, ...), so a bow
  drawn or food eaten while aiming at a cow goes on to useItem (#1350).
- Both hands: the main hand's item, then the off hand's entity, block and
  item (Minecraft.startUseItem). The server runs Interact with the packet's
  hand (GuestUse::Entity), and mobs, armour stands, leads and mounts use
  the held hand's stack, so an off-hand bucket milks a cow and off-hand
  wheat feeds it (#1362).
- The entity tracker sends an item frame's item and rotation when they go
  back to empty and 0, so a punched frame shows empty on clients (#1355).
- lan_e2e `hands`: the bow, the golden apple, the off-hand bucket and wheat
  and the emptied frame, for the host and the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:39:45 +02:00
Ludovic MarieandClaude Opus 5.5 d522d8fe50 Saved state: burning, air and falling survive a reopen; InLove for every animal
- Guests' player data saves Fire, Air and fall_distance and puts them back
  on their body when they come back (single player's players are guests
  of the integrated server), so a burning player still burns after a
  reopen (#1323); the old single player's level.dat keeps fall_distance
  too (#1327).
- Every Animal saves and loads InLove, so a fed animal stays in love
  across a reopen and is not fed again (#1396).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:39:29 +02:00
Ludovic Marie 55e7084890 Merge branch 'fix/mob-knockback-view' into fix/bed-respawn 2026-10-23 08:36:16 +02:00
Ludovic Marie cffa98d509 Merge branch 'fix/items-out-of-blocks' into fix/mob-knockback-view 2026-10-23 08:36:16 +02:00
Ludovic Marie 9df27670f2 Merge branch 'fix/setworldspawn' into fix/items-out-of-blocks 2026-10-23 08:36:16 +02:00
Ludovic Marie 83c6d78013 Merge branch 'fix/server-player-shield-mining' into fix/setworldspawn 2026-10-23 08:36:16 +02:00
Ludovic Marie 83db7e3c41 Merge branch 'fix/contact-damage' into fix/server-player-shield-mining 2026-10-23 08:36:16 +02:00
Ludovic MarieandClaude Opus 5.5 39438efd76 lan_e2e contacts: sneak before landing on magma, read health before letting go
The sneaking case read the health after releasing Shift and waiting 10
ticks on the magma, so a hit from standing up counted (and one could land
between the first reading and the key press).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:36:16 +02:00
Ludovic MarieandClaude Opus 5.5 0e36cb4d7a Beds: respawning checks the bed and stands up beside it, else the world spawn
A bed sets the respawn point as vanilla's ServerPlayer.startSleepInBed does
(once in reach, day or night): a RespawnConfig at the bed's head with the
player's yaw, not forced; straw beds never set it (straw_bed_rule).
Respawning resolves it through findRespawnAndUseSpawnBlock, which now
handles beds: AbstractBedBlock.findStandUpPosition (beside the bed on the
side away from the yaw it was set from, around it, then on it; bunk beds
too) through DismountHelper, facing the bed. A bed that is gone or boxed
in sends the player to the world spawn with "You have no home bed or
charged respawn anchor, or it was obstructed", and the point is forgotten:
in the window's world, on the server thread and for LAN guests. Leaving
the End goes to the bed too (not using it up).

Saves keep a bed as the player's `respawn` (resolved through its block
when loaded), and a `spawnpoint` in the Overworld as forced. A fallback
that is the world spawn follows `setworldspawn` with a bed or anchor set.

tools/test_use_item_on_parity.py: respawn cases at a bed's head set from
some yaw (intact, bunk, only the foot left or gone, crowded), against
vanilla's real findRespawnAndUseSpawnBlock; 4000/4000 and 12000/12000 on
two seeds. lan_e2e `beds`: intact, gone and boxed in, host and guest.

Fixes #1308

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:11:22 +02:00
kerneldroid 4dff61b697 feat(shaders): complete wgpu Iris/Horizon API-2 shader pipeline, shadow & G-buffer passes, GLSL translator fixes, and settings/import UI 2026-10-23 11:07:03 +05:00
Ludovic Marie 13f98233ea Merge remote-tracking branch 'origin/main' into fix/texture-mcmeta-blur-clamp
# Conflicts:
#	crates/horizon-client/src/hud.rs
2026-10-23 07:59:50 +02:00
Ludovic MarieandClaude Opus 5.5 5c9f11b954 Benchmark: a 1080p set next to 720p
perf_bench ran every game in a 1280x720 window only. It now runs the
standard set at 1280x720 and 1920x1080 (--sizes picks others), each size
its own reports: <label>-rd<N>.json at 720p as before, <label>-1080p-rd<N>.json
at 1080p.

Horizon takes vanilla's --width and --height for its window's starting size
(1280x720 by default); perf_bench passes them to every game, and to Horizon
only off 720p, so builds before this one still run the 720p set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:58:53 +02:00
Ludovic Marie c8f00dcf1e Merge branch 'fix/items-out-of-blocks' into fix/mob-knockback-view 2026-10-23 07:52:34 +02:00
Ludovic Marie 3d36883f52 Merge branch 'fix/setworldspawn' into fix/items-out-of-blocks
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:52:33 +02:00
Ludovic Marie e84f86a813 Merge branch 'fix/server-player-shield-mining' into fix/setworldspawn
# Conflicts:
#	crates/horizon-client/src/lan/playerdata.rs
#	tools/lan_e2e.py
2026-10-23 07:51:25 +02:00
Ludovic Marie 48c1b04cb0 Merge branch 'fix/contact-damage' into fix/server-player-shield-mining
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:50:58 +02:00
Ludovic Marie 10f6b183e3 Merge branch 'fix/server-air-absorption' into fix/contact-damage
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:50:48 +02:00
Ludovic Marie f949f08488 Merge remote-tracking branch 'origin/main' into fix/server-air-absorption
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:45:28 +02:00
Ludovic MarieandClaude Opus 5.5 144741b7c7 Screen overlays: one texture sample, as on main
The vignette, drawn over every frame, went from one texture sample to up to
four texel loads and per-pixel address arithmetic: +0.85 ms of GPU a frame on
an HD 620 (integer modulo, removed in the commit before), still +0.4 ms after
it.

- A blurred sprite gets a one-texel border in the atlas, copied from its
  opposite edges: the hardware's bilinear sample of the atlas with the
  entity group's linear sampler is then GL_LINEAR with repeat, edges
  included. Clamp-to-edge keeps the sample point half a texel inside the
  sprite, which is what the hardware's clamp does for both filters.
- Each sampling has its own pipeline, the shader specialised by override
  constants: a repeat sprite runs main's arithmetic and one sample, nearest
  or linear; only clamp adds a clamp. The sampling moves from the vertices
  to the quad, so the vertex layout is main's again.
- A black vignette changes no pixel (it darkens by texture x colour), and is
  no longer drawn: in daylight its brightness fades to 0 within half a
  minute, so most frames outdoors skip it, which main never could.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:35:26 +02:00
Ludovic Marie 5bb492ca54 Merge pull request #1318 from levzzz5154/fix/tnt-minecart-explosions
🤖 Agent: merged after verify passed and a proof run on the Mac mini: the self-check passed (parity 22/22), and six TNT minecarts now chain (6 → 0; 5 survive on main). Thanks @levzzz5154!
2026-10-23 06:56:31 +02:00
Ludovic Marie 15616aa115 Merge pull request #1317 from levzzz5154/fix/minecart-collisions
🤖 Agent: merged after verify passed and a proof run on the Mac mini: the self-check passed (parity 22/22), and lan_e2e minecarts passes in both single-player modes. Thanks @levzzz5154!
2026-10-23 06:56:07 +02:00
Ludovic Marie 910f59bf0e Merge pull request #1213 from ludovic111/fix/server-respawn-levels
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:27:48 +02:00
Ludovic Marie 247a33d889 Merge pull request #1212 from ludovic111/fix/server-end-gateways
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:26:20 +02:00
Ludovic Marie a1597c041f Merge pull request #1211 from ludovic111/fix/server-command-levels
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:24:51 +02:00
Ludovic Marie 1a4e783a04 Merge pull request #1210 from ludovic111/feat/lan-levels-check
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:23:22 +02:00
Ludovic Marie 91466d160c Merge pull request #1216 from ludovic111/feat/truetype-fonts
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:21:55 +02:00
Ludovic Marie ce04baf1ba Merge pull request #1215 from ludovic111/feat/win-screen
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:20:28 +02:00
Ludovic Marie b75370cf26 Merge remote-tracking branch 'origin/main' into m1215
# Conflicts:
#	crates/horizon-client/src/game/startup_menu.rs
#	crates/horizon-client/src/options_ui.rs
2026-10-23 06:11:37 +02:00
Ludovic MarieandClaude Opus 5.5 feeefb3a33 Mobs: a punch shows smooth and slow, as on vanilla's client
The server sends a knocked-back, pushed or jumping mob's move at once
(Entity.needsSync), and the remote client walks every living entity along
the server's positions with vanilla's SteppedInterpolationHandler: each
position takes the type's update interval to reach, sped up while steps
pile up. Before, the client waited for the next due update and then
lerped there in fewer ticks, so a hit pig jumped away fast.

tools/test_stepped_interpolation_parity.py drives vanilla's unmodified
InterpolationData and the Rust port with the same packets and ticks
(random cases and a punched pig's knockback); 135236 positions and
rotations match bit for bit.

Fixes #1374

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:46 +02:00
Ludovic MarieandClaude Opus 5.5 65bd2d0d45 Items push out of blocks; a server's client digs at the server's speed
ItemEntity.tick: an item stuck inside a full block (one set on it) has no
physics on the server and moves toward the closest free side
(Entity.moveTowardsClosestSpace: north, south, west, east, up; 0.1 to 0.3 a
tick), instead of staying in the block (#1252).

A server's client (LAN guests, and the window on the server thread) digs at
the same speed as the server: getDestroyProgress with the submerged mining
speed, haste, fatigue and efficiency, not the plain tool speed, so it no
longer finishes early under water and gets the block put back (#1254).

lan_e2e: `drops` (a diamond under a block set on it comes out, in both
views) and `underwater` (dirt by hand at eye height under water: about
halfway at 35 ticks on client and server, broken by 105).

Fixes #1252, #1254.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:25 +02:00
Ludovic MarieandClaude Opus 5.5 5c4600e403 /setworldspawn moves the world spawn, not the player's respawn point
SetWorldSpawnCommand sets the level's spawn and its angle; a player without
a bed or anchor respawns there (it used to set the player's own spawn
point, so other players and later deaths kept the old one). The angle is
saved in level.dat's spawn (yaw, pitch), and a guest whose saved respawn
point is the world spawn follows it when it moves.

lan_e2e `worldspawn`: /setworldspawn 24 blocks east, then the host and the
guest die and respawn there.

Fixes #1310.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:25 +02:00
Ludovic MarieandClaude Opus 5.5 2898b40670 Server players: an axe-disabled shield stays down 5 s, death forgets the digging
ItemCooldowns ticks once a tick for each player: a server's players count
their shield cooldown down in tick_guests, and the host's world in tick_world
(it was in tick_using, which ran for the host and again for every guest's
swap, so a guest's shield came back early on the server thread).

A guest that dies stops digging (its mining, the delay and the crack go), so
the block it was breaking doesn't break once it respawns.

lan_e2e: `shield` (a vindicator's axe disables each player's shield; the
server's cooldown then falls 40 in 40 ticks) and `deathmining` (die while
breaking obsidian, respawn, the obsidian stays). `lan status` and the agent
state report the shield cooldown.

Fixes #1315, #1316.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 ed28e1dd0d Contact damage: magma, cactus, lit campfires and sweet berry bushes hurt in both modes
Entity.applyEffectsFromBlocks: MagmaBlock.stepOn burns a player standing on
it who is not sneaking (hot_floor, 1), CactusBlock and CampfireBlock.entityInside
hurt what touches them (cactus 1, campfire 1, soul campfire 2, lit only).
A server's players (LAN guests, and the host on the server thread) now get
these and the grown sweet berry bush prick along the moves their client sent
that tick: the server rebuilds the tick's movement history and runs the
damage half of the block effects.

lan_e2e `contacts`: magma, sneaking on magma, a lit campfire, a cactus, grown
and young berry bushes, for the host and the guest.

Fixes #1305, #1311, #1312, #1313.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 7acebbae45 Server players: the HUD's bubbles and golden hearts follow the server, absorption is saved, Nether lava pushes harder
The server sends a player's own air supply and absorption amount
(Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID) when they
change, and the client applies them: on the server thread and as a LAN
guest the breath meter follows the server's Respiration-extended air
(#1304) and the golden hearts show (#1306). The connected client's tick
sets the helmet's Respiration and the Nether's FAST_LAVA as the window's
own tick does (#1309).

AbsorptionAmount is written from the live body and read back on load,
in the window's world and for a server's players (#1307).

lan_e2e: absorption, breath and lava checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 0c133101b6 Screen overlays: wrap texels with a select, not integer modulo
The camera overlays (the vignette every frame) read up to four texels a
pixel, each wrapped with two integer modulos. Intel GPUs emulate integer
division. Reducing the coordinate to one period first (fract, as the shader
did before) leaves every read at most one texel outside the sprite, so a
select wraps it. Same texels and weights; clamp-to-edge is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:21:47 +02:00
Ludovic Marie 9c6b35d4cb Merge remote-tracking branch 'origin/main' into fix/texture-mcmeta-blur-clamp 2026-10-23 05:21:47 +02:00
Ludovic Marie 357dceb999 Merge pull request #1197 from ludovic111/feat/locator-bar
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:21:26 +02:00
Ludovic Marie aec75bbd28 Merge pull request #1194 from ludovic111/feat/armour-trims-worn
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:19:59 +02:00
Ludovic Marie 8b6d49c4b4 Merge pull request #1357 from ludovic111/docs/agent-control
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 05:17:45 +02:00
Ludovic Marie 03c4d2c683 Merge pull request #1353 from ludovic111/feat/agent-connect-game
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 05:16:19 +02:00
Ludovic Marie 891b122783 Merge pull request #1256 from ludovic111/perf/random-tick-counts
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:15:28 +02:00
Ludovic Marie 06b754db4a Merge pull request #1253 from ludovic111/perf/spawn-checks
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:14:01 +02:00
Ludovic Marie 1c18ac6087 Merge pull request #1239 from ludovic111/perf/animated-texture-uploads
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:13:35 +02:00
Ludovic MarieandClaude Opus 5.5 d6f8dda823 Metal: the GPU frame timer measures the frame again (read about 0.01 ms)
On Apple GPUs timestamps go only at pass boundaries, and the frame was
bracketed by two one-thread compute passes. Two things made it read
about 0.01 ms:

- Metal resolves counter samples without waiting for the passes that
  write them. Resolved in the frame's own command buffer, the end
  timestamp was mostly the one an earlier frame left in the shared
  query. Each readback slot now has its own queries, and with markers a
  slot is resolved in a later frame, once on_submitted_work_done says the
  GPU finished the frame it timed.
- A compute pass depends on nothing, so the end marker ran alongside the
  frame's render passes. The end is now a render pass that loads the
  frame's target, shading one pixel with every write masked: it runs
  after every pass that drew into the target. The start stays a compute
  pass (cheaper than a load and store of the target).

M4 Pro, render distance 32, offscreen: GPU p50 0.002 ms before, now
1.95 ms against a 1.95 ms frame (village) and 4.46 ms against 4.51 ms
(mountain), where the frame is GPU-bound. Vulkan and DirectX 12, which
write timestamps between commands, are unchanged but for the per-slot
queries.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:11:31 +02:00
Ludovic MarieandClaude Opus 5.5 7db6ce51a6 Docs: control Horizon with your AI agent (players), the full inventory (developers)
website/docs/agents.md, in the nav under "Going further", is the player's page.
It covers what an agent can do, connecting it from the launcher, copy-paste
config for Claude Code, Codex, Cursor and any MCP client, the game's AI Agent
page, a CLI quick reference, safety and No AI.

docs/agent-control.md is rewritten for developers:
- the surfaces, and why one MCP server covers the launcher and the game;
- the inventory, before and after, for the launcher and the game;
- No AI on every surface, and the launcher commands that stay;
- connecting from the launcher and the game;
- the design rules for a new command.

docs/launcher-commands.md lists every launcher command, its arguments and its
MCP tool. It is generated from the clap definition (cli_tools::markdown). The
launcher test the_launcher_reference_is_up_to_date fails when it is stale or a
tool is undocumented, like horizon-ctl's generated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:10:58 +02:00
Ludovic MarieandClaude Opus 5.5 768f149190 Horizon Settings > AI Agent: turn agent control on, see who is connected, copy the details
Horizon Settings gains a row: "AI Agent..." and "No AI: Off". No AI hides the
AI Agent page and closes the control socket at once.

The AI Agent page has an Agent Control switch (horizon-extras.txt
agentControl, off by default): on, the game the player starts opens the
default control endpoint, so horizon-ctl and MCP clients reach it without
--control. Turning it off closes the endpoint at once; dropping a control
server now also stops its listener thread. The page shows whether an agent
is connected, when it last sent a command and the socket, and Copy
Connection Details copies the socket path, a horizon-ctl line to try and the
MCP config for this game.

agent_control_e2e.py --scenario setting covers it: Agent Control on opens the
endpoint without --control, the pages list their widgets, Copy works, and
turning the switch off removes the endpoint while the game keeps running.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:58 +02:00
Ludovic MarieandClaude Opus 5.5 a57efd4fa0 Launcher lock file: horizon-bench now depends on horizon-telemetry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:27:54 +02:00
Ludovic MarieandClaude Opus 5.5 7eeb7ebbc2 Merge feat/win-screen (Options test fix) into feat/truetype-fonts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:16:55 +02:00
Ludovic MarieandClaude Opus 5.5 3f58e512e0 Options test: Credits & Attribution follows Horizon's two buttons
main (#1271) pins Horizon Settings and Mods as the grid's last buttons;
this branch adds Credits & Attribution after them.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:59:39 +02:00
Ludovic MarieandClaude Opus 5.5 e6237aa1da Merge the updated stack base (with origin/main) into fix/server-respawn-levels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 f582eae9f4 Merge the updated stack base (with origin/main) into fix/server-end-gateways
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 e4fb8f0f3f Merge the updated stack base (with origin/main) into fix/server-command-levels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 b6a018bf87 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the mounts check; levels stays last (it moves
the players between dimensions, and skips itself without --server-thread).
CHECKS and CHECK_FNS: 67 each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:10 +02:00
Ludovic MarieandClaude Opus 5.5 bdbe59155d Locator bar: drop the live-backdrop check main removed
#1271 removed the title's live world backdrop (backdrop_live), so the
locator bar hides on the title alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:56:28 +02:00
Ludovic MarieandClaude Opus 5.5 43a6fe0f7d Merge main into #1265: mount inventories for a server's players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:53:53 +02:00
Ludovic Marie 92b0ee1347 Merge pull request #1325 from ludovic111/fix/server-mount-inventory
🤖 Agent: merged after verify passed and the local parity run (102 of 102 at a4e68e6a, posted on the PR); lan_e2e mounts passes in both modes.
2026-10-23 01:52:24 +02:00
Ludovic MarieandClaude Opus 5.5 91dc5b0f18 Merge main into #1265: riding for a server's players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:52:02 +02:00
Ludovic Marie 014fc57b97 Merge pull request #1246 from ludovic111/feat/modbrowser-modpacks-port
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:46:33 +02:00
Ludovic Marie a9ccd5458c Merge pull request #1243 from ludovic111/feat/modbrowser-project-page
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:45:07 +02:00
Ludovic Marie 3e99730b98 Merge remote-tracking branch 'origin/main' into sw1132 2026-10-23 01:43:49 +02:00
Ludovic Marie f7b6777af6 Merge pull request #1244 from ludovic111/feat/modbrowser-e2e-harness
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:43:40 +02:00
Ludovic Marie 00b438d0ce Merge pull request #1242 from ludovic111/feat/modbrowser-java-launcher
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:43:13 +02:00
Ludovic Marie 990c609269 Merge pull request #1230 from ludovic111/feat/modbrowser-java-verdicts
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:39:39 +02:00
Ludovic Marie 9cfed309ac Merge pull request #1240 from ludovic111/fix/mod-item-network-ids
🤖 Agent: merged after verify passed and the integration run on the desktop (integ-w3: main + the mod browser stack + #1269; fmt, clippy, 1224 + 131 tests, mod_browser_harness ok).
2026-10-23 01:38:13 +02:00
Ludovic MarieandClaude Opus 5.5 0ff735b664 Merge the updated stack base (with origin/main) into fix/server-respawn-levels
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:59 +02:00
Ludovic MarieandClaude Opus 5.5 2b088da069 Merge the updated stack base (with origin/main) into fix/server-end-gateways
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:59 +02:00
Ludovic MarieandClaude Opus 5.5 5472d4c668 Merge the updated stack base (with origin/main) into fix/server-command-levels
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 5e55ebd2a7 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the minecarts, frames, lightning and riding
checks; levels stays last (it moves the players between dimensions, and
skips itself without --server-thread). CHECKS and CHECK_FNS: 66 each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 181c91d1b8 Merge feat/win-screen (now with main) into feat/truetype-fonts
Brings #1215's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:30 +02:00
Ludovic MarieandClaude Opus 5.5 906d02fff3 Merge origin/main into feat/win-screen
- game.rs: main (#1271) dropped the Horizon-theme checks from menu_blur and
  the title scroll; kept without them, with this branch's WinScreen parts
  (the credits alone blur, the title doesn't scroll under the Win screen).
  Screen keeps main's Friends and this branch's Win.
- control.rs: horizon-ctl options pages keep credits, horizon and
  shader-packs.
- options_ui.rs: Page keeps Credits and main's Horizon. The Options grid is
  main's (Horizon Settings and Mods in vanilla's Telemetry and Credits slots;
  Horizon Extras now sits inside Horizon Settings) with Credits & Attribution
  after them, laid out by this branch's row count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:30 +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 MarieandClaude Opus 5.5 9e11394600 Merge feat/armour-trims-worn (now with main) into feat/locator-bar
No conflicts: brings #1194's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:20 +02:00
Ludovic MarieandClaude Opus 5.5 901a5b8207 Merge origin/main into feat/armour-trims-worn
hud.rs: HudAtlas::slots keeps this branch's trim size parameter, with
main's doc line (the on-screen keyboard's text replaced the Horizon menu
text, #1271).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:19 +02:00
Ludovic MarieandClaude Opus 5.5 ed76af1c11 Merge origin/main into feat/far-detail-reduced
graphics.rs: main (#1271) dropped the level key from horizon-graphics.txt;
farDetail stays after lodDistance in main's format (21 keys, 21 values).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:05 +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
levzzz 18078e058d Explosions hurt and push minecarts, so TNT minecarts chain
ServerExplosion.hurtEntities skipped minecarts: a TNT minecart's blast
never reached the carts beside it. Carts now take the explosion's damage
through VehicleEntity.hurtServer (destroyed past 40, where
MinecartTNT.destroy primes a TNT cart with a 0-39 tick fuse, as
damageSourceIgnitesTnt counts explosions) and the knockback from their
eyes. The exploding cart is the explosion's source, so it is removed
before the blast and can't hit itself. Cart ticks now look carts up by id,
since a blast can remove or prime other carts mid-loop.
2026-10-23 03:47:04 +05:00
Ludovic MarieandClaude Opus 5.5 33dcc66379 Merge main into #1265: keep the vehicle checks, take the server-thread default
lan_e2e takes main's version: #1280 kept `boats` (passing with and
without --server-thread) and added `minecarts`, `frames` and `lightning`
next to `ladder`, and main's run() already sets HORIZON_SERVER_THREAD
explicitly as this branch does. The code keeps this branch's change:
single player runs on its server thread by default again.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 00:40:34 +02:00
levzzz 18bf8cd2ef Minecarts push each other in their own ticks, as vanilla does
OldMinecartBehavior.tick ends with pushAndPickupEntities: after a cart
moves, every other cart in its box (0.2 wider) gets push(cart). Horizon
pushed all pairs once before any cart moved, so carts placed inside each
other stayed exactly overlapping (push skips distances under 1e-4) and
never split apart. Pushes now run per cart, after its move, with
pushOtherMinecart's Mth sin table facing and float thresholds.
2026-10-23 03:40:19 +05: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 MarieandClaude Opus 5.5 725962b858 Merge feat/win-screen into feat/truetype-fonts
No conflicts: brings #1215's Screen::Win menu-blur arm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:04:30 +02:00
Ludovic MarieandClaude Opus 5.5 9f54087a86 Win screen: cover main's menu-blur match (Screen::Win)
Main's Screen::blurs_background (in-world menu blur) has no arm for the
new Screen::Win. WinScreen.extractBackground fills the screen with the End
portal while the poem rolls and falls back to Screen.extractBackground
(blurred) for the credits alone, so Game::menu_blur blurs only for the
credits without the poem.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:04:29 +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 MarieandClaude Opus 5.5 c8d8a35273 Merge feat/armour-trims-worn (#1194) into feat/locator-bar
Stacks #1197 on #1194 so it merges cleanly after it. game/packs.rs: both
PRs add one line to the resource reload; both are kept (waypoint styles,
then armour-stand trims).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:43 +02:00
Ludovic MarieandClaude Opus 5.5 65fbde0f15 Merge the updated stack base (with origin/main) into fix/server-respawn-levels
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:17 +02:00
Ludovic MarieandClaude Opus 5.5 54e9379bc1 Merge the updated stack base (with origin/main) into fix/server-end-gateways
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:16 +02:00
Ludovic MarieandClaude Opus 5.5 5a2d55c863 Merge the updated stack base (with origin/main) into fix/server-command-levels
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:15 +02:00
Ludovic MarieandClaude Opus 5.5 b6b6eb9a87 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the ladder and boats checks where this branch
added levels. Both are kept, with levels still last: it moves the players
between dimensions (and skips itself without --server-thread).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:00 +02:00
Ludovic MarieandClaude Opus 5.5 6bd80fe817 Merge feat/win-screen (now with main) into feat/truetype-fonts
No conflicts: brings #1215's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 3ce3fe0374 Merge origin/main into feat/win-screen
hud.rs: HudBatch keeps both new fields, end_portal (this branch) and
blur_at (main's menu blur), in the struct, its constructor and the test
batch. draw_range already draws EndPortal runs on either side of blur_at.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:56:56 +02:00
Ludovic Marie 8ddb74031c Merge pull request #1273 from rdbtCVS/perf/end-island-volumes
🤖 Agent: merged after verify passed and the contributor integration on the desktop (v7: core 99 and worldgen 81 tests, 23 worldgen/density/noise parity harnesses 23/0; bit-exact by construction, with a test against the scalar path).
2026-10-22 22:52:05 +02:00
Ludovic MarieandClaude Opus 5.5 7f7a05cbd0 Play with Friends on single player's server thread
Sharing a world with an invite code refused with the integrated server
on its own thread ("open a singleplayer world first"): the window is a
client there. It now shares that server as Open to LAN publishes it:
friends_start publishes it when it isn't, the invite tunnel points at
its port, stopping sharing unpublishes it again, and the pause menu
shows the Friends entry with the players the server lists.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:51:16 +02:00
Ludovic MarieandClaude Opus 5.5 6a58fc20da Java shader packs: the pack's gbuffers_terrain and gbuffers_water draw the terrain
When a Java pack has a terrain program (gbuffers_terrain, or the program
Iris falls back to), Horizon's world pass skips its own terrain and the
pack's program draws every visible section into its render targets,
tested against the depth of the rest of the world; gbuffers_water draws
the translucent layer before the first composite with the pack's blend
modes. depthtex0/depthtex1 get the depth before and after water.

- translate: a geometry mode (group 2, attributes as globals a glue
  function fills before main, alpha test) and a pass that ends switch
  cases which return, which naga's WGSL writer otherwise rejects
  (Photon).
- java_terrain: the glue decoding Horizon's terrain vertices from the
  arena pages into Iris's attributes, the block.properties id table by
  sprite name, and a 2D atlas built from the sprite arrays (animated
  sprites copied in).
- RGBA16/RG16/R16 targets are 16-bit unorm where the GPU renders them:
  Chocapic13' packs its G-buffer into them.

MakeUp Ultra Fast, Chocapic13' and BSL draw their own terrain and water
on the M4 Pro; Photon still draws black (compute and .dat textures).

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:06:51 +02:00
Ludovic MarieandClaude Opus 5.5 91d89b4abf Java shader packs: modf into a vector component works in an initialiser
The comma form of modf (`v.y = trunc(x), x - trunc(x)`) printed without
brackets in a declaration's initialiser, which naga's GLSL front end then
read as a second declarator. A unary + around it has glsl-lang print the
brackets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 20:56:08 +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 MarieandClaude Opus 5.5 60fc787288 Java shader packs: Photon, Chocapic13' and MakeUp Ultra Fast load
The translator now legalises what these packs write and naga refuses:
lookups without a level outside fragment shaders become textureLod at
level 0; struct varyings travel as one varying per field; textureGather
is emulated with texelFetch; a bool given to a numeric constructor
becomes cond ? 1 : 0; `const in` parameters lose the const; array fields
of private structs get their natural stride (naga lays every struct out
as std140); modf into a vector component assigns it instead of passing a
pointer. shaders.properties keeps the empty lines the preprocessor
leaves, so Photon's `) \` before `#else` ends its value.

real_packs_load now also parses every translated program again as WGSL,
which is what wgpu is given.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:37:21 +02:00
Ludovic MarieandClaude Opus 5.5 8b16f76b3c Section walk: no per-section frustum test in columns entirely inside
A column whose box lies entirely inside the frustum holds no section the
frustum would reject (plane distances only grow from its nearest corner
to a section's farthest, and rounding keeps the order), so its sections
skip the test. At render distance 32 most columns are past the distance
where the vertical field of view takes in their whole height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:29:54 +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
Ludovic MarieandClaude Opus 5.5 abb7a97136 Animated sprites on the GPU: frames uploaded once, one dispatch per mip level
Every animation frame's mip chain goes to video memory once, at the
sprite's own size, as vanilla keeps its animated sprites' frames on the
GPU. A tick that changes sprites writes one 20-byte job per changed
sprite, and a compute dispatch per mip level repeats the chain into the
array's larger layers and mixes interpolated frames
(animate_sprite_interpolate), instead of blending on the CPU at the
array's size and copying each sprite level by level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:56:43 +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 4ad6bb18ef Natural spawning: resolve the entry's type once, skip the mobsAt recheck where it was picked, measure only nearby players
spawn_category_for_position looked up the picked entry's type twice per
attempt and asked mobsAt again at the position it had just picked the entry
from. The type is now resolved when the entry is picked, and the recheck is
skipped at that position (the list there holds the entry). The nearest player
distance only feeds comparisons with 24 and the despawn distances (128 at
most), so an attempt near its start measures only the players that can be
within 128 blocks of it; farther ones read as infinitely far, which every check
answers the same. The water-ambient biome tag is read only for that category.
Same lists, same random draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:54:06 +02: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 MarieandClaude Opus 5.5 2ced9a451d Server players respawn at their point in the Nether (anchors, spawnpoint)
A server's player (a LAN guest, and since the server thread the single
player too) always respawned in the Overworld. Its respawn anchor was
dropped when its action ended, and its save kept only an Overworld point.

- The guest keeps a respawn point outside the Overworld (respawn_elsewhere,
  saved as the player's `respawn`) and has it in place while it acts, so an
  anchor it uses is its own.
- guest_respawn respawns it in that point's level, waits there for the
  chunks, then resolves the point through its block
  (findRespawnAndUseSpawnBlock): beside the anchor, using a charge, or the
  world spawn and block.minecraft.spawn.not_valid when the block is gone.
- `spawnpoint` in another level than the Overworld sets a forced point there
  (SetSpawnCommand) instead of Overworld coordinates, and a forced point that
  is no anchor holds where a player fits (isPossibleToRespawnInThis), in the
  window's own game too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 86fdc72069 Server players go through End gateways
handle_server_portal ignored End gateways, so on a server (dedicated, or
single player's server thread) no player could use one: the in-frame game's
gateway code moved only its own player.

A gateway that is not cooling down now takes a server player at once
(EndGatewayBlock.entityInside, triggerCooldown, setAsInsidePortal), within
the End, keeping its motion and look (Relative.DELTA and ROTATION). An exact
exit is reached straight away; otherwise the player waits at the destination
while it loads, and the exit is found or built there (the in-frame
resolve_gateway, shared through gateway_job).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 9fe228cd1e Server commands run in their source's level (execute in, execute at)
On a server (dedicated, or single player's server thread) each dimension is
its own level, but a command ran in whichever level was in place. So a guest
in the Nether running `execute in minecraft:overworld run setblock ...` got
"That position is not loaded", and `execute at <a player in another level>`
used the wrong level's blocks.

with_source now puts the source's level in place for the command
(CommandSourceStack.getLevel), as as_target already does for a player in
another level, and puts the old one back after. `execute at` takes the
entity's level (withLevel(entity.level())), and a player's level is the
guest's own (target_level) even while another level is in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 84171cfc94 lan_e2e portals: wait for the guest to be placed in its Nether portal
On a server the guest is held where it entered the Nether until the
chunks around the destination are lit, then placed in its portal. The
check read the guest's position 40 ticks after the dimension changed,
which on a busy machine was still the held spot, and then stepped the
guest out of a portal that wasn't there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 bd36508c29 lan_e2e levels: players in their own levels on the server thread; design note
A new `levels` check (--server-thread) runs the host and a guest in different
dimensions: execute in ... tp, mobs ticking in two levels at once with unique
ids, rejoin and save/reload with the guest in the Nether, death there, the
host through a Nether portal alone, the guest through an End portal and the
exit portal alone. All of it already works on the server thread.

docs/server-levels.md says how the server's levels map onto vanilla's, what
the dedicated server and the server thread share, the cost of an extra level
and what's left. docs/server.md no longer says players can't change dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +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 MarieandClaude Opus 5.5 a069c2716f Random ticks: ask for the next ticking section, and read a section's blocks faster
tick_chunk_random asks for the lowest randomly ticking section from a height
up, instead of asking about every section: a chunk costs one question per
section that ticks, not 24. PalettedContainer::has tests whole words for the
few matching palette indices (a field-wise zero test) instead of reading
every entry, so a section read again after an edit costs a few hundred word
operations.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:30:19 +02:00
Ludovic MarieandClaude Opus 5.5 7e410d93b3 Fonts: TrueType providers and the font picked by text components
Every font definition of the resources (and packs) is now loaded, not
just font/default.json, and a text component's "font" field picks one:
"minecraft:alt" draws the enchanting table's Galactic alphabet,
"minecraft:illageralt" the illager runes, a pack's own font its glyphs,
and a font that doesn't exist draws every character as the missing
glyph (FontManager.getFontSetRaw).

- font.rs: one FontSet per font id with its providers in order (space,
  bitmap, unihex, ttf) and their filters ("uniform", "jp"). Bitmap
  sheets outside the atlas (another namespace or folder) are copied into
  the glyph region when first drawn.
- TrueTypeGlyphProvider: "ttf" providers (file under font/, size,
  oversample, shift, skip; CFF fonts refused) rasterized with ab_glyph
  into the glyph region as rrrr coverage (text_intensity), metrics as
  GlyphBitmap (bearings over the oversampling, whole-pixel advances).
- text::Style gains the font id; it is parsed from JSON and SNBT
  components, pack descriptions and scoreboard number formats, inherited
  by children and written back.
- Obfuscated text picks its random glyph in the style's font.

Fixes #336

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:27:47 +02:00
Ludovic MarieandClaude Opus 5.5 4e45a3bddf Credits and end poem (WinScreen)
Leaving the End through the exit portal for the first time now rolls the
end poem, the credits and the post-credits text (WinScreen), as does a
server's WIN_GAME event; Options -> Credits & Attribution -> Credits rolls
the credits alone. The texts come from the resource root
(texts/end.txt, credits.json, postcredits.txt), so packs can replace them.

- win_screen.rs: vanilla's line building (PLAYERNAME, the obfuscated
  names from LegacyRandom(8124371), wrapping at 256), scroll speeds and
  keys (Space, Ctrl, Up, Escape), the last line pinned mid-screen, the
  logo, the vignette and the backgrounds.
- HUD: Blend::Vignette (RenderPipelines.VIGNETTE) and Blend::EndPortal
  (the End portal effect as a GUI fill, the poem's background).
- Obfuscated text draws a random glyph of the same width each frame.
- seenCredits is read and saved; the player stays out of the world while
  the poem rolls and arrives in the Overworld after it.
- horizon-ctl `credits [poem] [SCROLL]` and `options credits`.

Fixes #338

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:25:47 +02:00
Ludovic MarieandClaude Opus 5.5 9d58d6f525 Random ticks: a section ticks while it holds a randomly ticking block, as in vanilla
Vanilla random-ticks a section while its count of randomly ticking blocks
and fluids is above zero (LevelChunkSection.isRandomlyTicking), and asks as
it reaches each section. Horizon ticked every section whose palette listed
such a state, even after the last one was gone, and decided before the tick's
scheduled ticks, which drew positions vanilla doesn't and shifted its
position sequence.

RandomTickSections now reads each section's blocks (PalettedContainer::has),
again only for the sections that changed, and tick_chunk_random asks for
each section when it reaches it; the game reads a chunk's sections again only
after a block changed. New harness random_tick_sections compares the
sections and positions with vanilla's real section counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 13:31:27 +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 38276fbc30 Locator bar HUD and waypoint styles
LocatorBar: when the server tracks waypoints for the player, the bar
above the hotbar shows a dot per waypoint within 60 degrees of the
view (farthest first), coloured from the waypoint's colour or its id's
hash, with the sprite its style picks by distance
(assets/*/waypoint_style/*.json, WaypointStyleManager) and the blinking
up or down arrow when the waypoint is above or below the screen. The
background is nine-sliced. Hud.nextContextualInfoState picks between
the jump bar, the experience bar (100 ticks after an experience change)
and the locator bar.

Waypoints come from ClientboundTrackedWaypointPacket on remote servers
and, on a served world, from the host's own connection in
lan/waypoints.rs; entity waypoints in view follow the entity's eyes.

Fixes #339

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:42:40 +02:00
Ludovic MarieandClaude Opus 5.5 c77863f163 Armour trims on worn armour (player, LAN guests, armour stands)
EquipmentLayerRenderer's trim layer: the pattern's
textures/trims/entity/<layer>/<asset>.png recoloured from its base
palette (palette metadata section) to the material's palette, or to the
equipment asset's trim_overrides palette (iron armour with an iron trim
uses trim/iron_darker), as PalettedTextureManager does. Each texture is
made once, on first use, into one of 32 atlas trim slots (least recently
drawn reused), and drawn over the armour layer on the player, on the
guests a LAN host sees and on armour stands.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:13:24 +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 21baf0dc99 Screen overlays: blur and clamp from the texture's .png.mcmeta
TextureMetadataSection's blur and clamp choose how vanilla samples a
standalone texture (ReloadableTexture.setSampler). The atlas now keeps
each texture's flags and the screen-effect quads (vignette, pumpkin blur,
powder snow outline, underwater, equippable camera overlays) sample
their sprite rectangle linearly and with clamp or repeat as those flags
say, instead of always nearest and repeat.

Fixes #335

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:47:03 +02:00
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
Ludovic MarieandClaude Opus 5.5 d0330061bd Far Detail: an opt-in Reduced level for far terrain, suggested on integrated GPUs
Reduced starts each far tile level at half the distance Full does (about a
quarter of the far quads beyond the full-detail edge, whose finest tiles stay).
Full stays the default everywhere and no preset changes it. On integrated
GPUs other than Apple's, the setting's description suggests Reduced.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:45:33 +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 14bd239141 Solid section draws grouped by arena page within 64-block bands
A list sorted only by distance switched vertex buffer every few sections:
about 1200 indirect multi-draws a frame at render distance 32, most of a
few entries, which Mesa's Intel driver unrolls on the CPU. Grouping each
band's sections by page keeps the list front to back within the band, with
one call per page and band.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:37:40 +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 Marie defaa7cd2a Merge pull request #1025 from ludovic111/fix/command-exec-dimension-flake
🤖 Agent: merged after verify and CI parity passed (modpacks-11).
2026-10-19 17:23:10 +02:00
Ludovic Marie 9e50fd8df2 Merge pull request #1028 from ludovic111/fix/carry-enter-block-advancements
🤖 Agent: merged after verify and CI parity passed (modpacks-11).
2026-10-19 17:23:05 +02:00
Ludovic Marie 8b53bd8a47 Merge pull request #1022 from ludovic111/fix/carry-rs-structure-void
🤖 Agent: merged after verify and CI parity passed (modpacks-11).
2026-10-19 17:23:00 +02:00
Ludovic Marie f441acf2f8 Merge pull request #1019 from ludovic111/fix/carry-untyped-loot-numbers
🤖 Agent: merged after verify and CI parity passed (modpacks-11).
2026-10-19 17:22:55 +02:00
Ludovic Marie 9d555dcdd0 Merge pull request #988 from ludovic111/feat/simple-template-structures
🤖 Agent: merged after verify passed and a local check on main + #988: chunk_generation, command_exec and mob_ai (the three harnesses that failed in CI on its old head, under load) all pass; modpacks-10's integration ran 301/301. Formations' structures generate from the noise column.
2026-10-19 16:46:40 +02: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 Marie 2ff8f5277d Merge pull request #1012 from ludovic111/feat/release-channels
🤖 Agent: merged after verify passed, security-1's dry runs on the branch, and an independent review whose findings were all fixed. Every release goes out as a beta and is promoted to stable after 6 hours if its smoke test passed and no release-blocker issue is open; launcher-v0.14.0 (stable by default) is already out.
2026-10-19 16:29:16 +02:00
Ludovic Marie 9414c55d22 Merge pull request #1015 from ludovic111/ci/codeowners
🤖 Agent: merged after verify passed, security-1's dry runs on the branch, and an independent review whose findings were all fixed. CODEOWNERS for the release pipeline and update code. The protection itself (tools/ci/protect_pipeline.sh --apply) waits for the switch to the GitHub App.
2026-10-19 16:29:12 +02:00
Ludovic Marie 19898a0d77 Merge pull request #1018 from ludovic111/docs/security
🤖 Agent: merged after verify passed, security-1's dry runs on the branch, and an independent review whose findings were all fixed. docs/security.md: the security model in plain words, with what is live and what still waits.
2026-10-19 16:29:07 +02:00
Ludovic Marie d93c739edc Merge pull request #1024 from ludovic111/ci/release-clean-checkout
🤖 Agent: merged after verify passed, security-1's dry runs on the branch, and an independent review whose findings were all fixed. Release builds on the Mac mini start from a clean checkout.
2026-10-19 16:29:03 +02:00
Ludovic Marie 2b756b7957 Merge pull request #1031 from ludovic111/feat/readme-front-page
🤖 Agent: merged after verify passed (docs and website only; the site build fails on broken links, anchors or images). The README is a shorter front page that links to the new docs.
2026-10-19 16:28:22 +02:00
Ludovic Marie df1155b8b5 Merge pull request #1030 from ludovic111/feat/site-docs
🤖 Agent: merged after verify passed (docs and website only; the site build fails on broken links, anchors or images). The website gets a Docs section with 11 pages; /server redirects to /docs/hosting.
2026-10-19 16:28:18 +02:00
Ludovic MarieandClaude Opus 5.5 ea6ad5165f README: a clear front page
One-paragraph pitch, download and install, what works today with links to the
site's documentation, hosting and modpacks in two lines each, then build from
source, contributing and the project status table (moved lower, unchanged in
form so the site and tests/test_parity.py still read it). The stale release
notes section and launcher version are gone: the release notes are linked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:25:33 +02:00
Ludovic MarieandClaude Opus 5.5 a13e6b40ce Website: a documentation section for the whole project, replacing the server page
/docs has a left navigation and eleven pages written for players first, then
hosters, then contributors: what Horizon is, install, settings, multiplayer,
logs and bug reports, hosting (the launcher's Servers page and the command
line), modpacks, mods and data packs, performance, contributing and the FAQ.
/server redirects to /docs/hosting. The build checks every link between the
pages and every image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:24:23 +02:00
Ludovic Marie d500f28ee4 Merge pull request #1026 from ludovic111/feat/launcher-servers-page
🤖 Agent: merged after verify passed (74 + 9 + 1 launcher tests; launcher-only). The Servers page: create, start/stop, players, settings, backups, updates, run at login, and Friends over the internet with Tailscale (device sharing, an optional rule limiting shared friends to the game port, never Funnel). Ludovic's real-tailnet check is described in the PR.
2026-10-19 16:22:08 +02:00
Ludovic Marie c95ab6c26e Merge pull request #1017 from ludovic111/feat/launcher-servers-backend
🤖 Agent: merged after verify passed (launcher-only; no parity harness depends on it). The launcher's server backend and `horizon-launcher servers …` CLI.
2026-10-19 16:22:02 +02:00
Ludovic Marie c94b6a0972 Merge pull request #1009 from ludovic111/feat/server-config-command
🤖 Agent: merged after verify and CI parity passed (40 server tests; the whole create → start → join → whitelist → backup → restore → update → stop flow ran on the Mac mini and the ThinkPad). Adds `horizon-server config`, `backups --json`, `update --check --json`; fixes whitelisting on offline servers and updates as root in containers.
2026-10-19 16:21:56 +02:00
Ludovic MarieandClaude Opus 5.5 399178174d port-pack: Repurposed's structure voids keep the world's block
repurposed_structures:structure_void_processor returns null for a
template's structure_void blocks, which is what vanilla's block_ignore
does with blocks: [structure_void]. Carry writes it that way, so the
82 lists that use it (its mineshafts, ocean village and temples, the
jungle pyramid's hidden room, swamp village) no longer place structure
voids over the terrain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:19:24 +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 8011d274eb port-pack: enter_block criteria name their block as blocks
26.3's EnterBlockTrigger reads a block list from 'blocks' and skips
1.21.1's 'block', so a carried criterion matched any block the player
entered: Regions Unexplored's Downer, From the Tree Tops and Light as a
Frog were granted on the first dive into the sea. Carry renames the
field (20 advancements in Regions Unexplored, with its boat recipes'
in-water criteria).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:16:54 +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 MarieandClaude Opus 5.5 5e90e26de0 launcher: the Tailscale panel reads invitations and access controls again every 30 s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:05:33 +02:00
Ludovic Marie 59a8aa2cd5 Merge pull request #984 from ludovic111/feat/carry-sea-level-processors
🤖 Agent: merged after verify passed and a local check on main + #983 + #984 (clippy clean; chunk_generation and locator_bar, the two harnesses that failed in CI on the old head, both pass; modpacks-10's integration ran 301/301). Structures built under the sea are flooded and waterlogged; Better Ocean Monuments' processors carried.
2026-10-19 16:02:52 +02:00
Ludovic Marie 84e9dd59fd Merge pull request #983 from ludovic111/feat/sea-level-processors
🤖 Agent: merged after verify passed and a local check on main + #983 + #984 (clippy clean; chunk_generation and locator_bar, the two harnesses that failed in CI on the old head, both pass; modpacks-10's integration ran 301/301). Structures built under the sea are flooded and waterlogged; Better Ocean Monuments' processors carried.
2026-10-19 16:02:45 +02:00
Ludovic MarieandClaude Opus 5.5 76b151d2b9 launcher README: Servers page screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:59:42 +02:00
Ludovic MarieandClaude Opus 5.5 373742573f command_exec harness: come back from the Nether to terrain that is already loaded
The relative-dimension check teleported the player from the Nether's
80 100 -40 to the overworld's 644 100 -316, fresh ocean chunks at seed 1.
On some runs vanilla broke some of their kelp and seagrass while they
loaded (its chunk loading is asynchronous), which rolled those blocks'
loot tables, so /random reset * later counted 12 or 13 sequences
against Horizon's 11. 1 230 0 in the Nether lands at 12 230 4, terrain
loaded since the start: the scaling is still checked, and 10 runs in a
row pass (5 of the 16 runs before failed this way).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:53:41 +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 47bfa67f49 launcher: keyboard and contrast tests for the Servers page; readable pre-update backup names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:50:11 +02:00
Ludovic MarieandClaude Opus 5.5 1ea04a2b10 docs/security.md: where things stand today, and the Mac mini runs forks' code too once labelled
- A table of what is live, waiting to merge, or waiting for Ludovic, so the page doesn't read as if it all were.
- Forks: their checks need a maintainer's approval, and run-parity runs them on the Mac mini; the Mac mini
  section no longer says forks don't run there, and says what a clean release checkout does and doesn't fix.
- Blockers hold every beta while open; releases from before signing stay installable with pinned files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:45:24 +02:00
Ludovic MarieandClaude Opus 5.5 c05a00092e docs/security.md: the security model in plain words
What an attacker could do before (instructions hidden in reports, a skipped check that counted as
passed, releases straight to every player, downloads trusting GitHub alone, pipeline files changed
like any other), what stops it now (untrusted text, verify always running, beta then stable,
signed updates, code owners, the agents' own GitHub App), what still needs Ludovic, and what it
doesn't solve. Written for the website's Contributing page; CONTRIBUTING.md links it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:44:29 +02:00
Ludovic MarieandClaude Opus 5.5 622b9a3a95 Releases: the Mac mini builds them from a clean checkout
Its work folder is shared with pull requests' parity jobs; with clean: false, whatever they left (target/, an
untracked .cargo/config.toml, a build script's output) could end up in a release. release.yml and launcher.yml
now always check out clean; target/ is rebuilt from scratch, and vanilla-data.sh relinks .local/ from its
preparations outside the checkout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:43:49 +02:00
Ludovic MarieandClaude Opus 5.5 19220000be CODEOWNERS: the launcher's downloader, links.json and the Cargo.toml files; Ludovic alone may bypass the review
- backend.rs downloads, installs and self-updates; links.json says where from; launcher/Cargo.toml and
  crates/horizon-server/Cargo.toml pick the crates that check downloads.
- protect_pipeline.sh: ludovic111 is the only owner and can't approve his own pull requests, so he is the one
  bypass_pull_request_allowances user. enforce_admins stays on, so verify is still required for everyone.
  --apply reads the protection back and fails if GitHub didn't keep it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:42:17 +02:00
Ludovic MarieandClaude Opus 5.5 1c341d58a6 CODEOWNERS for the release pipeline, and the protection that makes it count
.github/, tools/ci/, the release tools, the signing crate and the code that checks updates are
Ludovic's to review. tools/ci/protect_pipeline.sh prints (and with --apply sets) main's protection
with code owners' reviews required and no other approvals, keeping the required verify check;
it is for the orchestrator to apply once agents work as the GitHub App.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:42:09 +02:00
Ludovic Marie 7d5261345a Merge pull request #1023 from ludovic111/ci/release-public-key
🤖 Agent: merged after verify passed. The secret was set first (RELEASE_SIGNING_KEY in the release environment), so the next release is signed with this key.
2026-10-19 15:40:55 +02:00
Ludovic MarieandClaude Opus 5.5 07a7a26d7d launcher: older servers show no backup toggle; no empty line under a player without a ping
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:39:40 +02:00
Ludovic MarieandClaude Opus 5.5 a307bd2e3d Release channels: blockers hold every beta, latest only moves forward, a stalled promotion opens an issue
- A release-blocker issue's text is never read: its author can edit it after a maintainer labels it, so an
  open blocker holds every beta, and one closed as completed still holds the betas published before it closed.
- Even --force never promotes a beta that isn't newer than the stable release: horizon-server update and
  install.sh download GitHub's latest.
- When the oldest beta newer than the stable release has waited 24 hours, promote.yml opens a "Promotion to
  stable has stalled" issue with the last reason; the next promotion closes it.
- promote.yml: persist-credentials: false; issues: write for the stall issue.
- Website: the newest 100 releases, plus GitHub's latest (the stable one) when a day of betas pushes it out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:38:05 +02:00
Ludovic MarieandClaude Opus 5.5 9b1e413edf launcher: a server whose Horizon predates config says so instead of empty backups; a note after an update
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:36:55 +02:00
Ludovic MarieandClaude Opus 5.5 2a7303f76f Releases: beta and stable channels, promoted after 6 hours
Game releases go out as betas (GitHub pre-releases, never "latest") with the Mac mini's smoke
report attached. promote.yml (every 30 minutes, or by hand from a phone) runs
tools/release_channels.py, which makes the newest beta stable and latest once it has been out 6
hours, passed its smoke test and no release-blocker issue covers it, with notes rewritten to cover
everything since the last stable release. release_train.py counts merges from the newest release of
either channel, and a beta's notes say it is one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:34:03 +02:00
Ludovic MarieandClaude Opus 5.5 64c6c292ef Release signing: add the first public key
The private key was generated by tools/ci/new_release_key.sh straight into
the release environment's RELEASE_SIGNING_KEY secret (never on disk or
printed); from now on every release's SHA256SUMS is signed with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:33:08 +02:00
Ludovic Marie e2ea3014d5 Merge pull request #1013 from ludovic111/feat/launcher-beta-builds
🤖 Agent: merged after verify passed, with security-1's dry runs on the branch and an independent review (no regression: parity still runs on every PR from this repo; releases go out unsigned with a warning until the signing key is set).
2026-10-19 15:32:14 +02:00
Ludovic MarieandClaude Opus 5.5 c42ae71384 launcher: no update check while another action runs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:32:12 +02:00
Ludovic Marie 0a1d03b4b7 Merge pull request #1011 from ludovic111/feat/signed-release-checksums
🤖 Agent: merged after verify passed, with security-1's dry runs on the branch and an independent review (no regression: parity still runs on every PR from this repo; releases go out unsigned with a warning until the signing key is set).
2026-10-19 15:32:09 +02:00
Ludovic Marie e17e8b9d36 Merge pull request #1010 from ludovic111/ci/verify-always-runs
🤖 Agent: merged after verify passed, with security-1's dry runs on the branch and an independent review (no regression: parity still runs on every PR from this repo; releases go out unsigned with a warning until the signing key is set).
2026-10-19 15:32:04 +02:00
Ludovic MarieandClaude Opus 5.5 a4b7ac4a9b horizon-server update: unpack as the user, not the archive's owners
As root in a user namespace (an unprivileged LXC or rootless container), GNU tar
keeps the archive's owners by default and fails on IDs it can't map, so the update
stopped at "tar couldn't unpack". Found driving the launcher's Update now from one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:31:55 +02:00
Ludovic MarieandClaude Opus 5.5 a8dbaaabd4 launcher: refused values show under their field; a restore says what it did; Checking… while an update check runs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:21:56 +02:00
Ludovic Marie d468e1e78d Merge pull request #974 from ludovic111/feat/carry-extra-namespaces
🤖 Agent: merged after verify and CI parity passed, plus modpacks-10's local integration (parity 301/301, structure-start oracle 150/150 and 134/134). Towns and Towers' namespaces carried in its manifest.
2026-10-19 15:20:55 +02:00
Ludovic Marie 9a0077794c Merge pull request #969 from ludovic111/feat/mod-extra-namespaces
🤖 Agent: merged after verify and CI parity passed, plus modpacks-10's local integration (all nine branches on main: parity 301/301, structure-start oracle 150/150 and 134/134). Content mods may own namespaces besides their id; two mods may not share one.
2026-10-19 15:20:30 +02:00
Ludovic Marie 418e22a4b5 Merge pull request #975 from ludovic111/fix/mod-loot-tables
🤖 Agent: merged after verify and CI parity passed, plus modpacks-10's local integration (all nine branches on main: parity 301/301, structure-start oracle 150/150 and 134/134). Mod loot tables from every namespace, so modded structure chests are filled.
2026-10-19 15:20:26 +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 MarieandClaude Opus 5.5 1b17bef90d launcher: the Check Minecraft accounts note wraps in the Create dialog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:05:51 +02:00
Ludovic MarieandClaude Opus 5.5 621cea18ca docs/modpacks.md: carry types bare number ranges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:04:18 +02:00
Ludovic MarieandClaude Opus 5.5 d21b122379 launcher: the suggested server name is selected (typing replaces it); Space types a space everywhere
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 15:01:16 +02:00
Ludovic MarieandClaude Opus 5.5 fd5360533b launcher: a focus ring with a gap; the empty Servers page mentions the EULA
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:58:19 +02:00
Ludovic MarieandClaude Opus 5.5 32439053c9 launcher: an override Tailscale path that isn't there means not installed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:56:04 +02:00
Ludovic MarieandClaude Opus 5.5 8f51e68d2d launcher: draw the keyboard focus ring (a shadow needs some blur in GPUI)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:54:28 +02:00
Ludovic MarieandClaude Opus 5.5 76e3827545 release.yml sign step: a block scalar, so the line break isn't folded into an escaped space
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:47:39 +02:00
Ludovic MarieandClaude Opus 5.5 f94dfe1f08 Carry: number providers written as a bare {min, max} get 26.3's uniform type
1.21.1 read a {min, max} without a type as a uniform number provider; 26.3
dispatches on type alone. The game read such rolls and counts as 0, so
chests of 304 carried ATM10 tables (When Dungeons Arise 140, Repurposed
Structures 50, Structory and its towers 78, ...) came out empty or short.
Carry now types them in pools (rolls, bonus_rolls) and in the functions
that take one, nested bounds too; ranges such as limit_count's and item
predicates' counts stay ranges.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:39:18 +02:00
Ludovic Marie a43620023d Merge pull request #995 from ludovic111/perf/lod-integrated-gpu
🤖 Agent: merged after verify passed and CI parity passed on the stack head (#995; local 98/98). perf-frame-9's terrain LOD follow-ups: band and black cliffs fixed, far quads −36 %, per-frame budgets, LOD cap on non-Apple integrated GPUs. LODs off matches main (screenshots, FPS).
2026-10-19 14:38:25 +02:00
Ludovic Marie 4d164deb16 Merge pull request #994 from ludovic111/perf/lod-frame-spikes
🤖 Agent: merged after verify passed and CI parity passed on the stack head (#995; local 98/98). perf-frame-9's terrain LOD follow-ups: band and black cliffs fixed, far quads −36 %, per-frame budgets, LOD cap on non-Apple integrated GPUs. LODs off matches main (screenshots, FPS).
2026-10-19 14:38:10 +02:00
Ludovic MarieandClaude Opus 5.5 182a8e1a15 horizon-release-sign keygen: PKCS#8 v1 keys, which OpenSSL 3.0 reads
ring writes Ed25519 keys as PKCS#8 v2 (with the public key), which Ubuntu's OpenSSL 3.0 refuses
("unsupported"); verify caught it on ubuntu-latest. keygen now writes v1, the 32-byte seed alone,
checked to give the same public key as ring's document.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:37:57 +02:00
Ludovic Marie 1c5e5b96aa Merge pull request #993 from ludovic111/perf/lod-buried-gaps
🤖 Agent: merged after verify passed and CI parity passed on the stack head (#995; local 98/98). perf-frame-9's terrain LOD follow-ups: band and black cliffs fixed, far quads −36 %, per-frame budgets, LOD cap on non-Apple integrated GPUs. LODs off matches main (screenshots, FPS).
2026-10-19 14:37:56 +02:00
Ludovic Marie fa0b364f9d Merge pull request #992 from ludovic111/fix/lod-visual-bugs
🤖 Agent: merged after verify passed and CI parity passed on the stack head (#995; local 98/98). perf-frame-9's terrain LOD follow-ups: band and black cliffs fixed, far quads −36 %, per-frame budgets, LOD cap on non-Apple integrated GPUs. LODs off matches main (screenshots, FPS).
2026-10-19 14:37:42 +02:00
Ludovic MarieandClaude Opus 5.5 43287c18c2 horizon-server whitelist add: the offline UUID on a running offline server
The game resolves names as vanilla does, Mojang first, so on an offline server a name someone owns got that
account's UUID and the player, who joins under the offline UUID, was refused. horizon-server now writes the
offline entry and has the game reload the whitelist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:24:15 +02:00
Ludovic MarieandClaude Opus 5.5 d38c07c2fa Releases: sign in a job of its own that runs only sha256sum and OpenSSL
Any repository code running earlier in the job that holds the key could reach it (a build patched
through Cargo.toml, or a step writing $GITHUB_ENV or $GITHUB_PATH for the next one). The `sign`
job checks out only tools/ci/sign_release.sh, which signs with the runner's OpenSSL; the publish
job, without the key, checks the result with horizon-release-sign as launchers will.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:18:54 +02:00
Ludovic MarieandClaude Opus 5.5 d2898fcba1 launcher README: the Servers page
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:18:33 +02:00
Ludovic MarieandClaude Opus 5.5 a8a00aac94 launcher: Friends over the internet: Tailscale status, invitations and a safe access rule
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:16:53 +02:00
Ludovic MarieandClaude Opus 5.5 10d76ad89d Launcher: stable builds by default, a "Beta builds" switch, and each version's channel
Game releases now go out as betas and become stable 6 hours later (promote.yml). The launcher
installs new versions from the stable channel unless "Beta builds" is on in Settings; turning it
on checks again at once. The Versions page, `versions` and the MCP server show each build's
channel. The website's patch notes list stable releases only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:15:20 +02:00
Ludovic MarieandClaude Opus 5.5 4768d3f65b launcher: a Servers page that creates, runs and manages horizon-server servers (WIP)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:09:09 +02:00
Ludovic MarieandClaude Opus 5.5 f32a91d793 Releases: signed checksums (SHA256SUMS, SHA256SUMS.sig) for game and launcher releases
crates/horizon-release-sign signs a release's checksums with an Ed25519 key that exists only as a
secret of the `release` environment, and checks them as launchers will, against the public keys
built in from release-keys.txt. Dry runs sign with a throwaway key. Until release-keys.txt lists a
key, releases keep going out with SHA256SUMS alone and a warning; once it does, a missing or
unknown key fails the release before anything is published. tools/ci/new_release_key.sh pipes a
new key straight into the secret and prints only the public key. ring and base64 are already in
the lockfile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 14:03:03 +02:00
Ludovic MarieandClaude Opus 5.5 cef652d026 checks: verify always runs, so a skipped job can never satisfy main's required check
Labels started `checks` runs whose verify job was skipped, and GitHub counts a skipped required
check as passed: auto-merge merged #823 and #837 before the real verify finished. Labels no longer
start `checks` (the fork-only run-parity label has its own workflow), and verify has no condition.
The parity job moves into a reusable workflow both call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:58:27 +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 Marie 266aff190e Merge pull request #1005 from ludovic111/perf/spawn-structure-overrides
🤖 Agent: merged after verify passed and local parity (perf-server-12: `tools/parity.py --changed origin/main` 98/98 with the three branches together, sculk / natural_spawn / spawn_rules harnesses pass; before/after numbers in the PR). CI parity was still queued on a busy runner.
2026-10-19 13:50:35 +02:00
Ludovic Marie cde54369a0 Merge pull request #1004 from ludovic111/perf/sculk-chunk-ticks
🤖 Agent: merged after verify passed and local parity (perf-server-12: `tools/parity.py --changed origin/main` 98/98 with the three branches together, sculk / natural_spawn / spawn_rules harnesses pass; before/after numbers in the PR). CI parity was still queued on a busy runner.
2026-10-19 13:50:30 +02:00
Ludovic MarieandClaude Opus 5.5 627837e8c3 Launcher: servers hosted through horizon-server (backend and CLI)
horizon_launcher::servers keeps the list of servers and drives horizon-server
for them: create (init, then config set), start, stop, backups, settings, the
service, and the running supervisor's control API for the live status, the log
and console commands. The programs come from an installed, prepared version,
laid out as install.sh does (server-runtime/releases, current, game-files), so
horizon-server update and its rollback work on them unchanged; the game files
are a copy of the player's own prepared files.

Problems come back in plain words with the fix to offer (a busy port with a
free one, missing game files with Prepare, the EULA).

horizon-launcher servers list|create|set-up|run|forget does the same from a
terminal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:48:12 +02:00
Ludovic MarieandClaude Opus 5.5 42bb310df9 horizon-server config: read and change server.toml's settings, live where vanilla can
`config [--json]` prints the settings; `config set KEY=VALUE...` writes them through
toml_edit, checked as server.toml is checked (nothing is written if one is wrong). A
running server takes the difficulty, the default game mode and the whitelist at once
through its console, and the backup settings because the supervisor reads them each
time; the rest waits for the next start, and the command says which.

Also `backups --json` and `update --check --json`, for the launcher's Servers page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:29:51 +02:00
Ludovic Marie ddc5aaed0e Merge pull request #1008 from ludovic111/fix/java-pack-fullscreen-inputs
🤖 Agent: merged after verify passed; cloud_render, environment_render, weather_render and 923 client tests pass, with before/after screenshots of Complementary Unbound. Java shader packs get real eye brightness, custom uniforms, Iris's sun path and tilt, and clouds=off/sun/moon flags. Part of #944 (reported by brabus_); shadows and gbuffers still to come.
2026-10-19 13:24:02 +02:00
Ludovic Marie 79f0ddee2c Merge pull request #990 from ludovic111/feat/server-prune-releases
🤖 Agent: merged after the verify rerun passed (the first run's failure was a flake; horizon-server tests pass 39/39 locally) and CI parity passed. Old releases are pruned automatically after an update.
2026-10-19 13:20:54 +02:00
Ludovic Marie adb1476582 Merge pull request #989 from ludovic111/feat/carry-formations
🤖 Agent: merged after verify and CI parity passed (modpacks-10, reviewed with local harness proof in the agent's report). Carries Formations' structures.
2026-10-19 13:20:30 +02:00
Ludovic Marie f910be28c8 Merge pull request #982 from ludovic111/feat/carry-pillar-processors
🤖 Agent: merged after verify and CI parity passed (modpacks-10, reviewed with local harness proof in the agent's report). Carries the mods' pillar and fluid-fix processors in ported packs.
2026-10-19 13:20:14 +02:00
Ludovic Marie 36c4ceb723 Merge pull request #981 from ludovic111/feat/pillar-processors
🤖 Agent: merged after verify and CI parity passed (modpacks-10, reviewed with local harness proof in the agent's report). Structure pillars and fluid fixes placed outside the template, like the mods do.
2026-10-19 13:19:58 +02:00
Ludovic Marie 0231d2890c Merge pull request #1003 from ludovic111/fix/sidebar-legacy-codes
🤖 Agent: merged after verify passed and the HUD harness with new §-coded cases (34/220 on main → all pass). The sidebar applies § formatting codes like vanilla. Fixes #937.
2026-10-19 13:17:46 +02:00
Ludovic Marie 494afc8dc3 Merge pull request #1002 from ludovic111/fix/player-equip-sound
🤖 Agent: merged after verify passed and local proof (menu_click fails on main 7978/8000, passes with the fix; place_recipe passes). The player's armour equip sound and event follow every vanilla path. Fixes #986 (reported by famescare).
2026-10-19 13:17:32 +02:00
Ludovic MarieandClaude Opus 5.5 0431f12a6f Give Java packs' full-screen passes Iris's sky, eye light and custom uniforms
Complementary Unbound drew clouds and light shafts inside terrain and a
second sun because the full-screen passes got wrong inputs:

- eyeBrightness was a constant (0, 240), so packs saw open sky
  underground. It is now the block and sky light of the camera entity's
  eye block (CommonUniforms.getEyeBrightness), and eyeBrightnessSmooth
  follows it with the pack's eyeBrightnessHalflife (SmoothedFloat).
- Custom uniforms (uniform.* / variable.* in shaders.properties) were all
  0: Complementary's eyeBrightnessM, isEyeInCave, starter, framemod8 and
  the rest. horizon-shaderpack's uniforms.rs parses and evaluates them
  each frame like Iris (stareval priorities, IrisFunctions, SmoothFloat).
- sunPosition, moonPosition, shadowLightPosition, sunAngle and
  shadowAngle now follow CelestialUniforms: the sky's sun and moon angles
  turned by Y -90, the pack's sunPathRotation about Z, then the angle. The
  old sun vector pointed north-south instead of east-west.
- While a Java pack runs, sunPathRotation tilts Horizon's sun, moon and
  stars as Iris tilts vanilla's, and clouds = off | fast | fancy, sun and
  moon = false apply. Complementary and Sildur's set clouds = off; the
  vanilla clouds Horizon drew anyway wrote depth the packs' light shafts
  and fog took for terrain.

Fixes #944

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:14:48 +02:00
Ludovic Marie 572addb2ff Merge pull request #578 from Online5277/fix/classic-menu-default
🤖 Agent: merged, thanks Online5277! Ludovic decided: Classic menus by default for fresh profiles (saved choices and HORIZON_MENU_STYLE still win). We merged current main in; fmt, clippy -D warnings and 1,022 client tests pass, and verify passes. Fixes #548.
2026-10-19 13:09:39 +02:00
Ludovic MarieandClaude Opus 5.5 7c06feec74 Keep the equip list out of the place_recipe oracle's state line
VanillaPlaceRecipeOracle prints VanillaMenuOracle.state, so the equips
field the menu_click harness compares broke its lines. Only the
menu_click oracle's own lines carry it now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:48:49 +02:00
Ludovic Marie 451318860e Merge remote-tracking branch 'origin/main' into pr578 2026-10-19 12:48:48 +02:00
Ludovic MarieandClaude Opus 5.5 7426aed33c Play the equip sound when the player puts on armour
LivingEntity.onEquipItem now runs for the player wherever vanilla calls
it: the inventory menu's ArmorSlot and off-hand slot (setByPlayer, in the
menu engine, so clicks, shift-clicks, number keys, drags and LAN guests'
clicks), right-click equipping (Equippable.swapWithEquipmentSlot), the
creative screen (every changed slot, as handleSetCreativeModeSlot sets it),
the swap-hands key, dispensers (now also a shield into the off hand) and
/item and /loot on armour slots. It plays Equippable.equipSound (the
generic armour sound by default) on PLAYERS when the item lands in its own
slot, and emits equip/unequip game events for armour; nothing for
spectators, on a client, or when the item is unchanged.

The menu click harness's oracle now records vanilla's onEquipItem calls.

Fixes #986

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:43:01 +02:00
Ludovic MarieandClaude Opus 5.5 24abdb664d Apply legacy formatting codes in the scoreboard sidebar
Vanilla's Font draws every literal through StringDecomposer.iterateFormatted:
a § code styles what follows and is not drawn, a lone trailing § is
dropped. The sidebar drew owner names, team affixes, scores and the title
raw, so servers' sidebars showed § and the code letters. They now go
through Text::resolve_legacy, as the tab list already does.

The HUD harness's sidebar cases gain legacy-coded owners, affixes, titles
and fixed score formats.

Fixes #937

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:24:00 +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 85e5ba9d85 Natural spawning: look up only the structures that override spawns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:33 +02:00
Ludovic MarieandClaude Opus 5.5 24428c4f83 Sculk: ask each chunk once a tick whether its sensors tick
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 12:10:32 +02:00
Ludovic Marie 5c88c46c51 Merge pull request #1001 from ludovic111/fix/guest-projectile-owner
🤖 Agent: merged after verify passed. Remote players' projectiles record their owner: ender pearls teleport and hurt the thrower, mobs target the shooter; docs/server.md row updated; part of #484.
2026-10-19 11:52:38 +02:00
Ludovic Marie f7d2a0b311 Merge pull request #1000 from ludovic111/fix/pvp-friendly-fire
🤖 Agent: merged after verify passed. Teammates with friendly fire off no longer hurt each other on a server (lan_e2e pvp); part of #484.
2026-10-19 11:52:26 +02:00
Ludovic Marie 97993634c0 Merge pull request #999 from ludovic111/fix/guest-use-item-on
🤖 Agent: merged after verify passed. A remote player's item used on a block is used once (a snowball thrown at the ground flew 2× main hand / 3× off hand); part of #484.
2026-10-19 11:50:11 +02:00
Ludovic Marie 75573c292a Merge pull request #998 from ludovic111/fix/dead-guest-commands
🤖 Agent: merged after verify passed. Chat commands from a dead player run, as in vanilla's handleChatCommand (fixes #973); dedicated_server transcript 604/604 lines identical.
2026-10-19 11:49:58 +02:00
Ludovic MarieandClaude Opus 5.5 a480d10157 docs/server.md: what remote players get right on a dedicated server now (#484)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:12:42 +02:00
Ludovic MarieandClaude Opus 5.5 092bf87012 Server: a guest's projectiles are theirs (ender pearls take them)
A projectile a guest shot was recorded as the local player's (Who::Player), which on a
server means the host, or nobody on a dedicated server. So a guest's ender pearl
teleported the host on a LAN world and did nothing on a dedicated server, what it hit
answered to the host, and clients were told the projectile had no owner.

Projectiles now remember the guest who shot them (Projectile.guest, set when the
guest's turn ends). Clients get the guest as the owner (Projectile.getAddEntityPacket),
and the hit runs as that guest: their pearl teleports and hurts them, and the mob hit
answers to them. ThrownEnderpearl's endermite also needs monsters to spawn, and comes
where the player was.

The dedicated-server harness checks that the snowballs' owner is the player and throws
an ender pearl at the player's feet: vanilla teleports it and takes 5 health, main did
neither.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:11:44 +02:00
Ludovic MarieandClaude Opus 5.5 ca1e3a17c8 Server: a right click with an item on a block uses it once
A vanilla client sends a right click as one packet per hand and part
(Minecraft.startUseItem): UseItemOn for the block's part and ItemStack.useOn, then
UseItem for Item.use when that passed, the main hand first. The server ran the whole
click, both hands, for UseItemOn, then the item again for UseItem: a snowball thrown
at the ground flew twice, three times from the off hand.

Each packet now does its own part with its own hand
(ServerGamePacketListenerImpl.handleUseItemOn / handleUseItem): UseItemOn stops at
useOn (no throwing, drinking, eating, raising, equipping, buckets or an ender eye off a
frame), and neither touches entities (Interact does). Horizon's own client, which
counted on the server trying the off hand, now sends the off hand's packets when the
main hand's use passes, as vanilla's does. UseItem's rotation is the player's now
(absSnapRotationTo) without sending the client a teleport back.

The dedicated-server harness right-clicks the ground with a snowball in the main hand,
then in the off hand, and counts the snowballs against vanilla's (2 and 3 on main,
1 and 1 now).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:11:19 +02:00
Ludovic MarieandClaude Opus 5.5 b0c8f964b2 Server: players on one team without friendly fire don't hurt each other
Player attacks between the host and guests, or two guests, only checked the pvp game
rule. ServerPlayer.canHarmPlayer also asks the victim's team (Player.canHarmPlayer):
an ally on a team with friendlyFire false takes no damage, and the attacker hears the
no-damage sound.

lan_e2e's pvp check puts both players on such a team and checks that neither hurts
the other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:34:03 +02:00
Ludovic MarieandClaude Opus 5.5 4953305c7b Server: run a dead player's chat commands, as vanilla does (#973)
A dead guest's packets were all dropped but the respawn request, so a command typed on
the death screen never ran. ServerGamePacketListenerImpl.handleChatCommand and
handleSignedChatCommand don't look at the player's health: the commands now run.

The dedicated-server harness sends `/trigger nope` from the death screen and compares
the reply with vanilla's before respawning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:34:02 +02:00
Ludovic MarieandClaude Opus 5.5 ee3e874eb8 horizon-server: delete old releases after an update
An install kept every release it ever unpacked in releases/. Once the game is up on the
new release (or a stopped server is switched), the update now deletes the old ones,
keeping the release in use, the one `previous` names (rollback's target), the one the
running horizon-server came from, and the `updates.keep` newest of the others (1 by
default, a new server.toml setting). A release that can't be deleted is reported and
left for the next update.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:50:27 +02:00
Ludovic Marie ee59df13a9 Merge pull request #980 from ludovic111/feat/launcher-dx12-after-renderer-crash
🤖 Agent: merged after verify passed. If the game crashes natively after its Vulkan adapter line and before its first frame, the launcher switches the Graphics API setting from Auto to DX12 and tells the player; it doesn't relaunch by itself.
2026-10-19 09:46:01 +02:00
Ludovic Marie 5229857dab Merge pull request #979 from ludovic111/feat/dx12-fallback
🤖 Agent: merged after verify passed. DirectX 12 for Windows: Auto tries Vulkan first and falls back to DX12 when Vulkan has no adapter or its device request fails; Video Settings > Graphics API (Windows only) and HORIZON_GRAPHICS_API override it. We merged main in (kept #771's LOD row and lodDistance next to graphicsApi); clippy -D warnings and 1,021 client tests pass, including the WGSL→HLSL translation of every shader. Not yet run on real Windows hardware.
2026-10-19 09:45:49 +02:00
Ludovic MarieandClaude Opus 5.5 5148b2ee2c Far terrain: capped on integrated GPUs, held back on 4-thread machines
- Integrated GPUs other than Apple's draw far terrain to 128 chunks at
  most, whatever LOD Distance says; its description says so, and the
  status reports the distance in use (`lod.effective_distance`).
- On machines with four threads or fewer, far terrain starts no detail and
  one sample job per 500 ms while the near terrain loads, so the near
  chunks and the frame get the cores first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:34:32 +02:00
Ludovic MarieandClaude Opus 5.5 c468938a66 Far terrain: bounded render-thread work while tiles generate
- Upload far meshes within about 2 ms a frame (at least one); the rest
  wait, with their removals and the cutout, and apply in order.
- Gather mesh inputs across frames within about 2 ms, from a snapshot of
  the active tiles' samples, instead of hundreds at once.
- Select tiles only when the plan, samples, covered columns or dirty tiles
  changed; add the covered stand-ins only after a replan.
- Find the tiles touching an invalidated chunk per level, not by scanning
  every active tile; share tile samples with mesh jobs (Arc) instead of
  cloning them.
- Recompute the full-detail columns only when their completeness, the
  center or the radius changed.
- Observe chunks for the far summaries within about 1 ms a frame (at least
  one), unloaded ones first, with each block state's kind read once instead
  of matching block names per block.

LODs off is unchanged: none of this runs without far terrain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:34:32 +02:00
Ludovic MarieandClaude Opus 5.5 895dc26848 Far terrain: no faces for caves nothing can see into
Far tiles mesh every gap in their columns: caves and aquifers in visited and
refined chunks, and the gaps kelp and seagrass leave in water. Most are closed
on every side, so their floors, ceilings and walls cost vertices and never
show.

Before meshing, a tile fills each gap below a column's top that no
neighbouring column's open air looks into (every neighbour's top is at or
above the gap's top) with the material below it, or with the fluid above it
(kelp in water). Cave mouths stay open; unknown neighbours and cells meeting
full-detail or finer terrain count as open.

Seed 1234, RD12, LOD 64, overview: 4.93 M to 3.17 M far-terrain quads.

Follow-up to Online5277's terrain LODs (#771).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:34:32 +02:00
Ludovic MarieandClaude Opus 5.5 bf8d84930f Far terrain: no dark band at the full-detail edge, no black cliffs
Full-detail sections leaving the render distance stopped being drawn at once,
while the distant tiles still left their columns out until the next mesh job
finished: for a few seconds after moving (longer on slow machines) a hole
opened there, lined with the tiles' seal walls. Those took the light recorded
for buried blocks, none, so the hole showed as a dark band.

Lod::take_cutout hands the renderer the columns the finished meshes leave
out. Their resident sections stay drawn, even past the render distance, and
sections unloaded while still left out are released only once the tiles
cover them. With LODs off the cutout is empty and nothing changes.

Side faces above the top of the column they face open onto air. Reduced
columns and the seals expose sides that were buried, so those parts take at
least the sky light over that top (black cliffs in far tiles). Faces into a
cave keep their recorded light.

Follow-up to Online5277's terrain LODs (#771).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:34:32 +02:00
Ludovic MarieandClaude Opus 5.5 af9d888410 Far terrain: time each step of Lod::update
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 09:34:32 +02:00
Ludovic Marie 272414cc05 Merge remote-tracking branch 'origin/main' into feat/dx12-fallback
# Conflicts:
#	crates/horizon-client/src/graphics.rs
#	crates/horizon-client/src/graphics_ui.rs
2026-10-19 09:31:58 +02:00
Ludovic Marie dbfaf2e003 Merge pull request #976 from ludovic111/fix/wgpu-hal-empty-descriptor-pool
🤖 Agent: merged after verify passed and CI parity passed 322/323; the one failure, dedicated_server (two extra block updates at respawn, a server-only path this GPU change can't reach), passed 2/2 locally on main + this PR and 2/2 on main. Vendors wgpu-hal 30.0.1 with the upstream fix for gfx-rs/wgpu#9845; should fix #746 on HD 520/530, to be confirmed by the reporters on the release.
2026-10-19 09:29:37 +02:00
Ludovic Marie 1612c86c15 Merge pull request #985 from ludovic111/feat/server-bench-paper-fabric
🤖 Agent: merged after verify passed (benchmark data and docs only). Four-way equal-work benchmark: Horizon uses 3.8–4.8× less RAM and starts in ~8 s; Paper's average tick is shorter at every count, which is now perf work.
2026-10-19 08:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 8beeea55ff Carry Formations' structures
Its formations:simple becomes the game's horizon:formations (its pool and
placement), and its single pool element vanilla's with its ground_level
under horizon:ground_level. Its pieced structure (one) stays out; its
processors are still dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:35:08 +02:00
Ludovic MarieandClaude Opus 5.5 a6b183f7f5 Generate Formations' structures from the noise column
The structure context gets ChunkGenerator.getBaseColumn's blocks
(StartContext::noise_column: air, water, lava, solid or other, from the
noise's minimum Y, air past its ends), which Formations' placements test.

horizon:formations is Formations' SimpleStructure: one element of its pool
at a random spot of the chunk and rotation, at the height its placement
finds under the element's corners and centre (surface, ceiling, on water
or lava, in caves on the floor, under the ceiling, buried, on lava), as
the mod draws and checks them. Single pool elements take a ground level
(`horizon:ground_level`, 1 by default as vanilla's), which moves the piece
and its terrain adaptation as Formations' does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:34:23 +02:00
Ludovic Marie 6af77dfbe4 Merge remote-tracking branch 'origin/main' into feat/server-bench-paper-fabric
# Conflicts:
#	docs/server.md
2026-10-19 08:33:52 +02:00
Ludovic Marie 56fcc91e4b Merge pull request #967 from ludovic111/feat/site-host-a-server
🤖 Agent: merged after verify passed. The site's nav links Host a server; README gains a hosting paragraph; tools/server.py and docs/server-quickstart.txt retired.
2026-10-19 08:33:15 +02:00
Ludovic Marie aac0dc7f60 Merge pull request #966 from ludovic111/feat/server-update
🤖 Agent: merged after verify and CI parity passed. horizon-server update (SHA-256 check, world backup, switch, health wait, automatic rollback) and rollback; 6-hourly update notice in log, status and panel. Proven end to end on the Mac mini and the ThinkPad (healthy update, crash rollback, timeout rollback, manual rollback, real GitHub download); 37 unit tests.
2026-10-19 08:33:03 +02:00
Ludovic Marie fcb37f05e9 Merge pull request #965 from ludovic111/fix/server-relative-dir
🤖 Agent: merged after verify and CI parity passed. horizon-server run --dir <relative> no longer crash-loops the game; new test fails without the fix.
2026-10-19 08:32:51 +02:00
Ludovic MarieandClaude Opus 5.5 adb07c96ff Carry Better Ocean Monuments' processors and YUNG's legs
Better Ocean Monuments' ten processors (no fields; their behaviour is in
its Java) become vanilla's rule, the game's horizon:pick (one new
nextFloat per chance), horizon:flood_air, horizon:waterlog,
horizon:keep_world_block and horizon:yung_pillar; Better Witch Huts' log
and fence legs become horizon:yung_pillar. Its monuments are no longer
dry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:29:23 +02:00
Ludovic MarieandClaude Opus 5.5 d983b47db6 Carry the mods' pillar and fluid fix processors
Repurposed Structures' and Moog's pillar_processor, Repurposed's
bottom_pillar_processor and close_off_fluid_sources_processor, YUNG's
pillar_processor (Better Fortresses, Better Jungle Temples) and Better
Fortresses' stair_pillar_processor become the game's horizon:pillar,
horizon:bottom_pillar, horizon:close_off_fluid_sources,
horizon:yung_pillar and horizon:stair_pillar. A pillar's own processor
list is written in, with the Java processors in it dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:29:09 +02:00
Ludovic MarieandClaude Opus 5.5 0a733cec55 Carry: the namespaces a mod's own files name, as its manifest's namespaces
Towns and Towers ships its 60 structures under kaisyn and towns_and_towers,
not its mod id t_and_t, so carry left them outside. A namespace is now the
mod's when its own files (its id's, its tag additions, the namespaces
already claimed) name ids in it and no mod of the pack has it as its id.
Compat files for other mods and pack mods' configs stay outside; of ATM10's
42 jars only T&T claims any. A replacing tag of its own is an addition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:29:00 +02:00
Ludovic MarieandClaude Opus 5.5 303aa35a6f Flood and waterlog the structures built under the sea
Adds the game's side of Better Ocean Monuments' Java processors:

- horizon:flood_air: the template's air below sea level becomes water
  (its AirProcessor; the monuments' templates are mostly air, so they
  stood dry);
- horizon:waterlog: below sea level, blocks that can be waterlogged are,
  and water and lava on the edge of the chunk being generated are marked
  for postprocessing (its WaterlogProcessor);
- horizon:keep_world_block: a placeholder takes the world's block (its
  StructureVoidProcessor);
- horizon:pick gains `redraw` (a new nextFloat for each chance, as its
  random processors draw) and `keep_properties` (Block.withPropertiesOf,
  for its copper stairs and slabs);
- horizon:yung_pillar gains `keep_properties`, for Better Witch Huts'
  fence legs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:28:42 +02:00
Ludovic MarieandClaude Opus 5.5 df8bc603ff Content mods: namespaces besides the mod id, with no namespace shared by two mods
A content mod's manifest may list namespaces its files use besides its id
(Towns and Towers ships kaisyn and towns_and_towers under mod id t_and_t).
They are the mod's as its id is: files load, loaders read them. minecraft,
c and horizon cannot be listed. Two mods that are on owning one namespace
is an error naming both; the game keeps the first and leaves the other's
content out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:28:42 +02:00
Ludovic MarieandClaude Opus 5.5 6008be0a68 Loot: tables, modifiers, predicates, tags and recipes of every namespace
The loot loader read data/minecraft/ only, so every chest in a carried
mod's structure that names its mod's loot table opened empty. It now reads
every namespace (a vanilla root has only minecraft). Integers written as
floats (rolls 3.0, as Towns and Towers writes them) read as vanilla's int
codecs read them. 534 of the 675 non-block tables in ATM10's carried mods
now fill a chest; vanilla's chest fills are byte-identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:28:42 +02:00
Ludovic MarieandClaude Opus 5.5 ffa46a50af Place pillars and fluid fixes that reach outside the template
Adds the game's side of the mods' Java processors that write blocks around
a structure while its template is processed:

- horizon:pillar and horizon:bottom_pillar: Repurposed Structures' and
  Moog's PillarProcessor and BottomPillarProcessor, a column from a trigger
  block (or the bottom layer) down to the ground, with their nested lists
  and Moog's height randomizer;
- horizon:close_off_fluid_sources: their fluid fix, which walls off source
  blocks next to the structure's open blocks;
- horizon:yung_pillar and horizon:stair_pillar: YUNG's PillarProcessor
  (Better Fortresses, Better Jungle Temples) and Better Fortresses' stair
  supports.

They write through PlacementContext::set_chunk_block, the
ProtoChunk.setBlockState the mods call (heightmaps kept), and only inside
the chunk being generated, as the mods check WorldGenRegion's centre.
Processor lists nested in pillars are capped at 16 levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:28:42 +02:00
Ludovic MarieandClaude Opus 5.5 aec5c20698 hosting.md: rewrap the tick bullet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:25:15 +02:00
Ludovic MarieandClaude Opus 5.5 a64877f1a1 Server benchmark: Horizon against vanilla, Paper and Fabric + Lithium, equal work (2026-10-19)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:25:07 +02:00
Ludovic MarieandClaude Opus 5.5 731b4256db server_bench: turn Paper's connection throttle off (the bots share one address)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:23:27 +02:00
Ludovic MarieandClaude Opus 5.5 0001363aa0 server_bench: one tested pattern for every server's Done line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:23:27 +02:00
Ludovic MarieandClaude Opus 5.5 86cf1542d1 server_bench: Paper and Fabric + Lithium, dev-only downloads checked against their hashes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 08:23:27 +02:00
Ludovic Marie 0af3dd3900 Merge pull request #959 from ludovic111/feat/carry-yung-modifiers
🤖 Agent: merged after verify and CI parity passed, and a combined check with rdbt's #796 on current main (fmt, clippy, 359 tests, 14 world-gen harnesses). YUNG/Moog terrain adaptation around structures (Better Fortresses netherrack inside bridges 39–41 % → 2.5–7 %), its carry, and YUNG modifiers.
2026-10-19 08:10:05 +02:00
Ludovic Marie 0def8931cb Merge pull request #956 from ludovic111/feat/carry-terrain-adaptation
🤖 Agent: merged after verify and CI parity passed, and a combined check with rdbt's #796 on current main (fmt, clippy, 359 tests, 14 world-gen harnesses). YUNG/Moog terrain adaptation around structures (Better Fortresses netherrack inside bridges 39–41 % → 2.5–7 %), its carry, and YUNG modifiers.
2026-10-19 08:09:53 +02:00
Ludovic Marie 39b74fb468 Merge pull request #955 from ludovic111/feat/enhanced-terrain-adaptation
🤖 Agent: merged after verify and CI parity passed, and a combined check with rdbt's #796 on current main (fmt, clippy, 359 tests, 14 world-gen harnesses). YUNG/Moog terrain adaptation around structures (Better Fortresses netherrack inside bridges 39–41 % → 2.5–7 %), its carry, and YUNG modifiers.
2026-10-19 08:09:41 +02:00
Ludovic Marie b464f977f1 Merge pull request #796 from rdbtCVS/t3/optimize-noise-aquifers
🤖 Agent: merged, thanks rdbt! Bulk density arithmetic, the shared Beardifier kernel and Perlin column reuse: 12.685 → 10.069 ms/chunk Overworld terrain (−20.6 %, one worker). We merged current main in (density/tests.rs: kept both test sets) and checked it together with #955/#956/#959: fmt, clippy -D warnings, 359 core/world/port-pack tests, and 14 world-gen harnesses (density, noise, structure_density, server_world_gen, world_spawn, decorated_region, …) all pass.
2026-10-19 08:09:26 +02:00
Ludovic Marie c5656493cd Merge pull request #977 from ludovic111/feat/renderer-startup-steps
🤖 Agent: merged after verify passed. The renderer logs each start-up step once per device (driver, requested features and limits, surface, pipelines, first acquire/submit/present), so a native crash's last log line names the step.
2026-10-19 08:02:21 +02:00
Ludovic Marie 99b07dc810 Merge remote-tracking branch 'origin/main' into pr/796
# Conflicts:
#	crates/horizon-core/src/density/tests.rs
2026-10-19 07:58:32 +02:00
Ludovic Marie d2e8e5fe28 Merge pull request #771 from Online5277/feat/terrain-lod
🤖 Agent: merged, thanks Online5277! Terrain LOD lands off by default. With LODs off it matches main (0 differing pixels in 6 RD16 views, same FPS/GPU ms on the HD 620, same time to full render distance); CI verify and parity pass. We merged main in three times and adapted it to #905/#939 and the packed texture coordinates. The follow-ups from the review (edge band, integrated-GPU cost, lighter far meshes) we're taking on ourselves.
2026-10-19 07:49:12 +02:00
Ludovic MarieandClaude Opus 5.5 c2665ee7bd Launcher: switch the game to DirectX 12 after a Vulkan crash at its start (#746)
A native crash in a graphics driver leaves no crash report, only the game's
last lines. When the game ends with no report after its Vulkan adapter line and
before its first frame (Windows only), set graphicsApi:dx12 in its graphics
settings (the game home's and the run's own, only where it was auto) and tell
the player to press Play again, instead of the crash notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:43:12 +02:00
Ludovic MarieandClaude Opus 5.5 8aacf93d8f DirectX 12 on Windows: the fallback when Vulkan fails, and a setting (#746)
The Windows client builds wgpu's dx12 backend. With graphicsApi:auto (the
default), the renderer opens Vulkan and falls back to DirectX 12 when Vulkan
has no adapter (no Vulkan driver) or its device request fails; vulkan and dx12
pick one (horizon-graphics.txt, Video Settings > Graphics API on Windows, or
HORIZON_GRAPHICS_API; WGPU_BACKEND still overrides). Other systems keep their
one backend.

DirectX 12 keeps wgpu's indirect-draw validation: it is what passes each
draw's first instance to shaders there. Occlusion culling stays Vulkan-only and
flat draws Metal-only, as before; multi-draw and the GPU timer use DirectX 12's
own. gpu-allocator now resolves to the windows crate wgpu-hal's dx12 code
uses (0.62): with cpal's 0.54 it did not compile. A test translates every WGSL
shader to HLSL as the dx12 backend does for FXC.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:34:16 +02:00
Ludovic MarieandClaude Opus 5.5 70de685d2b Log each step of the renderer's start (#746)
A native crash in the graphics driver leaves no panic or crash report: the
last log line is all a player can send. After the adapter line, log the
driver, the features and limits the device is requested with, the surface
configuration, the pipelines (with multi-draw, occlusion culling and the GPU
timer on or off) and the first frame's acquire, submit and present, once per
device.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:27:44 +02:00
Ludovic MarieandClaude Opus 5.5 4992d877c7 Fix the Intel HD 520/530 crash at device creation (#746)
wgpu 30 creates a Vulkan descriptor pool with no pool sizes while it creates
the device. Vulkan allows that only since 1.3.215; Intel's Windows driver for
6th-10th gen integrated GPUs (igvk64.dll, HD 520/530, UHD 620/630) reads
past the empty array and crashes with 0xc0000005 inside request_device, right
after the game logs its renderer (gfx-rs/wgpu#9845). Apply the upstream fix
(gfx-rs/wgpu#10124: one sampler entry when the pool would be empty), which no
wgpu release has yet, through [patch.crates-io].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:22:12 +02:00
Ludovic MarieandClaude Opus 5.5 79f78f904b Vendor wgpu-hal 30.0.1 unchanged (examples left out)
The crates.io sources of wgpu-hal 30.0.1, minus its examples, lock file and
VCS metadata, so the next commit can apply one upstream fix that has no
release yet. Excluded from the workspace: it keeps its own lints.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 07:22:12 +02:00
Ludovic Marie 21ac62a085 Merge pull request #972 from ludovic111/perf/sculk-unload-once
🤖 Agent: merged after verify passed and local proof (sculk harness). Sculk drops every unloaded chunk's blocks in one pass: worst tick 583–609 → 38–44 ms with 40 bots.
2026-10-19 07:15:24 +02:00
Ludovic Marie cf5a979b35 Merge pull request #971 from ludovic111/perf/heightmaps-on-helpers
🤖 Agent: merged after verify passed and local proof (heightmap, chunk_stage, lan, …). Chunk-packet heightmaps are built on the warm-up threads; bytes identical; lan_chunks −11–13 %.
2026-10-19 07:15:15 +02:00
Ludovic Marie d157880712 Merge pull request #970 from ludovic111/perf/server-thread-first
🤖 Agent: merged after verify passed and local proof on the integration commit (8 harnesses, lan_e2e both modes). The server thread runs ahead of the workers on macOS and Windows, like vanilla's Thread.setPriority(8): 40 bots tick p99 101.5/77.7 → 32.8/32.7 ms, ticks over 50 ms 166/146 → 1/2.
2026-10-19 07:15:05 +02:00
Ludovic Marie f95a279a92 Merge pull request #968 from ludovic111/fix/lan-e2e-hazards-tidy
🤖 Agent: merged after verify passed. The host clears the hazards box, so a dead guest no longer leaves it behind for pvp/hurt (8/10 → 0/3 failures under --server-thread).
2026-10-19 07:10:53 +02:00
Ludovic Marie 14c6a197e4 Merge pull request #854 from Online5277/fix/multiplayer-chat-input
🤖 Agent: merged, thanks Online5277! Server chat now goes through the shared chat component, so the wheel and Page Up/Down scroll it. We merged main into the branch (main had moved the atlas to self.gui.atlas); clippy -D warnings, the 6 chat tests and the LAN chat check (both modes) pass, and verify passes. Fixes #810.
2026-10-19 07:06:30 +02:00
Ludovic Marie 9e98c1902b Merge remote-tracking branch 'origin/main' into pr854
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-19 06:40:04 +02:00
Ludovic Marie 78e8556e5a Merge pull request #800 from LookImEcho-Rick/codex/issue-592-nether-teleport
🤖 Agent: merged, thanks LookImEcho-Rick! It was still marked draft, but its description was complete and our checks were done, so we marked it ready. /execute in <dimension> now moves the player across dimensions like vanilla (fixes #592). With main merged in and the #947 fix (#962), command_exec, command_parse, server_dimension_players and dedicated_server pass locally, and verify passes. If you meant to add more, a follow-up PR is welcome.
2026-10-19 06:29:47 +02:00
Ludovic Marie 1f16677a44 Merge pull request #963 from ludovic111/feat/processor-random-and-condition
🤖 Agent: merged after verify passed and parity --changed 311/311 locally. Lithostitched apply_random, condition and set_block in the game: CTOV farms grow carrots, potatoes and beetroot, paths and mossy cobblestone appear.
2026-10-19 06:20:58 +02:00
Ludovic Marie 0ceede079e Merge pull request #964 from ludovic111/feat/carry-apply-random-condition
🤖 Agent: merged after verify passed and parity --changed 311/311 locally. Carry for Lithostitched apply_random, condition and set_block (130 dropped → 0).
2026-10-19 06:20:48 +02:00
Ludovic Marie 9ed2f482af Merge pull request #960 from ludovic111/feat/carry-processor-references
🤖 Agent: merged after verify passed and parity --changed 311/311 locally. Lithostitched reference processors carried: 83 dropped → 0.
2026-10-19 06:20:39 +02:00
Ludovic Marie 995187d82d Merge pull request #958 from ludovic111/feat/yung-modifiers
🤖 Agent: merged after verify passed and parity --changed 311/311 locally on the integration commit. YUNG modifiers, piece_in_range and dead-end pools (game side).
2026-10-19 06:20:28 +02:00
Ludovic Marie f3a3dd22bd Merge pull request #957 from ludovic111/fix/carry-json-comments
🤖 Agent: merged after verify and CI parity passed. The carry reads data files with comments as Minecraft does; six Better Fortresses pools come back (265 → 3,765 pieces over seed 1234's Nether centre).
2026-10-19 06:20:19 +02:00
Ludovic Marie cd4f8ba849 Merge pull request #954 from ludovic111/fix/stronghold-ring-biome-search
🤖 Agent: merged after verify passed and local proof on one integration commit of modpacks-9's PRs: parity --changed 311/311, structure-start oracle 3,153/3,153. CI parity's only failure was secure_chat, which this change doesn't touch. Stronghold rings 3/128 off → 0/128 on seeds 1234, 2, 3, 42, -7.
2026-10-19 06:20:09 +02:00
Ludovic Marie 874d62b49b Merge remote-tracking branch 'origin/main' into pr800 2026-10-19 05:55:51 +02:00
Ludovic Marie ee4b4c7834 Merge pull request #962 from ludovic111/fix/level-switch-entities
🤖 Agent: merged after verify and CI parity passed (command_exec 764/764 three times locally, 20 affected harnesses). /summon no longer spawns other pending entities and takes their id; the harness settle waits for entities like vanilla's. Fixes #947.
2026-10-19 05:55:41 +02:00
Ludovic MarieandClaude Opus 5.5 0bc6c689da Site: link the host-a-server page from the nav and the README; retire the Fabric server starter
tools/server.py and docs/server-quickstart.txt are gone (horizon-server replaces them);
build_release.py builds only the client starter, and the docs point to docs/hosting.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:34:34 +02:00
Ludovic MarieandClaude Opus 5.5 3ce89ebb8a horizon-server update: report a switch without an install instead of staying busy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:31:08 +02:00
Ludovic MarieandClaude Opus 5.5 bf2c5e7946 horizon-server update: wait for the old game after a rollback; document updates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:30:21 +02:00
Ludovic MarieandClaude Opus 5.5 2e730b9213 lan_e2e: tidy the hazards box from the host
The void can kill the guest before the hazards check tidies its stone box,
and the LAN server drops a dead guest's packets, so the box stayed: the
guest suffocated in it during pvp, and the hurt check's pig with it. The
host runs the tidying fills instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:28:27 +02:00
Ludovic MarieandClaude Opus 5.5 0e025df8e1 Server thread: ahead of the workers on macOS and Windows, as vanilla's spin
MinecraftServer.spin raises its server thread (Thread.setPriority(8)) when
there are more than four processors. The new horizon-threads crate makes the
same request for the dedicated server's thread and the integrated server's:
user-interactive QoS on macOS, above-normal priority on Windows, nothing on
Linux (an unprivileged thread can't raise itself).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:27:50 +02:00
Ludovic MarieandClaude Opus 5.5 857a88351f horizon-server update and rollback: switch releases, roll back by itself when the game doesn't come up
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:21:26 +02:00
Ludovic MarieandClaude Opus 5.5 a8682eb4c5 horizon-server: give the game its folders whole when --dir is relative
`horizon-server run --dir srv` (what init prints) started the game in srv/ with
--control-socket srv/.horizon/engine.json, which the game resolved inside srv/,
so it crashed at every start. spawn now passes absolute paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:20:36 +02:00
Ludovic MarieandClaude Opus 5.5 cd99c1c683 Sculk: drop the blocks of every unloaded chunk in one pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 3667bb963c Chunk batches: make the columns' heightmaps on the warm-up threads too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 05:01:18 +02:00
Ludovic Marie c569bbe291 Merge pull request #961 from ludovic111/fix/readback-alignment
🤖 Agent: merged after verify passed. GPU readbacks (occlusion results, GPU timer) no longer assume the mapping's alignment; fixes the Vega 11 launch crash in #953.
2026-10-19 04:47:48 +02:00
Ludovic MarieandClaude Opus 5.5 4b28390f72 docs/modpacks.md: Lithostitched's processors carried, and what they change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:42:29 +02:00
Ludovic MarieandClaude Opus 5.5 f62e07c36a Read GPU readbacks without assuming the mapping's alignment
A Vega 11 on Windows (Vulkan) panicked at launch in bytemuck's cast_slice
with TargetAlignmentGreaterAndInputNotAligned, starting with the release
that shipped occlusion culling. The section-test results and the GPU
timer's timestamps were cast in place from the mapped range, which isn't
guaranteed to be 4- or 8-byte aligned. Copy the words out instead.

Fixes #953

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:40:00 +02:00
Ludovic MarieandClaude Opus 5.5 bb24420df2 Carry: Lithostitched's apply_random, condition and set_block as the game's
apply_random becomes horizon:apply_random: its weighted lists, or its set
of lists at weight 1, with the inline lists carried like any other (their
Java processors dropped and counted). condition becomes horizon:condition
when the game runs every condition in it (true, all_of, any_of, not,
random_chance, matching_blocks with plain property values), with its then
and else lists carried. set_block with a simple or weighted state provider
and no block entity change becomes horizon:set_block. Anything else stays
Java and is dropped as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:29:23 +02:00
Ludovic MarieandClaude Opus 5.5 2647ed7ed0 Structure processors: Lithostitched's apply_random, condition and set_block as data
horizon:apply_random runs one processor list drawn by weight
(WeightedList.getRandom); horizon:condition runs `then` or `else` by a
test of the block (true, all_of, any_of, not, random_chance and
matching_blocks on the placed or the existing block). Both take their
random from Lithostitched's RandomSettings: seeded with the world seed
plus the name's hash, forked positionally at the block, the piece or the
structure's pivot. process_block now gets the piece's position for it.

horizon:set_block is Lithostitched's set_block with a simple or weighted
state provider: the block becomes the state (drawn at the chosen position
from the unsalted seed), keeping the properties it shares with the old one
unless preserve_state is off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:28:57 +02:00
Ludovic Marie 8993bd4594 Merge pull request #949 from rdbtCVS/t3/reuse-terrain-sampler-scratch
🤖 Agent: merged, thanks rdbt! Terrain sampler scratch buffers are reused between chunks; byte-identical output across the 22 world-gen parity harnesses on the combined commit.
2026-10-19 04:21:05 +02:00
Ludovic Marie 764b4e0803 Merge pull request #948 from rdbtCVS/t3/fix-issue-945
🤖 Agent: merged, thanks rdbt! Spectator scroll-wheel flight speed now follows vanilla (6,791 oracle cases); verified on the combined commit with local_input and the client tests. Fixes #945.
2026-10-19 04:20:53 +02:00
Ludovic Marie ea8964506c Merge pull request #946 from rdbtCVS/t3/dedup-neighbour-computation
🤖 Agent: merged, thanks rdbt! Checked on one commit with #948 and #949: fmt, workspace clippy -D warnings, 896 world/core/client tests, and 22 parity harnesses (biome, carving, decoration, density, structure_density, server_world_gen, world_spawn, …) all pass.
2026-10-19 04:20:41 +02:00
Ludovic MarieandClaude Opus 5.5 8e095b88fa Carry: write Lithostitched's processor references in
lithostitched:reference runs the processors of other lists in turn.
Carry now writes their processors in its place, in order and references
in them too, from the mod's own processor lists. A reference to a tag or
to a list the mod lacks stays, and is dropped and counted as before.
ChoiceTheorem's Overhauled Village builds most of its lists this way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:17:42 +02:00
Ludovic MarieandClaude Opus 5.5 ca790e8dba Far terrain: a third of the render-thread work per frame
Lod::update sorted every unsampled plan node each frame to take at most
four (none while every sample slot is busy), and woke the workers every
frame. It now skips the selection while the slots are full, keeps only
the best 2 * (SAMPLE_JOBS + pending) entries before sorting (a key is
listed at most twice, so the same tiles are taken in the same order),
takes the best fallback with min(), and wakes the workers only when a job
was queued. A finished chunk's detail now goes straight to the tile at
each level 0-4 holding it instead of scanning every ready tile (the others
never overlapped it).

Mac mini, RD12, LOD 256 while far terrain generates, `sample` of the main
thread over 30 s, alternating: Lod::update 10.4 % and 12.3 % of the
samples before, 3.4 % and 3.5 % after; update_world 3497 and 3373
samples before, 668 and 864 after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:08:00 +02:00
Ludovic MarieandClaude Opus 5.5 a19a6b5bcc docs/modpacks.md: YUNG's modifiers and dead-end pools, Towns and Towers' namespaces
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic MarieandClaude Opus 5.5 9a836c50a8 Docs: carried terrain kernels and what they change in seed 1234
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic MarieandClaude Opus 5.5 a906bb50ba Structures: YUNG's and Moog's enhanced terrain adaptation, as terrain kernels
YUNG's API and MoogsStructureLib add their own kernel to the Beardifier:
a size and falloff of its own, carving or burying above and below a piece's
floor, around rigid pieces and junctions. Better Strongholds buries its
stronghold, Better Fortresses carves the netherrack over its bridges and
Moog's mega fortress carves a band around its corridors. A structure or a
pool element holding `horizon:enhanced_terrain_adaptation` now gets that
kernel (horizon_core's TerrainKernel), summed after vanilla's density as
the libraries' mixins do; YUNG's also grows the start's box by its radius.
Vanilla data never has the key, so vanilla worlds are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic MarieandClaude Opus 5.5 be85b81d13 YUNG's assembler: modifiers, piece_in_range and dead-end pools
Better Nether Fortresses relies on three parts of YUNG's API the game did
not have:

- piece_in_range: another piece (of some templates, or any) inside the
  piece's box grown sideways, below and above.
- Modifiers, applied once the layout is done, in piece order and with the
  assembler's random: a condition, then actions on the piece itself.
  transform swaps its template for one picked by setFeatureSeed(x, y, x)
  and moves it by an offset turned with the piece; delay_generation moves
  it to the end of the piece list.
- Dead-end pools: a piece that got no child though it has other jigsaws
  is taken out (its parent's junction, its room and its count given back)
  and a piece of its dead-end pool is chosen for the same parent jigsaw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic MarieandClaude Opus 5.5 bb3ec12ed0 Carry: read data files with comments, as Minecraft does
Minecraft reads data JSON with Gson's lenient reader, which skips `//`,
`#` and `/* */` comments. Six of YUNG's template pools have them (Better
Fortresses' bridges, halls and hall ends; Better Jungle Temples' floors),
so carry refused them as unreadable and the structures lost those pieces:
seed 1234's seven Better Fortresses had 265 pieces in all, now 3,765, and
five Better Jungle Temples 170, now 196. A file that parses once its
comments are gone is carried as plain JSON.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic MarieandClaude Opus 5.5 cd30aae4a8 Strongholds: sample each ring search through one biome resolver
ChunkGeneratorStructureState's ring searches call findBiomeHorizontal,
which samples its 57x57 quarts in order through one resolver; vanilla's
climate index starts each lookup from the previous result and keeps it on
a tie. Horizon built a fresh resolver for every quart, so ties went the
other way: three of seed 1234's 128 strongholds were up to 6 chunks off
(the first one 96 blocks from vanilla's). StartContext::noise_biomes
samples a batch through one resolver and the search hands it the square.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic MarieandClaude Opus 5.5 8246171ae9 Carry: YUNG's dead-end pools, modifiers and piece_in_range
YUNG's single elements keep their dead-end pool and their modifiers under
horizon:yungsapi, which the game's assembler now reads: the modifier's
condition, and its transform and delay_generation actions on the piece
itself. piece_in_range carries as is. A modifier aimed at another target
than self, or another action, is counted lost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic MarieandClaude Opus 5.5 44b8aeab0a Carry: YUNG's and Moog's terrain adaptation as the game's terrain kernel
YUNG's API's `enhanced_terrain_adaptation` (its presets spelled out, NONE
as size 0) and MoogsStructureLib's (with its band) were counted lost. Carry
now writes them as `horizon:enhanced_terrain_adaptation` on the structure
and on the pool elements that have their own: YUNG's with every piece's
junctions and the start's box grown by the radius, Moog's with only the
terrain-matching pieces' junctions, as each library does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 04:06:59 +02:00
Ludovic Marie ab8d803d5c Merge pull request #952 from ludovic111/perf/heightmap-scan-from-surface
🤖 Agent: merged after verify and CI parity passed. tick_blocks finds the motion-blocking height from the top of the terrain: 1.98 → 1.36 ms per tick with 10 bots on the Mac mini (−31%).
2026-10-19 04:05:57 +02:00
Ludovic Marie 07f2913d53 Merge pull request #951 from ludovic111/fix/entity-ticks-ticking-area
🤖 Agent: merged after verify and CI parity passed. Non-living entities (TNT, falling blocks, boats, frames…) now tick only in entity-ticking chunks, as in vanilla.
2026-10-19 03:14:23 +02:00
Ludovic Marie 737841550f Merge pull request #950 from ludovic111/fix/natural-spawn-every-player
🤖 Agent: merged after verify and CI parity passed; 301/301 harnesses locally on the combination with #951/#952. Dedicated servers now spawn mobs around every player (0 → ~70 per bot, 139–144 for two, matching vanilla's caps).
2026-10-19 03:14:15 +02:00
Ludovic MarieandClaude Opus 5.5 4f992028bf Merge origin/main into feat/terrain-lod
Keeps both new generator tests (far surface sampling, worker-owned carver cache).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 03:09:16 +02:00
Ludovic MarieandClaude Opus 5.5 c0f0c17ed4 Keep restored chunk entities out of /summon's result after a level switch
Coming back to a level, the entities saved with its chunks wait in
World::loaded_entities until the next tick spawns them, as vanilla's
PersistentEntitySectionManager.processPendingLoads does. /summon pushed its
compound onto that queue and drained all of it, so the first waiting
entity took the id counted as the summon's root: after a Nether round trip
`/summon minecraft:armor_stand ...` reported "Summoned new Pig". Summon now
spawns only its own stack and leaves the queue to the tick.

The command harness's settle now waits for the entities of the chunks
around the player as well, like the oracle's settle
(areEntitiesActuallyLoadedAndTicking), and a `portal` op sends the player
through the nether portal it stands in (Entity.handlePortal:
NetherPortalBlock.getPortalDestination, teleportToPortalDestination). The
corpus's new `levels` group makes the round trip and checks the Overworld's
pig and a summon's feedback on the way back.

Fixes #947

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 03:07:17 +02:00
rdbtCVS 2fe7cb3a4b Reuse terrain sampler scratch buffers between chunks 2026-10-19 01:23:23 +01:00
rdbtCVS 0ab07f7209 Fix spectator scroll wheel flight speed 2026-10-19 01:10:17 +01:00
rdbtCVS 6a4bed4744 Coordinate concurrent neighbour cache misses by chunk key 2026-10-19 00:52:21 +01:00
Ludovic MarieandClaude Opus 5.5 c74948d24c Uploads: main's path again when no upload budget is set
The soft 4 ms yield per frame (also with Unlimited budgets) made a saved
RD32 world reach full render distance about 1 s later on the Mac mini
(streaming time after launch, 4 alternating pairs: main 1.9 s mean, yield
2.8 s; t100 from a first batch of 3 pairs: 6.1 s vs 7.1 s) without better
loading frame times (max 133-210 ms vs 31-150 ms). Without a budget every
finished mesh uploads at once again, as on main. The budgeted path keeps
this branch's fixes: only loaded chunks' sections wait, and sizes count the
24-byte terrain vertex.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:45:38 +02:00
Ludovic Marie ba3a0fcf01 Merge pull request #943 from rdbtCVS/t3/reduce-carver-cache-locks
🤖 Agent: thanks rdbt! Verify passes. A per-worker memo of the carver biome lookups in front of the shared mutex: the resolved biome per origin is a pure function of seed, dimension and position, so results can't change, and the test checks eviction leaves the terrain identical. Paired benchmarks: +3.3–3.9 % terrain throughput at six workers (16 pairs, two sites), up to +5.9 % at size 32, no single-worker regression.
2026-10-19 01:36:09 +02:00
Ludovic MarieandClaude Opus 5.5 e805e9886c Scan for the motion-blocking top from the world surface, not the build limit
getHeightmapPos(MOTION_BLOCKING) walked every column from y 319 down,
one block lookup per y. Each precipitation roll (3 in 48 per ticking
chunk per tick) and each isRainingAt paid for it: with the chunks of 10
players ticking it was most of tick_blocks. Only air lies above the
WORLD_SURFACE heightmap the chunk already keeps, and air neither blocks
motion nor holds a fluid, so the scan starts there. LevelAccess gains
world_surface (the build limit by default, so other levels scan as before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:35:44 +02:00
Ludovic MarieandClaude Opus 5.5 c755ca04f5 Tick paintings, frames, boats, armor stands, falling blocks and TNT only in entity-ticking chunks
ServerLevel.tick ticks an entity only where the chunk tickets make it
entity ticking (inEntityTickingRange, and EntityTickList holds only the
entities of entity-ticking chunks). Mobs, items and minecarts already
checked it (#931); these six entity kinds ticked wherever they were
loaded, so TNT went off and sand fell past the simulation distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:35:44 +02:00
Ludovic MarieandClaude Opus 5.5 08c77ad785 Spawn mobs around every player, and despawn from the nearest of them
Natural spawning returned without a local player, so a dedicated or
--server-thread server spawned no mobs and a LAN host spawned them only
around itself. ServerChunkCache.tickChunks now takes its spawning chunks
from every player in the level (ChunkMap.collectSpawningChunks), the
global caps from the natural spawn chunk tracker's size, and the local
caps from each player (LocalMobCapCalculator). Inhabited time and
thunder follow the same chunks (tickSpawningChunk), the wandering trader
picks a random living player (getRandomPlayer), and mobs despawn by
their distance to the nearest non-spectator of any kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:35:44 +02:00
Ludovic MarieandClaude Opus 5.5 471a8fd2f9 Merge origin/main into #800 (execute in: source dimension)
main's #880 added Source::level and Source::elsewhere for server players
teleported into another level; this branch adds Source::dimension and
moves the local player. Kept dimension (it covers what elsewhere was read
for) and level; /tp tries main's teleport_across for a server's players
first, then this branch's local-player level switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:34:25 +02:00
Ludovic Marie a9bcaa6ed5 Merge pull request #942 from ludovic111/fix/village-villagers
🤖 Agent: reviewed; verify passes. Ports removeWhenFarAway = false for Villager, WanderingTrader and AbstractGolem (iron/snow/copper golems, shulkers). Seed 1234 plains village: main 0 villagers, this PR 14, vanilla 26.3 server 14. 95/96 affected harnesses pass; world_spawn timed out on a busy machine, then 59/60 seeds alone (the one off is the known chunk-order flake #308). Fixes #917.
2026-10-19 01:31:13 +02:00
Ludovic Marie eaf6b03f86 Merge pull request #927 from ludovic111/fix/lazy-pair-recheck
🤖 Agent: reviewed; verify passes. Re-checked all four perf-server-10 PRs merged on current main (after #887 #931 #912 #916): fmt/clippy clean, horizon-client tests 893 passed, lan, dedicated_server, server_world_gen, chunk_stage, world_reopen, server_entities, server_player_targets, server_dimension_players pass, lan_e2e growth/plates/eating/hurt/chat/light pass in both modes (eating with --server-thread failed once in the long sequence, then passed twice alone). Numbers in the PR.
2026-10-19 01:24:19 +02:00
Ludovic Marie cdd55e54a5 Merge pull request #914 from ludovic111/perf/guest-tick-stats
🤖 Agent: reviewed; verify passes. Re-checked all four perf-server-10 PRs merged on current main (after #887 #931 #912 #916): fmt/clippy clean, horizon-client tests 893 passed, lan, dedicated_server, server_world_gen, chunk_stage, world_reopen, server_entities, server_player_targets, server_dimension_players pass, lan_e2e growth/plates/eating/hurt/chat/light pass in both modes (eating with --server-thread failed once in the long sequence, then passed twice alone). Numbers in the PR.
2026-10-19 01:23:58 +02:00
Ludovic Marie fdf4b16e72 Merge pull request #934 from ludovic111/perf/chunk-encode-workers
🤖 Agent: reviewed; verify passes. Re-checked all four perf-server-10 PRs merged on current main (after #887 #931 #912 #916): fmt/clippy clean, horizon-client tests 893 passed, lan, dedicated_server, server_world_gen, chunk_stage, world_reopen, server_entities, server_player_targets, server_dimension_players pass, lan_e2e growth/plates/eating/hurt/chat/light pass in both modes (eating with --server-thread failed once in the long sequence, then passed twice alone). Numbers in the PR.
2026-10-19 01:23:39 +02:00
Ludovic Marie d79b394086 Merge pull request #915 from ludovic111/perf/paletted-pack
🤖 Agent: reviewed; verify passes. Re-checked all four perf-server-10 PRs merged on current main (after #887 #931 #912 #916): fmt/clippy clean, horizon-client tests 893 passed, lan, dedicated_server, server_world_gen, chunk_stage, world_reopen, server_entities, server_player_targets, server_dimension_players pass, lan_e2e growth/plates/eating/hurt/chat/light pass in both modes (eating with --server-thread failed once in the long sequence, then passed twice alone). Numbers in the PR.
2026-10-19 01:23:19 +02:00
rdbtCVS 745c756df5 perf(worldgen): cache carver biomes per worker 2026-10-19 00:21:20 +01:00
Ludovic MarieandClaude Opus 5.5 a3f38b76aa Merge origin/main into feat/terrain-lod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:19:15 +02:00
Ludovic MarieandClaude Opus 5.5 1935cfc7e5 Translucent draws: main's order when no distant tile has water
Sections arrive back to front (visible is sorted by distance, then key), so
the merged list only needs sorting when a distant tile adds translucent
draws, and ties now break the same way as main's reversed visible order:
with LODs off the draw order and cost are exactly main's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 01:13:12 +02:00
Ludovic Marie 8fcae5043b Merge pull request #932 from ludovic111/feat/lithostitched-block-swap
🤖 Agent: reviewed; verify passes. Main merged into the stack (processors.rs tests kept from both sides; port-pack tests pass on each layer, clippy clean). Parity: all 269 game-side harnesses on modpacks-8's integration commit, structure-start oracle 35/35.
2026-10-19 01:07:45 +02:00
Ludovic Marie c1d2eddb5d Merge pull request #930 from ludovic111/feat/yung-chest-cap
🤖 Agent: reviewed; verify passes. Main merged into the stack (processors.rs tests kept from both sides; port-pack tests pass on each layer, clippy clean). Parity: all 269 game-side harnesses on modpacks-8's integration commit, structure-start oracle 35/35.
2026-10-19 01:07:21 +02:00
Ludovic Marie 3215ee942e Merge pull request #929 from ludovic111/feat/yung-placeholder-blocks
🤖 Agent: reviewed; verify passes. Main merged into the stack (processors.rs tests kept from both sides; port-pack tests pass on each layer, clippy clean). Parity: all 269 game-side harnesses on modpacks-8's integration commit, structure-start oracle 35/35.
2026-10-19 01:04:55 +02:00
Ludovic Marie cbd051a1d7 Merge pull request #926 from ludovic111/feat/carry-trial-spawners-vaults
🤖 Agent: reviewed; verify passes. Main merged into the stack (processors.rs tests kept from both sides; port-pack tests pass on each layer, clippy clean). Parity: all 269 game-side harnesses on modpacks-8's integration commit, structure-start oracle 35/35.
2026-10-19 01:04:30 +02:00
Ludovic MarieandClaude Opus 5.5 bc549713bb Merge feat/yung-chest-cap (with main) into feat/lithostitched-block-swap
processors.rs tests: keep both sides' tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:57:59 +02:00
Ludovic MarieandClaude Opus 5.5 63044f339c Merge feat/yung-placeholder-blocks (with main) into feat/yung-chest-cap
processors.rs tests: keep both sides' tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:57:52 +02:00
Ludovic MarieandClaude Opus 5.5 c3595f1a9c Merge feat/carry-trial-spawners-vaults (with main) into feat/yung-placeholder-blocks
processors.rs tests: keep #929's desert temple test and #920's rule-test test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:57:45 +02:00
Ludovic MarieandClaude Opus 5.5 03c423daac Merge origin/main into feat/carry-trial-spawners-vaults
processors.rs tests: keep both #926's trial spawner and vault tests and
#920's Repurposed rule-test test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:57:14 +02:00
Ludovic MarieandClaude Opus 5.5 3d0b76400b Merge origin/main into feat/terrain-lod
Keeps #905's single command buffer and #939's occlusion culling: distant
tiles draw in phase 1, so phase 2 stays exactly the deferred sections the
culling test reads back, and the flat path's origins include the tiles in
main's relocated upload block.

Adapts to main's APIs: block sprites and models now live in RenderAssets
(WorkerContext::render), BlockFacts behind Resources::facts(), and
terrain vertices hold the UV as 16-bit fixed point (#df58a474). Merged
distant faces repeat their sprite once per block, which the [0, 2) section
UVs cannot hold, so tile vertices now store UVs from the face's corner in
1/32-block steps (TerrainVertex::tiled, faces split at 1024 blocks) and the
instance's origin.w carries the UV step: 1/32768 for sections (decodes
exactly as before), 1/32 for tiles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 00:52:42 +02:00
Ludovic Marie 6da2963080 Merge pull request #924 from ludovic111/fix/carry-dead-vertical-distance
🤖 Agent: reviewed; verify passes. Parity: all 269 game-side harnesses on modpacks-8's integration commit (main 573f3a64 + the six game-side PRs), vanilla structure-start oracle 35/35. Merged in order #919 → #920 → #922 → #923 → #924, then the stack #926 → #929 → #930 → #932.
2026-10-19 00:52:33 +02:00
Ludovic Marie 5b9c75c017 Merge pull request #923 from ludovic111/feat/carry-small-nether-dungeon
🤖 Agent: reviewed; verify passes. Parity: all 269 game-side harnesses on modpacks-8's integration commit (main 573f3a64 + the six game-side PRs), vanilla structure-start oracle 35/35. Merged in order #919 → #920 → #922 → #923 → #924, then the stack #926 → #929 → #930 → #932.
2026-10-19 00:52:19 +02:00
Ludovic Marie d04c266edb Merge pull request #922 from ludovic111/feat/repurposed-mineshafts
🤖 Agent: reviewed; verify passes. Parity: all 269 game-side harnesses on modpacks-8's integration commit (main 573f3a64 + the six game-side PRs), vanilla structure-start oracle 35/35. Merged in order #919 → #920 → #922 → #923 → #924, then the stack #926 → #929 → #930 → #932.
2026-10-19 00:52:05 +02:00
Ludovic Marie 5f0fea0bd6 Merge pull request #920 from ludovic111/feat/carry-rule-tests
🤖 Agent: reviewed; verify passes. Parity: all 269 game-side harnesses on modpacks-8's integration commit (main 573f3a64 + the six game-side PRs), vanilla structure-start oracle 35/35. Merged in order #919 → #920 → #922 → #923 → #924, then the stack #926 → #929 → #930 → #932.
2026-10-19 00:51:52 +02:00
Ludovic Marie 0c933cc9c5 Merge pull request #919 from ludovic111/feat/structure-rule-tests
🤖 Agent: reviewed; verify passes. Parity: all 269 game-side harnesses on modpacks-8's integration commit (main 573f3a64 + the six game-side PRs), vanilla structure-start oracle 35/35. Merged in order #919 → #920 → #922 → #923 → #924, then the stack #926 → #929 → #930 → #932.
2026-10-19 00:51:38 +02:00
Ludovic Marie 8625ec35e0 Merge pull request #939 from ludovic111/perf/occlusion-cull-cheap
🤖 Agent: reviewed; verify passes. HD 620 (Vulkan/Mesa) culling off → on: RD16 +22–42 % FPS (peak unchanged, culling paused), RD32 +14–109 %; 1 % lows and CPU ms equal or better except RD32 peak's 1 % low (38.8 → 37.4). Screenshots: static views within main-vs-main noise, 21 frames after 30–40° camera jumps identical (no popping). parity section_mesh, translucency, light, hud locally; CI parity covers the rest. On only on Vulkan devices with SHADER_EARLY_DEPTH_TEST, INDIRECT_FIRST_INSTANCE and fragment-writable storage; Metal draws as before. Not yet checked on NVIDIA/AMD or Windows Vulkan: HORIZON_GPU_CULL=0 turns it off.
2026-10-19 00:39:08 +02:00
Ludovic MarieandClaude Opus 5.5 13cd89578a Terrain: occlusion culling of solid sections (Vulkan)
Sections that terrain in front hides entirely are drawn with an instance
count of 0. Phase 1 draws the sections the last test read back saw; in the
same pass every other section's box (and an eighth of phase 1's) is drawn
against phase 1's depth, front faces only, with a fragment shader forced
past an early depth test that marks the section seen. Phase 2, in a second
pass, draws what the test saw. The test is exact per pixel and needs no depth
read or Hi-Z, and every solid draw stays one entry of one list, so the CPU
records as many draws as before (Mesa unrolls a multi-draw per entry on
Intel Gen9). Culling pauses while the last result hid under a tenth of the
solid indices, with one frame in 64 testing everything.

HD 620 at RD16: +23-42% FPS in five of perf-frame-6's six views, the peak
view unchanged; RD32: +14-109%. Pixels unchanged, also on camera jumps.
Vulkan only: naga's MSL has no early fragment tests. HORIZON_GPU_CULL=0
turns it off, `all` culls every frame; `section-cull on|off` in a game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:53:53 +02:00
Ludovic Marie 25f2e2396d Merge pull request #916 from ludovic111/feat/server-headless-build
🤖 Agent: reviewed; verify passes. Release dry run on this branch (run 39167763008): linux, macos, windows builds and publish all succeed; horizon-server-linux-x86_64.tar.gz (28 MB) built and import-checked (no libasound/libudev). Agent's proof: 880/879 client tests in both builds, dedicated_server/server_entities/lan on the server build, install → init → start → bot join on the ThinkPad without the libraries.
2026-10-18 23:42:28 +02:00
Ludovic Marie 204a887ca2 Merge pull request #936 from ludovic111/feat/server-bench-equal-work
🤖 Agent: reviewed; verify passes. Equal-work server benchmark: fixed exploring speed, 300 s warm-up, chunk-work comparison per run. Horizon 1.8/4.7/11.7/23.8 ms per tick at 1/10/25/50 bots vs vanilla 11.3/34.3/50.5/stopped by its watchdog; single runs on the ThinkPad, caveats in the README.
2026-10-18 23:27:56 +02:00
Ludovic Marie 76d2985865 Merge pull request #921 from ludovic111/feat/vanilla-client-macos
🤖 Agent: reviewed; verify and parity pass. tools/vanilla_client.py runs on macOS (vsync off so a hidden window doesn't block the join); the owner's vanilla 26.3 client joined a Horizon server and stayed connected 4 min.
2026-10-18 23:25:45 +02:00
Ludovic MarieandClaude Opus 5.5 32a37e8937 Benchmark: Horizon's server against vanilla 26.3 with mixed bots, equal work
Explore bots paced at 0.8 blocks a second and a 300-second warm-up, so both servers serve the same
paths; the work table shows it. Horizon ran as horizon-engine (the server build, from its archive).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:22:07 +02:00
Ludovic Marie e293132dda Merge pull request #912 from rdbtCVS/t3/fix-grimac-false-flags
🤖 Agent: thanks rdbt! Verify passes. Checked locally merged with current main: fmt and clippy clean, cargo test -p horizon-client (885 passed), 14 harnesses including your two new oracles (player_sync, interaction_input, local_input, sprint_input, item_use, item_use_start, item_use_lifecycle, use_item_on, teleport_consume, lan, dedicated_server, server_player_combat, server_player_targets, server_entities), and lan_e2e pvp, hurt, eat, eating, clicks, chat, respawn in both modes: all pass. Fixes #903.
2026-10-18 23:12:59 +02:00
Ludovic MarieandClaude Opus 5.5 875773a08f Villagers, traders and golems never despawn for distance
Village villagers are placed from the jigsaw templates without
PersistenceRequired (the village's iron golem has it), so Mob.checkDespawn
discarded every villager of a village generated more than 128 blocks from
the player, and the rest after a while beyond 32 blocks. Vanilla's
Villager, WanderingTrader and AbstractGolem (iron, snow and copper golems,
shulkers) override removeWhenFarAway to false.

Fixes #917.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:10:31 +02:00
Ludovic Marie 18f9ae5413 Merge pull request #931 from ludovic111/fix/server-block-ticks-no-player
🤖 Agent: reviewed; verify passes. parity --changed 95/95 before the rebase; the 9 block-tick harnesses (dedicated_server byte-identical, 591 lines) and lan_e2e plates/growth/time in both modes after it; growth fails on main. Fixes #890: blocks tick from vanilla's ticket levels around every player. MSPT with 10 bots rises from ~1.6 to ~14 ms because blocks now tick at all on a server (vanilla: 40).
2026-10-18 22:52:12 +02:00
Ludovic MarieandClaude Opus 5.5 a9ad185287 Lithostitched's block swaps, with the properties both blocks have
ChoiceTheorem's Overhauled Village swaps blocks in its templates through
Lithostitched's block_swap processor (137 lists): a map of block to
block, the new one taking Block.withPropertiesOf the old state, and the
block left alone when the new one is not registered. Carry dropped it.

- Game: horizon:block_swap, the same map; the new block's default state
  with each property of the old one it has too (by name, when the value
  is one of its own).
- Carry: lithostitched:block_swap becomes it.

ATM10: processors carried 136 -> 273 of 1,427 (dropped 1,291 -> 1,154).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:45:28 +02:00
Ludovic MarieandClaude Opus 5.5 50215b484a Better Dungeons' small dungeons: one or two chests, as the mod caps them
Better Dungeons' small dungeon templates hold several chests; its Java
chest processor keeps the first, then each other one on nextFloat <= 0.2
until two have stayed, and turns the rest into cave air. Carry dropped it,
so every chest stayed.

- Game: horizon:count_cap. Of `blocks`, the first `min` a placement meets
  stay, then each stays on settings.getRandom(pos).nextFloat() <= chance
  until `max` have; the others become `replacement`. Counted per
  placement (PlaceSettings, reset in process_infos), like the mod's
  DungeonContext, set up per StructureTemplate.placeInWorld.
- Carry: betterdungeons:small_dungeon_chest_processor becomes it with the
  mod's config defaults (1, 2, 0.2; chest and trapped chest).

Seed 1234, five small dungeons decorated: 12 chests -> 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:39:03 +02:00
Ludovic MarieandClaude Opus 5.5 c7985974e7 Better Desert Temples' placeholder blocks: suspicious sand, sherds, swaps
Better Desert Temples builds its temples with placeholder blocks (orange,
red and white glass, blue and yellow concrete, red wool, polished diorite,
blank pots) that its Java processors turn into suspicious sand, sand and
sandstone, upside-down stairs, cobwebs, terracotta, TNT and pots with
sherds. Carry dropped them, so the placeholders stayed in the world.

- Game: a horizon:pick processor. A block of `block` takes the first
  pick whose `below` is past one settings.getRandom(pos).nextFloat(),
  else `default`; an outcome is a state or one of several by nextInt
  (Util.getRandom), a new block entity or the block's own, and NBT lists
  of picked strings (one float each).
- Carry: the fixed swaps become vanilla's rule; the random ones
  horizon:pick with the mod's bounds (BlockStateRandomizer's sums as
  floats). The processors that read or change the world around (pillars,
  Diorite's waterlogging) stay dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:23:18 +02:00
Ludovic MarieandClaude Opus 5.5 a0bcc13f1c Tick blocks around every player, from vanilla's chunk tickets (#890)
Without a local player (dedicated server, --server-thread) no block ticked:
tick_blocks returned early, so crops, fluids, redstone, pistons and pressure
plates stood still, and a LAN host ticked only around itself.

ticket_levels ports DistanceManager's levels: PLAYER_LOADING at 31 within the
view distance and PLAYER_SIMULATION at 31 - simulation distance for every
player in the level (the local player, LAN guests, a server's players), and
FORCED at 31. Random ticks run in simulation entity-ticking chunks that are
ticking chunks, scheduled ticks, block events and block entities in block
ticking ones, entities in entity ticking ones; the sculk blocks follow the
same rule. Saved ticks unpack when a chunk starts ticking
(startTickingChunk), not when it loads.

The hub keeps the tick-rate state current while nobody is in the game, so a
player joining a frozen server is told once (updateJoiningPlayer).

dedicated_server parity froze nothing while the client was sent its chunks:
saved fluid ticks there (a lava fall that never settles) ran before or after
the chunk was sent depending on each server's loading speed, and it failed on
main too. It now freezes the game until the client has its chunks.

lan_e2e gets a growth check: water flows and wheat grows next to a guest
1000 blocks from the host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:20:39 +02:00
Ludovic MarieandClaude Opus 5.5 96822cd5f8 Moog's trial spawners and vaults: the saved state out, the configs in
Moog's trial spawner and vault randomizing processors keep the block and
change its block entity. The templates were saved with state that breaks
them in a new world: a vault's server_data holds
state_updating_resumes_at far in the future (it never opens) and a
spawner's spawn_data an empty entity (it spawns nothing of its own).

- Game: a horizon:edit block entity modifier takes `remove`'s keys out,
  then merges `data` in. append_static now merges as CompoundTag.merge
  does (compounds in both are merged), not a shallow replace.
- Carry: each processor becomes vanilla's rule processor, one rule per
  block state (12 trial spawner, 32 vault), with horizon:edit: the
  spawner's normal and ominous configs by id, the vault's loot table and
  key (the ominous ones on ominous vaults). trial_spawner/ files get their
  mobs' 1.21.1 ArmorItems/HandItems as 26.3's equipment.

ATM10: 6 more processors carried (125 of 1,427).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:12:37 +02:00
Ludovic MarieandClaude Opus 5.5 3cd155792d Entity tracking: check a pair again only when one side moves, as vanilla's ChunkMap
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 22:11:47 +02:00
Ludovic Marie 24bcef42be Merge pull request #887 from ludovic111/fix/lan-remote-item-use
🤖 Agent: verify passes; merged with main and checked locally (see the comment above). Part of #803: players see each other eat and use items.
2026-10-18 21:59:12 +02:00
Ludovic MarieandClaude Opus 5.5 4b18b79d15 vanilla_client.py on macOS: the real vanilla 26.3 client joins Horizon's server
The tool picks this machine's natives (macOS arm64 or Linux), adds -XstartOnFirstThread on macOS
and turns vsync off there: with it on, an unseen window (a locked session) blocks in
SDL_GL_SwapWindow before the client joins. docs/server.md records the join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:46:09 +02:00
Ludovic MarieandClaude Opus 5.5 1e800099e9 Carry: vertical_distance_from_start_piece is a dead field, not a lost rule
Repurposed Structures' jungle fortress and End stronghold still write
vertical_distance_from_start_piece, but no codec of the mod (or of Moog's
Structure Lib) reads it any more: its own structures ignore it. Carry
drops it without counting it in rules_lost, which now lists only rules
the mods do run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:43:58 +02:00
Ludovic MarieandClaude Opus 5.5 e3909f4370 Carry: Better Dungeons' small Nether dungeon, as YUNG's jigsaw
betterdungeons:small_nether_dungeon is YUNG's jigsaw structure (the same
fields and YungJigsawManager assembly) behind the mod's
smallNetherDungeons config switch, which ships on. Carry maps it as it
maps yungsapi:yung_jigsaw: minecraft:jigsaw with horizon:yungsapi.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:43:45 +02:00
Ludovic MarieandClaude Opus 5.5 82c8de262f Repurposed's mineshafts: laid out under the terrain, carried as jigsaws
Repurposed Structures' 10 overworld mineshafts (birch, desert, icy,
jungle, ocean...) are its generic jigsaw with one change in
MineshaftStructure.postLayoutAdjustments: the layout is not centred on
the chunk; its top goes 15 blocks under the lowest ocean floor at the
chunk's middle and 16 blocks to each side (taking one off after each
side, as the mod does), never up, within y_allowance.

- Game: "layout": "mineshaft" in horizon:piece_limited runs that step.
- Carry: repurposed_structures:generic_mineshaft becomes minecraft:jigsaw
  with the piece-limited layout and that key, as its generic jigsaw does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:43:37 +02:00
Ludovic Marie b77891dfe0 Merge pull request #918 from ludovic111/ci/cancel-on-close
🤖 Agent: verify passes; workflow-only change (cancel a PR's unfinished runs on close).
2026-10-18 21:43:12 +02:00
Ludovic MarieandClaude Opus 5.5 41bd91f295 CI: cancel a pull request's unfinished checks when it closes
A merged or closed pull request's queued runs held the self-hosted runners
until someone cancelled them by hand. pull_request_target lets the token
cancel runs of fork pull requests too; the job checks out nothing. It
matches the branch and the head commit, so a fork's branch named like
another doesn't cancel that one's runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:35:47 +02:00
Ludovic MarieandClaude Opus 5.5 00343ad2e2 Chunk packets: pack a paletted container a long at a time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 21:21:34 +02:00
Ludovic MarieandClaude Opus 5.5 f1d5bf5209 server_bench: compare each bot count's work across servers, and flag runs that aren't equal
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 1f2f2a9f6d Chunk batches: encode each column once a tick, its new sections on warm-up threads
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:53:09 +02:00
Ludovic MarieandClaude Opus 5.5 5fcea03fb8 Carry: Repurposed's rule tests as the game's, and the chain as the iron chain
Two things kept Repurposed Structures' mineshaft processor lists from
loading in 26.3, and with them the mod's 5 Nether mineshaft pools:

- Its three rule tests written in Java. Carry renames them to the game's
  horizon:matter_phase, horizon:piece_origin_axis_aligned_linear_pos and
  horizon:y_value (213 in 75 lists), counted in the report's
  rule_tests_carried.
- 1.21.1's minecraft:chain, which 26.3 calls minecraft:iron_chain (its
  datafixers rename it in worlds). Carry renames it in recipes, loot
  tables and worldgen files: 112 files in 5 of ATM10's mods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:49:23 +02:00
Ludovic MarieandClaude Opus 5.5 d634870f15 Structures: Repurposed's matter phase, piece origin and y value rule tests
Repurposed Structures' mineshafts and Nether monument test blocks with
three rule tests of their own. Vanilla, and the game, refuse a processor
list naming a type they do not know, and with it every pool naming the
list: the 5 Nether mineshaft start pools in a ported pack never load.

The game now runs them under its own types, with the mod's fields:

- horizon:matter_phase (MatterPhaseRuleTest): solid (not air, no fluid,
  canOcclude), liquid, air, and air or liquid with rails and chains,
  optionally inverted. Structure processors now bind the block
  properties to read canOcclude.
- horizon:piece_origin_axis_aligned_linear_pos: vanilla's axis-aligned
  chance over the block's distance from its template's origin.
- horizon:y_value: the block's world height in a range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:48:34 +02:00
Ludovic MarieandClaude Opus 5.5 b18df1fd6b Merge origin/fix/lan-remote-item-use (#887) into main
Conflicts: the hub's player state keeps both main's attributes (#857) and
#887's living_flags; remote.rs and lan.rs debug JSON keep swinging and
using; the guests' model tick keeps both the swing update and the item
in use; lan_e2e.py checks unioned. remote_player_eating reads main's
Option<Consumable> (#799): any consumable with particles, its own use
time and should_emit, as the local player does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:36:43 +02:00
Ludovic Marie 573f3a64f7 Merge pull request #913 from ludovic111/integ/bl4
🤖 Agent: verify passes; local checks in the PR body. Merges bugs-lan-4's #881–#886.
2026-10-18 20:27:03 +02:00
Ludovic Marie 20cc48b48f Merge pull request #905 from ludovic111/perf/render-encode
🤖 Agent: reviewed; verify passes. Mac mini RD32 standing, 4 alternating pairs: 116.1 → 163.3 FPS, CPU 7.09 → 4.98 ms/frame. Screenshots of 6 views plus HUD and inventory: no pixel differs beyond main-vs-main noise. parity --changed 95/95 locally. No early return after the copies encoder is taken, so staged section copies can't be dropped.
2026-10-18 20:26:51 +02:00
Ludovic MarieandClaude Opus 5.5 519ec1217c Server build: the game without sound or controllers, so Linux servers need neither ALSA nor udev
horizon-client's cpal output and its controllers (gilrs, the DualSense's HID output) move behind the
audio and controllers features, on by default, so the game is unchanged. Built with
--no-default-features it links neither libasound.so.2 nor libudev.so.1. release.yml packages that
build as horizon-engine in horizon-server-linux-x86_64.tar.gz (xtask package-server, checked by
check_imports.py --server); install.sh installs it on Linux (and --archive installs a local one);
horizon-server already prefers horizon-engine. The Dockerfile uses it too, and CI lints it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 20:17:21 +02:00
Ludovic Marie def901b06f Merge remote-tracking branch 'origin/main' into integ/bl4 2026-10-18 20:09:06 +02:00
Ludovic Marie 51c49e11f4 Merge pull request #911 from ludovic111/feat/carry-repurposed-jigsaw-types
🤖 Agent: reviewed; verify passes. Parity: 298/298 harnesses on the integration commit 234bd101 (main + #907/#908/#910); vanilla structure-start oracle 35/35 with all five modpacks-7 changes. Merged in the required order #907 → #908 → #909 → #910 → #911.
2026-10-18 20:08:30 +02:00
Ludovic Marie 7a78d15c9e Merge pull request #910 from ludovic111/feat/repurposed-start-checks
🤖 Agent: reviewed; verify passes. Parity: 298/298 harnesses on the integration commit 234bd101 (main + #907/#908/#910); vanilla structure-start oracle 35/35 with all five modpacks-7 changes. Merged in the required order #907 → #908 → #909 → #910 → #911.
2026-10-18 20:08:04 +02:00
Ludovic Marie 36775db223 Merge pull request #909 from ludovic111/feat/carry-spawner-processors
🤖 Agent: reviewed; verify passes. Parity: 298/298 harnesses on the integration commit 234bd101 (main + #907/#908/#910); vanilla structure-start oracle 35/35 with all five modpacks-7 changes. Merged in the required order #907 → #908 → #909 → #910 → #911.
2026-10-18 20:07:39 +02:00
Ludovic Marie 567a68dd4f Merge pull request #908 from ludovic111/feat/spawner-mobs-processor
🤖 Agent: reviewed; verify passes. Parity: 298/298 harnesses on the integration commit 234bd101 (main + #907/#908/#910); vanilla structure-start oracle 35/35 with all five modpacks-7 changes. Merged in the required order #907 → #908 → #909 → #910 → #911.
2026-10-18 20:07:15 +02:00
Ludovic Marie 00a2ea23f0 Merge pull request #907 from ludovic111/fix/processor-ids-namespace
🤖 Agent: reviewed; verify passes. Parity: 298/298 harnesses on the integration commit 234bd101 (main + #907/#908/#910); vanilla structure-start oracle 35/35 with all five modpacks-7 changes. Merged in the required order #907 → #908 → #909 → #910 → #911.
2026-10-18 20:06:49 +02:00
rdbtCVS 4de112fe16 Fix multiplayer tick ordering and interaction target parity 2026-10-18 19:06:34 +01:00
Ludovic Marie 87cf8cb9e9 Merge pull request #904 from ludovic111/fix/server-log-lines
🤖 Agent: reviewed; verify passes. Refused logins name Server thread in latest.log; console, supervisor.log and the panel print local time.
2026-10-18 19:56:57 +02:00
Ludovic Marie 2a9d67bd68 Merge pull request #902 from ludovic111/docs/server-benchmark-mixed
🤖 Agent: reviewed; verify passes.
2026-10-18 19:52:37 +02:00
Ludovic Marie a714a9b6f8 Merge pull request #900 from ludovic111/fix/server-light-border
🤖 Agent: reviewed; verify passes.
2026-10-18 19:52:34 +02:00
Ludovic Marie e046036869 Merge pull request #898 from ludovic111/fix/server-linux-libraries
🤖 Agent: reviewed; verify passes (client --help exits 0 without loading the game, so the start check only catches a program the system can't load). Linux installs now name the missing ALSA/udev libraries; services run through the installer's current link.
2026-10-18 19:52:31 +02:00
Ludovic Marie 2d1dd54b33 Merge pull request #893 from ludovic111/fix/server-find-resources
🤖 Agent: reviewed; verify passes. Fresh installs now find game files prepared without sounds (horizon-resources.json).
2026-10-18 19:52:22 +02:00
Ludovic MarieandClaude Opus 5.5 02a6ab7429 Guest ticks: count the per-tick statistics without making strings, and date a criterion only when one is granted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:29:23 +02:00
Ludovic MarieandClaude Opus 5.5 bdc758c5e5 Server log: vanilla's thread in latest.log, and local time on the console
A dedicated server printed refused logins on its console as vanilla
does ("[Server thread/INFO]: Disconnecting <name> ..."), but wrote them
to logs/latest.log under the connection thread's own name
("[horizon-lan-conn/INFO]"): the file logger used the OS thread's name,
not the one vanilla logs the line from. The engine's lines now go to the
file under the same thread as on the console.

The console's times were UTC while logs/latest.log and vanilla's
console (Log4j's %d{HH:mm:ss}) use local time, so the two disagreed by
the time zone, as did the supervisor's own lines in
logs/supervisor.log and the web panel's console. Both now print local
time. Backup names keep their UTC time, as documented.

Found by the end-to-end install check on the Mac mini and the ThinkPad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:27:23 +02:00
Ludovic MarieandClaude Opus 5.5 40972de77a Benchmark: Horizon's server against vanilla 26.3 with mixed bots, as installed
The first published benchmark for the working version (docs/server.md
section 10, item 4). It ran the v2026.10.18.15 release, as install.sh
installs it, against vanilla 26.3 with 1, 10, 25 and 50 mixed bots. The
servers ran on the ThinkPad (quiet, under nice 19) and the bots on the
Mac mini over the tailnet. Vanilla's default game rules applied on both;
Horizon's server runs the locator bar now.

Both servers kept 20 TPS throughout. Horizon used 3.7 to 5.2 times less
memory. Its average tick was shorter with 1, 10 and 25 bots and 18 %
longer with 50, while it generated about twice the new terrain a second
(the exploring bots fly as fast as each server sends chunks). Its P99
was lower at every count.

benchmarks/server-mixed-2026-10-18/ holds the README, the report and the
machine's load; docs/server.md links it from sections 9 and 10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:26:26 +02:00
Ludovic Marie d4d46b1bb7 Merge remote-tracking branch 'origin/fix/lan-paused-clock' into integ/bl4
# Conflicts:
#	tools/lan_e2e.py
2026-10-18 19:22:08 +02:00
Ludovic Marie faba097ff2 Merge remote-tracking branch 'origin/fix/lan-swings' into integ/bl4
# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
#	tools/lan_e2e.py
2026-10-18 19:22:07 +02:00
Ludovic Marie c234641ead Merge remote-tracking branch 'origin/fix/lan-damage-events' into integ/bl4
# Conflicts:
#	crates/horizon-protocol/src/packets/play.rs
#	tools/lan_e2e.py
2026-10-18 19:17:43 +02:00
Ludovic Marie bff5121f6e Merge remote-tracking branch 'origin/fix/lan-guest-pressure-plates' into integ/bl4
# Conflicts:
#	tools/lan_e2e.py
2026-10-18 19:16:57 +02:00
Ludovic Marie 40fbae13b6 Merge remote-tracking branch 'origin/fix/lan-guest-brewing' into integ/bl4
# Conflicts:
#	crates/horizon-client/src/game/lan_crafting.rs
#	tools/lan_e2e.py
2026-10-18 19:16:56 +02:00
Ludovic Marie feb85f6d15 Merge remote-tracking branch 'origin/fix/lan-guest-machine-screens' into integ/bl4
# Conflicts:
#	tools/lan_e2e.py
2026-10-18 19:16:35 +02:00
Ludovic Marie f422cbb79a Merge pull request #901 from ludovic111/integ/sh7
🤖 Agent: Merged after the real verify passed; checks in the description.
2026-10-18 19:15:06 +02:00
Ludovic MarieandClaude Opus 5.5 ac2547c24a lan_e2e light: wait for the border chunk's light before comparing
On the server thread, lan_e2e --check light failed its border step (#891):
the guest's border chunk still had block light 0 next to glowstone placed
in the chunk past it. The server was right; the check was not.
same_light() waits until the host's light equals the guest's, but on the
server thread the host's game is a client too, and both are dark until
the light update arrives. They agreed at once, same_light returned, and
the next line read the guest's light before the update came.

The check now waits up to 100 ticks for the guest's border light, then
compares the host's with it.

With temporary logging on the server thread: the glowstone lands in a
finished chunk that isn't lit (the ring past what the guest tracks), so
the edit queues a full light job for its neighbours. The border chunk's
job then relights sections 15 to 17, and Hub::broadcast_light_changes
sends them to both clients as a tracked border chunk, as
ChunkMap.isChunkOnTrackedBorder does. Nothing in the engine changes.

Fixes #891

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 19:03:29 +02:00
Ludovic Marie b0b4414397 Merge pull request #752 from rdbtCVS/perf/biome-zoom-shared-mix
🤖 Agent: Merged. Bit-exact against the per-corner oracle (unit test over a dense grid, 7 seeds, wrapping extremes), CI parity passed, and the surface pass is about 10% faster in chunkgen_bench. Thanks rdbt!
2026-10-18 18:37:12 +02:00
Ludovic MarieandClaude Opus 5.5 80dacdba68 lan_attributes: read items through Resources::items() after #878
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:36:16 +02:00
Ludovic Marie 453ef077f4 Merge remote-tracking branch 'origin/main' into integ/sh7 2026-10-18 18:30:16 +02:00
Ludovic MarieandClaude Opus 5.5 4ea2243e4d horizon-server on Linux: say which libraries the game needs, and follow updates
On a Linux without ALSA's library (most minimal server images), the
game exits at once with the loader's "error while loading shared
libraries: libasound.so.2". The supervisor took that for a crash and
restarted it five times over two and a half minutes before giving up
with "see logs/latest.log", which holds nothing about it.

Now:
- `run` starts the game with --help first (it loads no game files) and,
  if the system can't load it, stops at once with the loader's line and
  the packages to install on Debian/Ubuntu, Fedora, Arch and NixOS;
- `init` prints the same as a warning after "Game:";
- install.sh lists the libraries `ldd` can't find, with the same advice;
- docs/server.md says so in the install section.

Also, a service installed without the installer's launcher on the PATH
ran `<dir>/releases/<tag>/horizon-server`: Linux reports the running
program with links resolved, so the unit pinned that release and an
update never reached it. The fallback now goes through `<dir>/current`
while it points at that release.

Found by the end-to-end install check on the ThinkPad (NixOS, no
nix-ld libraries) with v2026.10.18.15.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:16:14 +02:00
Ludovic Marie 73787507d1 Merge pull request #895 from ludovic111/fix/world-rest-no-timeout
🤖 Agent: Merged after the real verify passed.
2026-10-18 18:11:00 +02:00
Ludovic MarieandClaude Opus 5.5 1b141ef330 Startup: no time limit on loading the world resources
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 18:04:38 +02:00
Ludovic Marie f2a323a2d9 Merge pull request #878 from rdbtCVS/startup/combined-progressive-loading-20261018
🤖 Agent: Merged. Thanks rdbt for the consolidation and for merging main yourself! On de6f15e6: fmt, workspace clippy, horizon-client tests and the client build pass, and 19/19 covering harnesses pass (options, world_reopen, lan, dedicated_server, block_removal, section_mesh, resource_pack, menu_click, screen_layout, the pack, world-options, chat and container screens, and the consumable ones). We'll handle the two follow-ups noted in the comment above (the 120 s fatal timeout, and the backdrop terrain built on the event loop).
2026-10-18 17:52:54 +02:00
Ludovic MarieandClaude Opus 5.5 44fba3bf9d horizon-server: find game files prepared without sounds
init and run looked for asset-index.json to recognise a prepared folder,
but horizon-prepare writes that file only with the optional sounds
(`horizon-prepare sounds`). A server prepared the usual way, without
sounds, was never found: init said "no prepared game files" and listed
the folder that held them. They are now recognised by
horizon-resources.json, the manifest every preparation writes and the
one the game itself reads.

Found by installing v2026.10.18.15 with install.sh on the Mac mini and
preparing from the release folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:50:52 +02:00
Ludovic Marie 163bcdd604 Merge pull request #889 from rdbtCVS/fix/811-main-menu-debug-overlay
🤖 Agent: Merged. Matches 26.3's KeyboardHandler.keyPress: the F3 toggle isn't conditional on a world or the absence of a screen, and the title counts as an open screen so it gets no gameplay keys. Fixes #811. Thanks rdbt!
2026-10-18 17:35:02 +02:00
Ludovic Marie e7fded804e Merge remote-tracking branch 'origin/feat/flight-server-tick-ms' into integ/sh7 2026-10-18 17:23:49 +02:00
Ludovic Marie dd1fd6d3c5 Merge remote-tracking branch 'origin/fix/remote-chat-events' into integ/sh7
# Conflicts:
#	tools/lan_e2e.py
2026-10-18 17:23:49 +02:00
Ludovic Marie 56c352679d Merge remote-tracking branch 'origin/feat/server-thread-agent-commands' into integ/sh7 2026-10-18 17:23:24 +02:00
Ludovic Marie e30527c800 Merge remote-tracking branch 'origin/fix/server-respawn-repair' into integ/sh7 2026-10-18 17:23:23 +02:00
Ludovic Marie 52298d9a37 Merge remote-tracking branch 'origin/fix/server-crouch-flags' into integ/sh7
# Conflicts:
#	crates/horizon-client/src/lan/entity.rs
2026-10-18 17:23:22 +02:00
Ludovic MarieandClaude Opus 5.5 aea18f245f Renderer: one command buffer per frame, uploads before the first pass
wgpu-core ends its Metal command buffer at every pass: each render or
compute pass costs two command buffers (the pass and its barriers), each
run of copies between passes one more, and Metal creates and commits every
one on its own. The frame made about 18: the section copies in their own
encoder, the queue's pending writes for the HUD's fire animation, copies
before and after the flatten pass, and the screen effects' and HUD's
vertex uploads between later passes. At render distance 32 that cost 2-5 ms
of submit per frame, mostly waiting on the command queue's lock.

The frame now records after the section copies in their encoder, stages
the fire animation and the HUD's glyph patches through the frame's belt
instead of Queue::write_texture, and records every upload (screen
effects, HUD, flatten inputs, indirect draws) before the first pass.
The commands and their order within each pass are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:22:43 +02:00
Ludovic Marie eed2d8ddff Merge remote-tracking branch 'origin/feat/server-entity-attributes' into integ/sh7 2026-10-18 17:17:54 +02:00
Ludovic Marie f5d81e883c Merge remote-tracking branch 'origin/feat/server-player-targets' into integ/sh7 2026-10-18 17:17:53 +02:00
Ludovic MarieandClaude Opus 5.5 be161f100b Report the world's tick time in flight summaries
A `fly` benchmark replied with frame times only. With single player on its
server thread, the world's ticks no longer show in the frames, so a flight
could not tell how long the server took.

The summary now has tick_ms and tick_ms_max: the world's smoothed tick time
(the F3 server line) averaged and at its highest over the flight. On the
server thread it is the server's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 17:15:24 +02:00
Ludovic MarieandClaude Opus 5.5 159a5dfa5f LAN: pressure plates, tripwires and detector rails see the guests
Block triggers counted only the host's own player and the minecarts, so a
guest stepping on a plate did nothing. The guests in the level, not
spectators, now trigger them through Entity.checkInsideBlocks and are
counted by the plates' entity queries. On the server thread no block
ticks run at all yet (#890).

lan_e2e.py plates: a stone plate under the guest, on both sides.

Part of #848

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:59:56 +02:00
rdbtCVS de6f15e664 Merge latest main and preserve surface-height and consumable changes 2026-10-18 15:53:28 +01:00
Ludovic Marie 6c078b2852 Merge pull request #874 from ludovic111/perf/server-tick-spikes
🤖 Agent: Merged. HORIZON_SPIKES logs slow server ticks with thread CPU time and spans; without it the tick runs exactly as before.
2026-10-18 16:50:21 +02:00
Ludovic Marie 2c9bfbb70a Merge pull request #888 from ludovic111/fix/integrated-owner-movement
🤖 Agent: Merged. Fixes #861: a ctl tp/fly move is sent even while the destination chunk isn't loaded, the single-player owner is exempt from the move check as in vanilla, and wait-ready waits for the level around the player. lan, dedicated_server, server_entities and server_dimension_players pass.
2026-10-18 16:50:18 +02:00
Ludovic Marie a9bf5dc8e5 Merge pull request #873 from ludovic111/perf/spawn-surface-heightmap
🤖 Agent: Merged. Natural spawning reads a WORLD_SURFACE heightmap kept on the chunk, as vanilla: column-height lookups 333/183 → 0/3 samples, natural spawning per tick 1.36/0.78 → 0.66/0.49 ms. 9 harnesses pass (natural_spawn, spawn_rules, heightmap, chunk_generation, chunk_stage, spawner, finalize_spawn, world_spawn, raid).
2026-10-18 16:50:15 +02:00
Ludovic Marie c5b6eed173 Merge pull request #872 from ludovic111/perf/structure-start-shards
🤖 Agent: Merged. Game thread waiting on the structure-start cache lock while exploring: 194/57 → 1/0 samples; structures_reaching 260/127 → 15/50. structure_density, worldgen, chunk_stage and chunk_generation pass.
2026-10-18 16:50:12 +02:00
Ludovic Marie e0b75a86b7 Merge pull request #799 from ludovic111/fix/consume-effects-vanilla
🤖 Agent: Merged. CI parity and verify passed, and on current main with this branch, horizon-client clippy is clean and all its tests pass (873 lib + integration). Consumables now finish as in vanilla 26.3.
2026-10-18 16:48:54 +02:00
rdbtCVS 378739703b Fix F3 debug overlay toggling in the main menu 2026-10-18 15:45:06 +01:00
Ludovic MarieandClaude Opus 5.5 7a6a4d9249 Server thread: horizon-ctl tp and fly make the server load the new chunks (#861)
With the world on its server thread, a horizon-ctl `tp` or `fly` moved the
window's player to chunks the server had not sent. The client skips its
tick while its own chunk isn't loaded, so it never sent the move, and the
server kept streaming the old area: "loading terrain..." forever.

- The client sends a move made where nothing is loaded (by horizon-ctl;
  a player who walks there has already sent it), so the server streams
  the chunks there.
- The integrated server doesn't check its owner's moves, as vanilla's
  `shouldCheckPlayerMovement` (`isSingleplayerOwner`); a long `tp` was
  "moved too quickly".
- `wait-ready` on a server also waits for the level around the player
  (`PlayerLoaded`); the status shows it as `server.loaded`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:42:47 +02:00
Ludovic MarieandClaude Opus 5.5 5b47d53dbf LAN: guests use brewing stands
BrewingStandBlock opens BrewingStandMenu for a guest: the host clicks it on
the menu engine over the stand's slots (one bottle per slot, ingredient and
fuel slots, quick moves by item) and sends its four data slots
(BrewingStandBlockEntity.dataAccess). The guest opens minecraft:brewing_stand
menus, maps their slots, predicts clicks on Kind::Brewing and shows the
times. Reported by Tydragon00.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:41:07 +02:00
Ludovic MarieandClaude Opus 5.5 76e659c74b LAN: players see each other use items (eating, bows, shields)
Neither side sent LivingEntity.DATA_LIVING_ENTITY_FLAGS for players, so
no one saw another player eat, draw a bow or raise a shield. The host now
sends the item in use and its hand for itself and every guest (always:
the trackers send only the entries that changed, and stopping is a change
back to 0), and draws its guests' bodies using their items. A guest's
client starts and stops the other players' item use from the flags and
drops their eating crumbs at their mouth (Consumable.onConsumeTick); the
eating sounds already come from the server.

lan_e2e.py eating: the host and the guest each see the other eat bread,
and stop.

Part of #803

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 89cbe03ebe LAN: everyone sees players' arm swings
The host ignored ServerboundPunchPacket, so a guest mining or hitting
swung only on its own screen; only the first swing a guest's use ever
started went out, the host's own swings never did, the host drew no swing
on guests' bodies and a guest's client ignored
ClientboundSwingAnimationPacket.

The host now swings the guest's hand on a punch (handlePunch), sends each
tick's guest swings to the players around and the host's own to the
guests, and swings and ticks the guests' bodies it draws. A use the server
ran that swung goes back to the guest too: Horizon's client does not run
uses itself, so it never predicted that swing (the hoe). The guest's
client swings itself or the other entity on the packet.

lan_e2e.py swings: mining swings seen from each side, and the guest's own
hoe swing.

Part of #848

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 0bc19687aa LAN: guests see mobs flash when hit
The host told guests of a hurt mob with EntityEvent 2, which 26.3's
clients read as a kinetic hit, not a hurt. It now sends
ClientboundDamageEventPacket (ServerLevel.broadcastDamageEvent) with the
damage type and the attacker, and the guest's client restarts the hurt
flash and jolts the walk animation (LivingEntity.handleDamageEvent).

lan_e2e.py hurt: a pig the guest hits and one the host hits flash on
both sides.

Part of #848

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 a49f93f9ef LAN: guests open hoppers, dispensers and droppers
The guest's client closed every menu it had no screen for, so hoppers
(MenuType.HOPPER) and dispensers and droppers (GENERIC_3x3) did nothing.
They open as their 5 and 9 slot screens now, drawn by the menu's type
and under the server's title.

lan_e2e.py machines: each opens with its slots and keeps a stack the guest
shift-clicks in.

Part of #848

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 567cc33576 LAN: the guest's clock stops with /time pause and keeps up with the server's game time
The host sent the overworld clock's rate even while /time pause held it
(ServerClockInstance.packNetworkState sends 0 when paused), so a guest's
day kept running. A guest also moved its clock by the rounded rate each
client tick and never caught up when the server's game-time sync moved
game_time on, so a guest whose game lagged fell behind for good.
ClientClockManager.tick advances the clock by the game time passed, with
its partial tick; the guest now does the same.

lan_e2e.py time: the paused clock and a guest stalled for 4 seconds.

Part of #848

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:41:05 +02:00
Ludovic MarieandClaude Opus 5.5 164f1dfd52 Feed a remote client's chat lines to the agent's event log
A client joined to a server (and the window on the server thread) keeps the
server's chat lines in its own list. The event log only counted the local
chat's lines, so `wait-for chat` never saw a line from the server.

The remote client counts the lines it receives. The event log reads that
count and those lines while connected.

lan_e2e gets a `chat` check: a line the host sends reaches the guest's
event log, and on the server thread the host window's too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:39:37 +02:00
rdbtCVS c296578a17 Remove startup verification screenshots 2026-10-18 15:38:01 +01:00
Ludovic MarieandClaude Opus 5.5 7b5a57be7c Carry: Repurposed's Nether city, Nether shipwreck and End stronghold
They are Repurposed's generic jigsaw with a start check of their own:
carried as minecraft:jigsaw with the piece-limited layout and the
check named in horizon:start_checks.repurposed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:37:24 +02:00
Ludovic MarieandClaude Opus 5.5 f3099e8340 Start checks: Repurposed's Nether city, Nether shipwreck and End stronghold rules
Repurposed Structures' city_nether, shipwreck_nether and stronghold_end
types are its generic jigsaw with one more start check each. The
`repurposed` key of horizon:start_checks names the check:
- city_nether: no block from sea level + 20 to sea level + 45 at the
  corners of the eight chunks around;
- shipwreck_nether: no solid, filled block in a 33x30x33 grid above the start;
- stronghold_end: land at least 45 above the bottom at 70 and 35 blocks
  in each direction and at the start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:36:52 +02:00
Ludovic MarieandClaude Opus 5.5 db13bd7df5 Carry: the structure mods' spawner processors, as data the game runs
Carry used to take every processor written in Java out of its list (#822),
so spawners kept what the template has: an empty spawner in Moog's
templates, a placeholder pig in Better Dungeons'. The new processors module
rewrites the ones with a data form before that:

- Moog's Structure Lib's and Repurposed Structures' spawner randomizing
  processors become horizon:spawner_mobs, with Repurposed's rs_spawners list
  written in (weight-0 entries left out, as its loader does);
- YUNG's Better Dungeons and Better Desert Temples spawner processors (a
  spawner or a placeholder block becomes a spawner of a fixed mob) become
  vanilla's rule processor with an append_static block entity, the entity in
  26.3's shape (equipment, drop_chances);
- Moog's vanilla loot swap is a no-op while its "replace vanilla" preset is
  off, as they ship, and is taken out.

Each is counted in carry-report.json's processors_carried. The game's own
horizon: types stay in the lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:36:52 +02:00
Ludovic MarieandClaude Opus 5.5 2ebc1c44b3 Structures: horizon:spawner_mobs, the mods' spawner randomizing processors
Moog's Structure Lib and Repurposed Structures register a spawner
randomizing processor in Java: each spawner of the template gets one mob of
a weighted list, picked with settings.getRandom(pos).nextFloat(), and the
spawner NBT they write (Delay ... SpawnRange as shorts, the mob as the only
potential and the next spawn, optional custom spawn rules). With nothing to
pick the spawner becomes the replacement block.

horizon:spawner_mobs does the same from data; horizon-port-pack writes it in
place of theirs. Without it carry drops the processors, and Moog's templates
keep their spawners empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:36:46 +02:00
Ludovic MarieandClaude Opus 5.5 ae3622eb92 Processors: read rule ids without a namespace, as vanilla does
A rule processor's position predicate and block entity modifier types are
identifiers: vanilla reads "always_true" as minecraft:always_true. Horizon
matched the full string, so a list writing the short form failed, and with it
every pool naming the list. ChoiceTheorem's Overhauled Village writes
"always_true" in four village pools (plains, plains fortified, halloween), whose
houses then never generated.

The two decoders move out of Rule's decode into PosRule::decode and
Modifier::decode, which normalize the type first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:36:46 +02:00
Ludovic MarieandClaude Opus 5.5 60bc15a63e Run the agent's commands, give and time on the server thread
With single player on its server thread, the window is a client of the
server. The agent's `command` went out as the owner's chat and returned no
output, so harnesses that read a command's feedback (lan_e2e trials) failed.
Control `give` and `time` changed only the window's copy of the world.

The server now runs them as single player's console does without a server
thread (`as_console`): as the world's owner, in the owner's level. The
feedback goes to the owner's chat and comes back to the agent. `give` runs
`give @s`, and `time` sets the server's clock too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:28:48 +02:00
rdbtCVS 70c4b205ba Merge main and adapt progressive startup to current client APIs 2026-10-18 15:28:40 +01:00
Ludovic Marie 2b6ae71fb7 Merge pull request #877 from ludovic111/fix/launcher-fmt
🤖 Agent: Merged after the real verify passed (formatting only).
2026-10-18 16:25:48 +02:00
Ludovic Marie 720240feac Merge pull request #875 from ludovic111/fix/container-break-drops
🤖 Agent: Merged after #855 and the real verify. block_removal 4000/4000 including 319 mining cases; dispenser, crafter, composter, mace and use_item_on pass.
2026-10-18 16:25:33 +02:00
Ludovic MarieandClaude Opus 5.5 3a474c2060 Launcher: rustfmt modpacks.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:20:32 +02:00
Ludovic Marie 176da24f2e Merge pull request #855 from Online5277/fix/loaded-container-drops
🤖 Agent: Merged. Fixes #844: mined containers spill their contents again (block_removal 4000/4000 with #875's mining cases). Thanks Online5277!
2026-10-18 16:20:26 +02:00
Ludovic Marie f257131bde Merge pull request #858 from ludovic111/fix/prism-prepare-libraries
🤖 Agent: Merged after the real verify passed. Prism's metadata renames native jars (same sha1), so prepare now also looks under Prism's name, still checked against Mojang's sha1, with nothing downloaded. Launcher and horizon-prepare tests pass.
2026-10-18 16:20:22 +02:00
rdbtCVS 409a014ff0 Report the progressive title RSS comparison and its limits 2026-10-18 15:20:09 +01:00
rdbtCVS 55cd4e90f4 Record startup phase, early-entry and memory verification 2026-10-18 15:20:09 +01:00
Ludovic MarieandClaude Opus 5.5 fcf3a3e267 spans: test that the thread CPU clock counts work, not sleep
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:17:15 +02:00
Ludovic MarieandClaude Opus 5.5 be03fec7fc Server ticks: HORIZON_SPIKES logs slow ticks with their CPU time and spans
With HORIZON_SPIKES=MS, a dedicated or integrated server tick longer than
MS ms is logged with the server thread's CPU time over the tick and the
tick's spans (tick_mobs, streaming, ...). A tick with much more wall time
than CPU time waited for a lock or for a core.

The spans recorded on a server thread were never taken before, so they
grew for as long as the server ran with HORIZON_SPIKES set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:17:15 +02:00
Ludovic MarieandClaude Opus 5.5 e8247df5f0 Natural spawning reads a kept WORLD_SURFACE heightmap
Every spawn attempt (`NaturalSpawner.getRandomPosWithin`) asks for the
WORLD_SURFACE height of a column. Horizon scanned the column down for it
each time, a chunk-map lookup and a block-name compare per block. Vanilla
reads the chunk's heightmap.

Chunks now keep WORLD_SURFACE: built on the first read, and dropped by a
`set` that can move a column's surface (`Heightmap.update` leaves writes
two or more below it alone). Natural spawning, the spawn rules' bat check
and beacons read it.

The unused `heights` field (never written after `Chunk::empty`) is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:16:27 +02:00
Ludovic MarieandClaude Opus 5.5 20d9c4c984 Pair a respawned player again for the other players
ServerLevel.removePlayerImmediately and addRespawnedPlayer: when a player
respawns, every other player tracking it gets RemoveEntities and the next
tick pairs it again as a new entity. The hub only cleared the respawned
player's own tracker, so the others kept the dead body, lying down, wherever
the player went (the host window too, on the server thread).

The in-frame LAN host's death now reaches its guests (LivingEntity.die's
EntityEvent DEATH), and its respawn goes through the same path.

lan_e2e gets a `respawn` check: the host dies and respawns (and on the
server thread the guest too), and the other side's client draws it dead,
then alive. `status` lists the other players a client draws dead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:11:08 +02:00
Ludovic MarieandClaude Opus 5.5 62cacfa607 Mining: the block-removal harness mines blocks as a survival player
test_block_removal_parity gains a "mine" removal: VanillaDispenserOracle
calls the real ServerPlayerGameMode.destroyBlock for a survival player holding
a pickaxe, axe, shovel, sword, shears, a mace or nothing, and Horizon's
dispense fixture calls break_block, the path every player's mining takes (the
single player game, the server thread and LAN guests). It compares the drops,
the spilled contents, experience, sounds, events, statistics and the held
stack. On main it shows every mined container losing its contents (#844, fixed
by #855) and three gaps of Horizon's own, fixed here:

- ItemStack.mineBlock: an item with a tool component counts as used, and wears
  its damage_per_block (2 for swords, the mace and the trident), also the mace
  and trident, whose tool component has no rules. Shears wear on every block
  but fire (ShearsItem.mineBlock).
- DecoratedPotBlock.playerWillDestroy: a pot mined with an item in
  breaks_decorated_pots, unless an enchantment prevents it, cracks first and
  shatters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:10:10 +02:00
Ludovic MarieandClaude Opus 5.5 43223c34e2 Structure starts: a sharded cache, so the server thread stops waiting on workers
The generator's structure-start cache was one Mutex<HashMap>. Each
generated chunk reads the 289 start chunks around it for its references,
and the server thread reads the starts of the chunks its mobs and spawn
attempts are in (`structures_reaching`). While workers generated, the
server thread spent most of its start lookups waiting for that lock.

The cache is now 64 RwLock shards keyed by a hash of the chunk: a lookup
read-locks one shard. Vanilla keeps the starts on each chunk
(`StructureManager.getStartForStructure` -> `chunk.getStartForStructure`),
so its lookups share no lock either. The first starts built for a chunk
are still the ones everyone keeps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 15:58:20 +02:00
Ludovic Marie 6e09dbe10a Merge pull request #871 from ludovic111/perf/waypoint-pairs
🤖 Agent: Merged after the real verify passed. Waypoints::tick −33% (166 → 111 µs median), with byte-identical packets (530,834, same hash). locator_bar, lan, dedicated_server and server_dimension_players pass.
2026-10-18 15:50:09 +02:00
Ludovic Marie 2fd82a0c2c Merge pull request #864 from ludovic111/perf/tracking-sections
🤖 Agent: Merged. Entities::tick −37% (112–116 → 71–75 µs median per tick at 40 bots + 570 entities), with the same 14,159,154 packets before and after. 9 covering harnesses pass (server_entities, lan, dedicated_server, server_dimension_players, locator_bar and the entity ones); main has no lan/ changes since.
2026-10-18 15:46:03 +02:00
Ludovic Marie 7b8d8a604d Merge pull request #870 from ludovic111/integ/mp6
🤖 Agent: Merged after the real verify passed. Combined check on this branch: fmt, clippy, workspace tests, and the worldgen, structure_density, chunk_generation and world_spawn harnesses pass. modpacks-6's own integration passed 298/298 harnesses with the start oracle at 35/35.
2026-10-18 15:41:42 +02:00
Ludovic Marie ac287c78c5 Merge pull request #867 from ludovic111/perf/mesh-queue-columns
🤖 Agent: Merged. Mesh scheduling while flying at RD32: 0.62/0.72 → 0.36/0.22 ms per frame; at rest with ~12,000 waiting sections: 0.30–0.91 → 0.06–0.22 ms. Same checks as #865.
2026-10-18 15:36:44 +02:00
Ludovic Marie 6ad66df8d0 Merge pull request #866 from ludovic111/perf/light-dirty-sections
🤖 Agent: Merged. Light changes mark dirty sections without the per-frame sort and dedup (0.05–0.09 ms → 0). Same checks as #865.
2026-10-18 15:36:40 +02:00
Ludovic Marie 5620e4f1d1 Merge pull request #865 from ludovic111/perf/compact-terrain-vertex
🤖 Agent: Merged. Terrain vertices go 28→24 B: vertex memory −14%, Metal GPU 0.62→0.54 ms. HD 620 FPS is unchanged (vertex size isn't its bottleneck). Screenshots: the only differing pixels are on water, at main-vs-main noise level, with identical drawn sections. 95/95 covering harnesses on an integration with #866 and #867.
2026-10-18 15:36:37 +02:00
Ludovic MarieandClaude Opus 5.5 8bf73f89d4 Locator bar: look up each connection and its two players once
A moving player updates its connection with every other player, three
times a tick (updateWaypoint, then updatePlayer at the position change and
at ServerChunkCache.move), so with 40 players there are about 4800
updates a tick. Each looked the connection up twice and both players up
four to six times. Now an update finds the connection's place once and
resolves the two players once, then checks, updates or remakes it there.

The same packets go out, in the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:57:06 +02:00
Ludovic MarieandClaude Opus 5.5 b171508c0f Server: check each entity's range for every client from one compact pass
Entity tracking still asks every client about every entity each tick
(vanilla re-checks each pair whenever the player sends a move, so for
moving players it does the same), but the check is cheaper:

- each entity's position, id, range and chunk are read once a tick into
  a compact list, not from its EntityState once per client;
- each client looks up each of the tick's chunks once in the chunks it
  holds, not once per entity in it;
- an entity with no changes and no client starting to track it skips the
  loop over the clients.

The decisions and the packets are the same, in the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:49:31 +02:00
Ludovic Marie 6d238a89c5 Merge pull request #862 from ludovic111/fix/streaming-meshes-remote-lit
🤖 Agent: Merged after the real verify passed; the streaming_meshes tests pass locally with resources.
2026-10-18 14:45:55 +02:00
Ludovic MarieandClaude Opus 5.5 dd9f8a9088 Server: send a player standing up again, and draw other players' crouch
A value back at its default is left out of an entity's state, so the
tracker never told clients that a player stopped crouching or a mob stopped
burning: Entity's shared flags and pose now go back to 0. On a client of a
server (the host window on the server thread), another player's crouch flag
now reaches their model and name tag.

From #731's server-thread list; lan_e2e's walls check passed in-frame and
failed with --server-thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:43:55 +02:00
Ludovic MarieandClaude Opus 5.5 6bbcf8e3d6 streaming_meshes test: remote columns arrive lit since #824
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:39:34 +02:00
Ludovic Marie 4f9df73831 Merge pull request #860 from ludovic111/perf/save-compress-helpers
🤖 Agent: Merged after the real verify passed. Output is byte-identical (palettes / region files), and 17 covering harnesses passed with both PRs on main 256f7c20 (since then main has only #751, a settings UI change).
2026-10-18 14:10:03 +02:00
Ludovic Marie 47aaf3a015 Merge pull request #859 from ludovic111/perf/palette-ids-table
🤖 Agent: Merged after the real verify passed. Output is byte-identical (palettes / region files), and 17 covering harnesses passed with both PRs on main 256f7c20 (since then main has only #751, a settings UI change).
2026-10-18 14:09:58 +02:00
Ludovic MarieandClaude Opus 5.5 3e825eaa40 Prepare: find native jars where Prism Launcher keeps them (#849)
Prism's metadata moves a native jar's classifier into its artifact name
(com.mojang:jtracy:1.14.38:natives-windows becomes
com.mojang:jtracy-natives-windows:1.14.38) and Prism stores libraries at their
name's path, so every native jar of a Prism 26.3 install is in
com/mojang/jtracy-natives-windows/1.14.38/jtracy-natives-windows-1.14.38.jar,
not at the version JSON's path. The bytes and sha1 are Mojang's. Preparing
from a linked Prism folder stopped on the first one (jtracy on Windows).

horizon-prepare now looks there when the version JSON's path is missing; the
copy is still checked against the version JSON's sha1. The missing-file hints
no longer tell players of other launchers to install the official one.

Reported by famescare on Discord.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 13:51:33 +02:00
Ludovic Marie 831cd657de Merge remote-tracking branch 'origin/docs/modpacks-structure-libraries' into integ/mp6 2026-10-18 13:23:07 +02:00
Ludovic Marie 7dacd43706 Merge remote-tracking branch 'origin/feat/carry-piece-limited' into integ/mp6 2026-10-18 13:23:06 +02:00
Ludovic Marie 643dccfa42 Merge remote-tracking branch 'origin/feat/piece-limited-jigsaw' into integ/mp6 2026-10-18 13:23:06 +02:00
Ludovic Marie 6a5e3a836d Merge remote-tracking branch 'origin/feat/carry-yung-placements' into integ/mp6 2026-10-18 13:23:06 +02:00
Ludovic Marie d5ab26f1d9 Merge remote-tracking branch 'origin/feat/yung-placements' into integ/mp6 2026-10-18 13:23:06 +02:00
Ludovic Marie 32be8bac0a Merge remote-tracking branch 'origin/feat/carry-yung-jigsaw' into integ/mp6 2026-10-18 13:23:06 +02:00
Ludovic Marie eae7688378 Merge remote-tracking branch 'origin/feat/yung-jigsaw-game' into integ/mp6 2026-10-18 13:23:06 +02:00
Ludovic Marie 6d15bad156 Merge remote-tracking branch 'origin/fix/large-structure-templates' into integ/mp6 2026-10-18 13:23:05 +02:00
Ludovic Marie 2d8d55942c Merge remote-tracking branch 'origin/feat/carry-structure-set-additions' into integ/mp6 2026-10-18 13:23:05 +02:00
Ludovic Marie 2efa0d65b5 Merge remote-tracking branch 'origin/feat/mod-structure-set-additions' into integ/mp6 2026-10-18 13:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 bfa622bbde Server: send entity attributes to clients
ClientboundUpdateAttributesPacket, as vanilla's ServerEntity sends it: every
client-syncable instance when an entity is paired, the changed ones with the
dirty entity data, and a player's dirty set to the player itself.

- Mobs and armor stands report the instances vanilla would have created
  (movement speed, finalizeSpawn's bases and modifiers, the baby modifier,
  effects', equipment's and /attribute's), players their LiveAttributes.
- Guests get the per-tick movement speed instance and the creative reach
  modifiers (ServerPlayer.updatePlayerAttributes).
- cube::set_size sets the attribute bases as AbstractCubeMob.setSize does,
  so a summoned slime has its size's health and speed.
- server_entities compares attributes again, the player's own too, with a
  slime and a scaled pig in its scene.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 13:21:13 +02:00
Ludovic MarieandClaude Opus 5.5 0bc5f4263c save_bench: time the region writer on a saved world's chunks
Reads every chunk of a saved dimension, then times SaveDir's save_later and
flush against the same encoding and compression done on one thread, and
checks the written chunks byte for byte.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:38:04 +02:00
Cosmic 8f8db8096b Preserve non-heart block entities until normal removal 2026-10-18 06:26:49 -04:00
Ludovic MarieandClaude Opus 5.5 2ec71e5387 Mesh scheduling looks at dirty sections column by column
Sections waiting to be remeshed were one hash set that the render thread
walked section by section every frame, though every check (column loaded,
in view, its 3x3 lit, its region) holds for a whole column. At RD 32 about
12,000 sections wait at the edge of the lit area at any time. They are now
kept per column (a bit per section index), so each frame looks at each
column once and meshes all of its ready sections together.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:23:32 +02:00
Ludovic MarieandClaude Opus 5.5 6bf479ec0b Light changes dirty sections by section, not cell by cell
Relighting block edits and relit chunks collected every changed cell,
sorted and de-duplicated them, then marked the sections around each cell
for remeshing. Vanilla's light engine reports changed sections to
LevelRenderer instead. LightChanges now keeps, per changed section, which
of 27 border classes (low face, inside, high face per axis) its changed
cells fall in; that gives exactly the same dirty sections and light log
without keeping or sorting the cells.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:23:32 +02:00
Cosmic 323002cf98 Fix multiplayer chat scrolling through the shared chat component 2026-10-18 06:20:57 -04:00
Ludovic MarieandClaude Opus 5.5 df58a474fb Terrain vertices in 24 bytes: the UV as 16-bit fixed point
TerrainVertex goes from 28 to 24 bytes: the UV becomes two u16 in 32768
steps per unit, which hold model UVs (multiples of 1/16 and their halves)
exactly and decode exactly in terrain.wgsl. The position stays a float:
fixed point moves the edges of offset plants and rotated elements by a
fraction of a pixel.

Section vertex memory drops 14% (RD 16: 380 -> 325 MB, arena 503 -> 369 MB);
pixels are unchanged on Metal and Vulkan outside animated water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:09:07 +02:00
Ludovic Marie e508a5ce1d Merge pull request #751 from LookImEcho-Rick/codex/issue-582-settings-search
🤖 Agent: Merged. CI parity passed 94 of 95. The one miss, tooltip_display, passes locally on main with this branch, and so does options. Thanks LookImEcho-Rick!
2026-10-18 12:04:59 +02:00
Ludovic MarieandClaude Opus 5.5 d1db8c4a54 Region writer: encode and compress a batch's chunks on helper threads
The writer thread encoded (NBT) and zlib-compressed every chunk of a batch
one after another before writing them. A batch of at least 8 chunks is now
shared out to scoped helper threads (at most half the hardware threads),
which take the next chunk as they finish one. The results are put back in
submission order and written exactly as before, so the region files are
byte-identical: each chunk's bytes depend only on the chunk, and sector
allocation follows the same write order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:01:28 +02:00
Ludovic MarieandClaude Opus 5.5 fdee6576c6 Paletted containers: build palettes without hashing
palette_ids (behind PalettedContainer::pack, every saved and sent section,
and from_values, every generated one) scanned the palette for each of the
4096 values while it had up to 16 entries, then hashed every value with
SipHash. A run of one value is now matched once, and past 16 entries a
thread-local table indexed by value (block states and biomes are below
65536) replaces the hash map; only the palette's entries are set, and they
are cleared before returning. The output is unchanged: palette in
first-occurrence order and the same ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:01:12 +02:00
Ludovic MarieandClaude Opus 5.5 1b6a3cf535 Docs: modpacks, the structure libraries Horizon now runs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 11:41:27 +02:00
Ludovic Marie 256f7c200a Merge pull request #842 from ludovic111/perf/waypoint-lookups
🤖 Agent: Merged. Waypoints::tick is 3.5× cheaper with the same packets in the same order. Same checks as #841.
2026-10-18 11:36:36 +02:00
Ludovic Marie 96a1e1cb24 Merge pull request #841 from ludovic111/perf/tracker-dirty-data
🤖 Agent: Merged. tick_lan is 40–55% cheaper and MSPT about 20% lower (40 bots, ~600 entities). The agent ran 95/95 harnesses (--changed) with #842 on main 4b43242a. The orchestrator then ran clippy and the lan tests on current main with both: clean.
2026-10-18 11:36:33 +02:00
Ludovic Marie 798249a212 Merge pull request #781 from rdbtCVS/t3code/2a9d6cd1
🤖 Agent: Merged. light_chunk is 28% faster, light parity shows 0 differing values against vanilla 26.3, and CI parity passed on a merge with main after #824's light changes. Thanks rdbt!
2026-10-18 11:36:23 +02:00
Cosmic dde52bc1e9 Merge remote-tracking branch 'origin/main' into feat/terrain-lod
# Conflicts:
#	crates/horizon-client/src/gfx.rs
2026-10-18 05:31:37 -04:00
Cosmic fde95ff20e Build distant terrain from sparse seed samples and layered voxel columns 2026-10-18 05:30:56 -04:00
Ludovic Marie f0c7df2000 Merge pull request #836 from LookImEcho-Rick/codex/falling-block-collision-parity
🤖 Agent: Merged. Reviewed against vanilla: landing comes from Entity.move's vertical collision, the landing position is blockPosition(), velocity is zeroed per axis, and placement goes through Level.isUnobstructed with the collision shape. Locally on this head, fmt and clippy are clean, and contextual_collision, level_place, placement, powder_snow_collision and world_movement all PASS. Thanks LookImEcho-Rick!
2026-10-18 11:16:57 +02:00
Ludovic MarieandClaude Opus 5.5 512962e3ca Carry: Repurposed Structures' and Moog's jigsaws keep their layout rules
Their jigsaw structures, the Nether variants too, get a
horizon:piece_limited key for the game's piece-limited assembler:
y_allowance, burying_type, cannot_spawn_in_liquid, the pools whose
fallbacks ignore the bounds, the Nether variants' land search and ledge,
and each structure's piece counts from the mod's rs_pieces_spawn_counts
or msl_pieces_spawn_counts files. use_bounding_box_hack becomes vanilla's
use_expansion_hack. Lithostitched's jigsaw stays vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 11:11:40 +02:00
Ludovic MarieandClaude Opus 5.5 aab43c46ae Carry: YUNG's placements, and the vanilla structures its mods turn off
YUNG's enhanced random spread becomes vanilla's random spread with its
enhanced exclusion zone as horizon:exclusion_zone; the desert and jungle
temples' placements add horizon:avoid_biomes (no ocean or river within 48
blocks of the chunk's middle at y 120); Better Strongholds' adds
horizon:ring_sections. A YUNG's structure the game can start gets
horizon:disables with the vanilla type its mod's mixin turns off; Better
Mineshafts, a Java structure type, leaves vanilla's mineshafts in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 11:10:58 +02:00
Ludovic MarieandClaude Opus 5.5 f57c59a971 Structures: Repurposed Structures' and Moog's piece-limited jigsaw assembler
A jigsaw structure with "horizon:piece_limited" is laid out as
PieceLimitedJigsawManager does (the same code in both mods): a fresh
random seeded by the chunk, the start piece drawn uniformly and moved to
the surface at its center, a volume 120 blocks down and 180 up, children
drawn by weight from the pools' entries in order, pieces capped per
template and some required (retried up to 40 times), pools whose
fallbacks ignore the bounds, y_allowance clipping. After the layout, the
structure's postLayoutAdjustments: the pieces centred on the chunk corner,
then buried (lowest corner, average land, lowest side) or, for the Nether
variants, moved onto the highest or lowest land or a fixed height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 11:08:03 +02:00
Ludovic Marie 845a122faa Merge pull request #758 from rdbtCVS/perf/portal-section-mask
🤖 Agent: Merged. CI parity passed 88 of 89 harnesses. The one miss, dedicated_server (a known intermittent failure), passes locally on this head. Thanks rdbt!
2026-10-18 11:01:13 +02:00
Ludovic MarieandClaude Opus 5.5 54bae6131b feat(server): commands act on other players (names, UUIDs, @a/@p/@r/@s)
EntitySelector.findPlayers and EntityArgument's player targets on a
hosted world or dedicated server: every guest other than the one in place
is Target::Guest, found by name, UUID or selector, and what a command does
to them (give, gamemode, tp, kill, effect give/clear, data) runs with
their state swapped in, their own messages going to their chat. The
console, operators and command blocks can all target them; execute
as/at reads their position, rotation and eye height; a teleport into
another level (execute in, the console's Overworld, a destination in
another level) moves them there.

as_guest splits into enter_guest/leave_guest so that guest turns nest,
and a guest's commands run with the hub back in self.lan (queued per
packet), so they reach the other players (and /kick works for ops).
Item and advancement names and player names in feedback take vanilla's
component shape; /kill on a guest sends vanilla's death message.

Harness: tools/test_server_player_targets_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:39:02 +02:00
Ludovic MarieandClaude Opus 5.5 4db511079d Locator bar: update each connection in place, without clones
ServerWaypointManager's per-tick work for every moving player copied each
connection out of its row (its icon's style string too), scanned the row
again to put it back, copied the lists of players and waypoints it walked,
and looked players up through SipHash. Connections are now updated where
they are (the same row order and the same packets), the lists are walked
by index and the maps use FxHash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:28:17 +02:00
Ludovic MarieandClaude Opus 5.5 c63fa74239 Server: track each entity once for all its clients, as vanilla's ServerEntity
Every guest had its own copy of what it was last told about every entity,
so each tick compared each entity's data and built and encoded its update
packets once per guest. A level now keeps one ServerEntity state per
entity (ChunkMap.entityMap), whose changes are worked out once a tick,
encoded once and sent to every guest tracking it; a guest's Tracker is the
set of entities it is paired with. The update cadence is the entity's own
tickCount, as vanilla's, and late clients are paired with the data the
others hold. The per-tick copy of every entity state, the id lists and the
packet lists are gone too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:28:16 +02:00
Ludovic MarieandClaude Opus 5.5 c7c9725942 Structures: YUNG's placements and their vanilla counterparts turned off
Three keys a carried mod's data can hold, which vanilla data never has:
- "horizon:ring_sections" on a random spread (BetterStrongholdsPlacement):
  starts only in the odd rings around the origin, with Java's int math.
- "horizon:avoid_biomes" (BetterDesertTemplePlacement and
  BetterJungleTemplePlacement): no start when one of the biomes is found
  around the chunk's middle block, as findBiomeHorizontal samples it.
- "horizon:disables" on a structure: every structure of the listed types
  fails to start, as YUNG's DisableVanilla*Mixin makes
  ChunkGenerator.tryGenerateStructure return false; in a set of several
  structures the next one is tried, as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:22:22 +02:00
Ludovic Marie 4b43242a61 Merge pull request #835 from ludovic111/perf/decoration-order-grid
🤖 Agent: merged: decoration_order::vanilla_order on flat arrays (RD32 8.14→1.06 ms per call; ticket order unchanged, checked against the old version). Combined on current main with #834/#835: clippy, client + world tests PASS.
2026-10-18 10:21:16 +02:00
Ludovic Marie 9a71700d51 Merge pull request #834 from ludovic111/perf/section-upload-belt
🤖 Agent: merged: section uploads through one reused staging buffer and one encoder, as vanilla's SectionRenderDispatcher (RD32 exploring: FPS 43.5→56.0, CPU 23.7→18.6 ms/frame, p99 122→106 ms; sections drawn identical in 12 views). Combined on current main with #834/#835: clippy, client + world tests PASS.
2026-10-18 10:21:11 +02:00
Ludovic Marie 6fff54b95d Merge pull request #837 from ludovic111/fix/tooltip-display-harness-bools
🤖 Agent: merged the tooltip_display harness fix (lowercase case flags; harness PASS).
2026-10-18 10:11:18 +02:00
Ludovic MarieandClaude Opus 5.5 9dd896bfc3 tooltip_display harness: write the case flags in lowercase
Rust's bool parser takes only true/false, so the fixture failed on Python's
True/False; Java's Boolean.parseBoolean ignores case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:10:57 +02:00
LookImEcho-Rick 7ddb4ddae5 Fix falling block collisions with partial blocks 2026-10-18 03:02:55 -05:00
Ludovic MarieandClaude Opus 5.5 4676ea2819 Carry: YUNG's jigsaws and pieces with their rules
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:54:51 +02:00
Ludovic MarieandClaude Opus 5.5 3c08f74d79 Structures: YUNG's API's jigsaw assembler for carried structures
A jigsaw structure with "horizon:yungsapi" (min_y/max_y) assembles the way
YUNG's API's JigsawManager does: the start piece by priority then weight
from the shuffled raw entries, children breadth-first, pool or fallback per
depth, and per-element rules from the element's "horizon:yungsapi": a name
capped by max_count, a depth range, a priority, ignore_bounds and a
condition (true, false, not, all_of, any_of, depth, altitude,
random_chance, rotation, piece_in_horizontal_direction).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:54:51 +02:00
Ludovic MarieandClaude Opus 5.5 571066158e Structure templates read without a node cap, as vanilla does
Vanilla reads structure templates with NbtAccounter.unlimitedHeap()
(TemplateSource.readStructure). Horizon used the default 1,000,000-node
cap, so a template past it loaded as empty: YUNG's Better Ocean
Monuments' start piece (115x64x126 blocks) never started. Limits::templates
keeps the 256 MiB byte cap and drops the node cap, for worldgen and /place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:54:31 +02:00
Ludovic Marie c1b4ccd571 Merge pull request #824 from ludovic111/perf/chunk-light-sync
🤖 Agent: merged: LAN guests and the server-thread game keep each chunk's light and get vanilla light updates (travel CPU −17%, light_chunk −35%; lan_e2e light PASS with and without --server-thread).
2026-10-18 09:46:11 +02:00
Ludovic Marie f845e26050 Merge pull request #829 from ludovic111/perf/anim-tick-hash
🤖 Agent: merged: block-entity animation list rebuilt only when drawn block entities change (anim tick −82% with 32,768 chests). perf-ticks-7's four PRs together on main d85a3603: clippy, 861 client + world tests, 19 harnesses (light, lan, dedicated_server, save_roundtrip, world_reopen, disk, datafix ×6, world, server_world_gen, block_entity ×4, chunk_stage) PASS.
2026-10-18 09:45:28 +02:00
Ludovic Marie 5177fb3601 Merge pull request #828 from ludovic111/perf/brewing-ticker
🤖 Agent: merged: brewing stands tick in the level's single ticker list, in vanilla's order. perf-ticks-7's four PRs together on main d85a3603: clippy, 861 client + world tests, 19 harnesses (light, lan, dedicated_server, save_roundtrip, world_reopen, disk, datafix ×6, world, server_world_gen, block_entity ×4, chunk_stage) PASS.
2026-10-18 09:45:24 +02:00
Ludovic Marie c717628169 Merge pull request #827 from ludovic111/perf/region-in-place
🤖 Agent: merged: chunks saved in place in their region file, as vanilla (save into a full region 80.3→7.1 ms). perf-ticks-7's four PRs together on main d85a3603: clippy, 861 client + world tests, 19 harnesses (light, lan, dedicated_server, save_roundtrip, world_reopen, disk, datafix ×6, world, server_world_gen, block_entity ×4, chunk_stage) PASS.
2026-10-18 09:45:19 +02:00
Ludovic Marie e93c99a4c7 Merge remote-tracking branch 'origin/main' into fix/pr751-merge
# Conflicts:
#	crates/horizon-client/src/options_horizon.rs
2026-10-18 09:44:42 +02:00
Ludovic MarieandClaude Opus 5.5 8901a49d0b Upload section meshes through a reused staging belt
Queue::write_buffer creates a staging buffer for every call: on Metal a
kernel call to make the buffer, then page faults while the mesh is copied
into fresh memory. While exploring at render distance 32 the render thread
made two such writes per uploaded section, plus one for its flat-draw ids.

Section meshes and flat ids now go through their own StagingBelt (4 MiB
mapped buffers, reused once the GPU has read them) into one encoder that
the frame's submission runs ahead of its own commands. A page of the
section arena that grows records its copy into that encoder too, so copies
recorded before the grow reach the grown buffer. A frame that is not drawn
still submits the copies, and more than 64 MiB of them are submitted early.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:40:55 +02:00
Ludovic MarieandClaude Opus 5.5 817a71ffa5 Clients keep the light the server sends; the server sends light updates
A remote client (a LAN guest, or the game on its server thread) threw away
the light in each LevelChunkWithLight and relit the column and its
neighbours itself. It now applies the sky and block layers as
ClientPacketListener.applyLightData does, and relights only block changes,
as vanilla's client does.

The hub now sends ClientboundLightUpdatePacket for the light sections that
changed in a column to the players for whom it is on the tracked border
(ChunkHolder.broadcastChanges), so light from blocks a guest doesn't track
reaches it.

horizon-ctl gains 'light X1 Y1 Z1 X2 Y2 Z2', and lan_e2e a 'light' check
comparing the guest's light with the host's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 09:39:50 +02:00
Ludovic Marie d85a3603bd Merge pull request #761 from rdbtCVS/t3code/9960489f
🤖 Agent: merged rdbt's paletted container access (BlockSection::set 1.12x; identical palette, counts and NBT; CI parity green).
2026-10-18 09:33:45 +02:00
Ludovic MarieandClaude Opus 5.5 d8f81f7a98 Carry: lithostitched set and pool additions, and CTOV's villages
Lithostitched's add_structure_set_entries and add_template_pool_elements
modifiers become horizon:add_to files under worldgen/<kind>/horizon_added/,
upgraded as the mod's own pools are. Their load conditions (lithostitched's
predicate, NeoForge's, else Fabric's) are checked against the mods ported
along; other modifier types and tag targets are counted, not carried.

ChoiceTheorem's Overhauled Village adds its villages and outposts to
vanilla's sets in Java code; carry writes them with its default config
(small, medium and large villages weighted 10, 4 and 1, outposts 1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 08:56:01 +02:00
Ludovic MarieandClaude Opus 5.5 1ccc41c8b2 Content mods: add to other structure sets and pools (horizon:add_to)
A content mod's structure set or template pool file holding
"horizon:add_to" appends its entries to the sets or pools it names
(vanilla's, another mod's) instead of being one of its own, as
lithostitched's add_structure_set_entries and add_template_pool_elements
modifiers do on NeoForge and Fabric. The content root merges them after
every mod's own files, in mod and file order; a name nobody defines is
skipped. Vanilla data never has the key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 08:56:01 +02:00
Ludovic Marie 1d937524a9 Merge pull request #826 from ludovic111/docs/modpacks-structures
🤖 Agent: merged: docs/modpacks.md. Combined check of main + modpacks-5's six: clippy, tests, worldgen structure_density block_properties registry_fluid chunk_stage PASS.
2026-10-18 08:53:03 +02:00
Ludovic Marie 6da2d8ebcc Merge pull request #825 from ludovic111/perf/facts-registry-stream
🤖 Agent: merged: facts.json streamed into the block registry (registry peak 816→153 MB at ATM10 size). Combined check of main + modpacks-5's six: clippy, tests, worldgen structure_density block_properties registry_fluid chunk_stage PASS.
2026-10-18 08:52:58 +02:00
Ludovic Marie 3415246998 Merge pull request #820 from ludovic111/feat/structure-start-checks
🤖 Agent: merged: Repurposed and Moog jigsaw start rules (Repurposed 1→13 starts, MVS 829→90, failing pools 65→0, vanilla oracle 35/35). Combined check of main + modpacks-5's six: clippy, tests, worldgen structure_density block_properties registry_fluid chunk_stage PASS.
2026-10-18 08:52:53 +02:00
Ludovic Marie c741bde15f Merge pull request #822 from ludovic111/feat/carry-drop-java-processors
🤖 Agent: merged: Java-only processors dropped and counted (1,427). Combined check of main + modpacks-5's six: clippy, tests, worldgen structure_density block_properties registry_fluid chunk_stage PASS.
2026-10-18 08:52:48 +02:00
Ludovic Marie 166b7f8d05 Merge pull request #821 from ludovic111/feat/carry-structure-rules
🤖 Agent: merged: carry writes the mods' structure start rules and Moog's spacing. Combined check of main + modpacks-5's six: clippy, tests, worldgen structure_density block_properties registry_fluid chunk_stage PASS.
2026-10-18 08:52:44 +02:00
Ludovic Marie 73c726759e Merge pull request #816 from ludovic111/fix/carry-worldgen-block-states
🤖 Agent: merged: worldgen block states rewritten to 26.3's format (1,005 files in 17 mods). Combined check of main + modpacks-5's six: clippy, tests, worldgen structure_density block_properties registry_fluid chunk_stage PASS.
2026-10-18 08:52:39 +02:00
Ludovic Marie eb3f2b94ca Merge pull request #831 from ludovic111/feat/secure-chat
🤖 Agent: merged secure chat as vanilla 26.3's server (chat sessions, signed chat and commands, enforce-secure-profile; secure_chat harness identical to vanilla offline and online; 7 server harnesses pass).
2026-10-18 08:40:05 +02:00
Ludovic Marie dc206d6132 Merge pull request #830 from ludovic111/fix/server-thread-view-distance
🤖 Agent: merged: the integrated server follows the window's render distance past 32 (fixes #804), and the lan_e2e hazards check now finds the guest by name (the server-thread hunger gap was a test bug).
2026-10-18 08:39:19 +02:00
Ludovic MarieandClaude Opus 5.5 84d3d2a9a0 Rerun CI (flaky options_ui test, #802)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 08:14:28 +02:00
Ludovic MarieandClaude Opus 5.5 a2363a3bf6 fix(server-thread): the integrated server follows the render distance, past 32 too
IntegratedServer.tickServer: the server takes the window's render and
simulation distances when they change ("Changing view distance to N, from
M"), tells every player (SetChunkCacheRadius, SetSimulationDistance), and
each player's chunks follow, up to what they asked for. The integrated
server no longer clamps the view distance to 32, and a remote client sends
its render distance again when it changes (Options.broadcastOptions),
past 32 too. Fixes #804.

lan_e2e: a distance check (--server-thread), and the guest is found by
name in lan status, where the server thread lists the owner too: the
hazards check read the owner's food (the "no hunger from moving" gap).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 08:09:49 +02:00
Ludovic MarieandClaude Opus 5.5 8f3d35ab6d Block entity animations: read the drawn block entities only when they change
tick_block_entity_anims rebuilt a map of every drawn block entity, sorted
it and swept the animation states against it every tick. The renderer now
keeps a revision that changes when its block entities do, and the sorted
ticker list is rebuilt only then. The animation states use FxHashMap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:57:10 +02:00
Ludovic MarieandClaude Opus 5.5 454b3842bb feat(server): secure chat, as vanilla 26.3's server
Chat session updates with their profile key checked against the services'
keys, signed chat and the message chain, last-seen acknowledgements, the
signature cache, signed say/me/msg, enforce-secure-profile, and vanilla's
log lines. Offline servers and players without a chat session chat
unsigned, as on vanilla. tools/test_secure_chat_parity.py compares both
servers with test keys and a mock services server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:52:46 +02:00
Ludovic MarieandClaude Opus 5.5 05517a167f docs/modpacks.md: structure carry-over now decodes and starts where the mod would; facts.json streams
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:40:41 +02:00
Ludovic MarieandClaude Opus 5.5 d0d5e79f1a Structures: honour a carried mod's start and placement rules
Repurposed Structures' and Moog's jigsaws check biomes and terrain height in
a radius and refuse to start in liquid; their spreads keep a minimum
distance from the world origin and away from several other sets. Carry maps
them to vanilla's jigsaw and random spread and writes those rules under
horizon: keys; the game now runs them (start_checks.rs, the spread's
min distance and exclusion sets). Vanilla data has none of the keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:38:59 +02:00
Ludovic MarieandClaude Opus 5.5 65732406fc Carry: take processors written in Java out of their lists
The game, like vanilla, refuses a processor list holding a type it does not
know, and with it every pool naming the list, so those structures did not
generate at all. Carry now takes the library types out of processor lists
(files and inline ones in pool elements) and counts them by type in the
carry report: the structures generate, undecorated. On ATM10's carried
mods: 1,427 processors of 146 types; pool decode failures 31 -> 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:38:10 +02:00
Ludovic MarieandClaude Opus 5.5 f72c8674ad Carry: the start and placement rules of mapped jigsaws
Repurposed Structures' and Moog's jigsaws and spreads add rules on top of
vanilla's; mapped to vanilla they were lost, and Moog's spreads structures
1.65 times as far apart as its files say, so MVS alone started 9x as often
as on NeoForge. Carry now writes the start rules under
horizon:start_checks, the minimum distance and the super exclusion zone's
sets (from the mod's structure set tags) under horizon: placement keys,
and scales Moog's spacing. Layout rules (piece clipping, burying) are still
lost and counted in the carry report's rules_lost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:38:10 +02:00
Ludovic MarieandClaude Opus 5.5 7d27950e31 Carry: write worldgen block states in 26.3's shape
1.21.1 writes a block or fluid state in worldgen JSON as {"Name", "Properties"};
26.3's codec reads {"id", "properties"} and refuses the old shape, so every
processor list, feature or pool holding one failed to decode. Carry now
rewrites them. On ATM10's carried mods: 1,005 files in 17 mods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:38:09 +02:00
Ludovic MarieandClaude Opus 5.5 9190d14b7f Block registry: read facts.json as it streams
The game and the assets step each parsed facts.json into a JSON tree, then
copied each state into an NBT tree, before building the registry. With a
pack's 214,000 states each step peaked at 816 MB for a 139 MB registry.
The registry now deserializes the rows it needs straight from the file
(each row's state and its air and fluid columns, and the defaults):
registry peak 816 -> 153 MB, assets peak 816 -> 235 MB, RSS after the
assets step 1,344 -> 442 MB. Vanilla: 138 -> 27 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:35:28 +02:00
Ludovic Marie 5b62d771bb Merge pull request #823 from ludovic111/integ/mp-sh
🤖 Agent: merged modpacks-4's #753–#765 and server-host-5's #759–#790, combined on main and checked locally (clippy, workspace tests, 9 harnesses).
2026-10-18 07:25:41 +02:00
Ludovic MarieandClaude Opus 5.5 2932704156 Region files: write each saved chunk in place, as vanilla's RegionFile
Saving a chunk read every chunk of its region file and wrote them all to a
new file. The writer now writes only the saved chunk, into the first free
run of sectors (RegionBitmap), points the header at it and then frees its
old sectors (RegionFile.write / clear), with one flush per region and batch.
Readers and the writer share one open file per region. A chunk queued again
before the writer reached it is written once, by its newest batch
(IOWorker.pendingWrites).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:19:55 +02:00
Ludovic MarieandClaude Opus 5.5 b540ccb642 Brewing stands tick in the level's block entity ticker list
Brewing stands were the last server ticker with a pass of its own over the
whole block entity map every tick. They now tick in their place in
Level.blockEntityTickers, as BrewingStandBlock.getTicker makes them: in
list order, only in block-ticking range and inside the world border, with
their bottle states written as each one ticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 07:17:44 +02:00
Ludovic Marie c2f3d01ee4 Merge remote-tracking branch 'pr/779' into integ/mp-sh
# Conflicts:
#	mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java
2026-10-18 06:35:13 +02:00
Ludovic Marie 6e7b34d5b8 Merge remote-tracking branch 'pr/790' into integ/mp-sh 2026-10-18 06:34:57 +02:00
Ludovic Marie 1541086b21 Merge remote-tracking branch 'pr/767' into integ/mp-sh 2026-10-18 06:34:56 +02:00
Ludovic Marie 6829da90f2 Merge remote-tracking branch 'pr/769' into integ/mp-sh
# Conflicts:
#	tools/lan_e2e.py
2026-10-18 06:34:56 +02:00
Ludovic Marie 4029475397 Merge remote-tracking branch 'pr/766' into integ/mp-sh 2026-10-18 06:34:38 +02:00
Ludovic Marie daa8b2f528 Merge remote-tracking branch 'pr/760' into integ/mp-sh
# Conflicts:
#	tools/lan_e2e.py
2026-10-18 06:34:26 +02:00
Ludovic Marie e6f0384dda Merge remote-tracking branch 'pr/759' into integ/mp-sh 2026-10-18 06:34:07 +02:00
Ludovic Marie 8a1515b64f Merge remote-tracking branch 'pr/765' into integ/mp-sh 2026-10-18 06:34:07 +02:00
Ludovic Marie 85fa29ee7a Merge remote-tracking branch 'pr/764' into integ/mp-sh 2026-10-18 06:34:06 +02:00
Ludovic Marie 319343992e Merge remote-tracking branch 'pr/763' into integ/mp-sh 2026-10-18 06:34:05 +02:00
Ludovic Marie f683d359a2 Merge remote-tracking branch 'pr/762' into integ/mp-sh 2026-10-18 06:34:04 +02:00
Ludovic Marie 0fbb7bdd0b Merge remote-tracking branch 'pr/756' into integ/mp-sh 2026-10-18 06:34:04 +02:00
Ludovic Marie 8d0ec947a3 Merge remote-tracking branch 'pr/755' into integ/mp-sh 2026-10-18 06:34:03 +02:00
Ludovic Marie 092dc7c641 Merge remote-tracking branch 'pr/754' into integ/mp-sh 2026-10-18 06:34:02 +02:00
Ludovic Marie 14f58d85e0 Merge remote-tracking branch 'pr/753' into integ/mp-sh 2026-10-18 06:34:02 +02:00
Ludovic Marie 2ac362f1d3 Merge pull request #813 from ludovic111/perf/dedicated-no-mesh
🤖 Agent: merged: no meshing on a dedicated server. Combined check of main + perf-ticks-6's six: clippy, tests, 8 harnesses PASS.
2026-10-18 06:33:51 +02:00
Ludovic Marie b63debb376 Merge pull request #793 from ludovic111/perf/creaking-heart-scan
🤖 Agent: merged: creaking heart scan only in sections whose palette holds a heart (240→3 ms per 15 s). Combined check of main + perf-ticks-6's six: clippy, tests, 8 harnesses PASS.
2026-10-18 06:33:46 +02:00
Ludovic Marie 2e2f4a6581 Merge pull request #809 from ludovic111/fix/ask-server-suggestions
🤖 Agent: merged: ask_server suggestions. Combined check of main + perf-ticks-6's six: clippy, tests, 8 harnesses PASS.
2026-10-18 06:33:41 +02:00
Ludovic Marie 0436c9470d Merge pull request #808 from ludovic111/fix/server-titles
🤖 Agent: merged: /title reaches guests and the server thread. Combined check of main + perf-ticks-6's six: clippy, tests, 8 harnesses PASS.
2026-10-18 06:33:36 +02:00
Ludovic Marie 4fb261fd30 Merge pull request #807 from ludovic111/fix/guest-death-health
🤖 Agent: merged: dead guest gets health 0. Combined check of main + perf-ticks-6's six: clippy, tests, 8 harnesses PASS.
2026-10-18 06:33:32 +02:00
Ludovic Marie e19803aa62 Merge pull request #806 from ludovic111/perf/chunk-ticket-levels
🤖 Agent: merged: vanilla FULL ticket square per server player (RD32 finished chunks 8809→4761, spawn CPU 153→98 s). Combined check of main + perf-ticks-6's six: clippy, tests, 8 harnesses PASS.
2026-10-18 06:33:27 +02:00
Ludovic Marie 5f49a85a4a Merge pull request #817 from ludovic111/perf/surface-rules
🤖 Agent: merged the shared ore-vein richness sample (surface −8% alone, −14% with #801; byte-identical, 19 worldgen harnesses pass).
2026-10-18 06:22:08 +02:00
Ludovic Marie 999c532a57 Merge pull request #801 from rdbtCVS/t3/optimize-surface-generation-upstream
🤖 Agent: merged rdbt's constant surface material-rule branches (surface −6%; byte-identical on four dimension/seed hashes, 19 worldgen harnesses pass; review in the thread).
2026-10-18 06:18:08 +02:00
Ludovic MarieandClaude Opus 5.5 cf97a5e1dc Decoration order: dense grids instead of hash maps
vanilla_order replays vanilla's ticket scheduler over every chunk in view
each time the player crosses into another chunk, on the render thread. At
render distance 32 that is about 200,000 level updates in SipHash maps:
8 ms per call. Over dense grids covering the square it can touch, the
same replay takes 1 ms. A test checks the order against the hash map
version for several centres and radii.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 05:44:08 +02:00
Ludovic MarieandClaude Opus 5.5 65fc3b9f94 Content mods: structure mods' worldgen joins the worldgen bundles
A content mod's data/<ns>/worldgen/ files were copied into the content root,
where nothing reads them: the worldgen loader reads bundles. The build now
adds a mod's structures, structure sets, template pools, processor lists and
configured and placed features to their bundles, its data/<ns>/structure/
templates to worldgen/structure/<ns>/, and its biome tags to the biome tag
bundle. The loader refuses a biome tag naming what it lacks (another mod's
biomes, a loader's convention tags), so those entries are dropped, as they are
from block and item tags. Biomes, noise and density functions stay out: they
would change vanilla's terrain.

Structure and placement types Horizon does not have stay unsupported, as
before; carry-over maps the common mod ones next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 05:24:48 +02:00
Ludovic MarieandClaude Opus 5.5 6f7096b396 Ore veins: sample shared richness once, index the volume without division
26.3's copper and iron vein rules both bind minecraft:overworld/ore_vein/richness
eagerly, so the material stage sampled the same root over the same chunk volume
twice. Bindings of one compiled root now share its buffer; sampling is
deterministic, so the values are identical.

The two vein density reads per solid block went through
DensityVolume::index_of_block, which divides and takes the remainder by the
step on each axis. The stage's volumes are unit-step, so index them directly
(falling back for strided volumes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 05:00:16 +02:00
Ludovic MarieandClaude Opus 5.5 ff6e01de03 Command suggestions: ask the server for nodes the tree marks ask_server
A remote client's chat now sends ServerboundCommandSuggestionPacket for an
argument node whose provider is minecraft:ask_server (bossbar ids, data
storage, datapacks, team names, functions...), as ClientSuggestionProvider.customSuggestion
does. The suggestions are done when ClientboundCommandSuggestionsPacket
answers that id; its completions join the local ones (Suggestions.merge).
Replies to older requests are dropped.

The remote tree takes each node's provider from the packet only: a local
provider the server didn't send stood in for the server's before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 04:57:29 +02:00
rdbtCVS 0b5f0a3edf Document 1 2 4 and 8 worker scaling measurements 2026-10-18 03:56:31 +01:00
Ludovic MarieandClaude Opus 5.5 0f482a45c8 Dedicated server: never mesh sections
A dedicated server draws nothing, like the integrated server, yet its
streaming still meshed every section it lit (Streaming::mesh followed only
options.integrated). It now skips meshing too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 04:49:06 +02:00
rdbtCVS c47b30fcf0 Optimize binary density, Beardifier and Perlin sampling 2026-10-18 03:49:02 +01:00
Ludovic Marie 9f7bd91109 Merge pull request #789 from ludovic111/perf/entity-culling
🤖 Agent: merged vanilla shouldRender culling for boats, paintings, item frames, armor stands, TNT, tridents and fireworks (~525 entities: FPS 107→141; only entities beyond vanilla's range disappear).
2026-10-18 04:41:28 +02:00
Ludovic MarieandClaude Opus 5.5 e8eaaf59d7 Rerun CI (flaky options_ui test, #802)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 04:35:23 +02:00
Ludovic MarieandClaude Opus 5.5 20fada25bd Rerun CI now that the PR is ready for review
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 04:27:55 +02:00
Ludovic Marie e627842ede Merge pull request #788 from ludovic111/perf/occlusion-async
🤖 Agent: merged cave culling on a worker thread (RD32 prepare 10.3→1.0 ms, p99 275→208 ms, FPS 116→135; drawn sections identical in all 18 views; 89/89 harnesses).
2026-10-18 04:12:15 +02:00
rdbtCVS 4cfa6a507c Document incremental surface optimization measurements 2026-10-18 03:11:34 +01:00
rdbtCVS d370364f12 Specialize constant material rule branches after binding 2026-10-18 03:11:34 +01:00
rdbtCVS 594b502780 Remove generated noise benchmark artifacts from PR 2026-10-18 03:10:26 +01:00
LookImEcho-Rick c1211b2245 Fix execute source dimension for player teleports 2026-10-17 21:08:53 -05:00
Ludovic MarieandClaude Opus 5.5 c12f5cf41a Server titles: /title reaches the guest, and its HUD shows and clears them
TitleCommand sends the targeted player ClientboundSetTitleText,
SetSubtitleText, SetActionBarText, SetTitlesAnimation and ClearTitles. A
guest's /title (the window's own player on the server thread too) only
changed the server's HUD; it is now sent to the guest. The remote client
showed titles and action bars as a bare line and ignored subtitles, times
and clearing; it now feeds the HUD's titles as ClientPacketListener does
(the action bar of a SystemChat overlay too) and ticks them (Gui.tick).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 04:00:48 +02:00
Ludovic MarieandClaude Opus 5.5 65d5a35cd2 Server: a dead guest is sent its health of 0, as ServerPlayer.doTick does
The hub held SetHealth back once a guest died, so the guest's health
stayed at its last positive value under the death screen. ServerPlayer
sends it every tick it changes, dead or not, and only when the health or
the food level changes or the saturation reaches or leaves zero: the hub
sent it on every change of saturation too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 04:00:24 +02:00
Ludovic MarieandClaude Opus 5.5 c6531e4bde Server players finish vanilla's FULL square, not a 1.5x circle
DistanceManager.PlayerTicketTracker gives every chunk within the server's
view distance (Chebyshev) of a player a PLAYER_LOADING ticket at level 31,
so FULL (33) reaches two chunks past it and FEATURES and TERRAIN one and
two more. A server player's streaming area was a circle of radius
(view distance + 2) * 3 / 2 + 1, finished one further: 2.1x the chunks at
view distance 12. It is now that square, and lan status reports the
server world's chunk counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 04:00:23 +02:00
Ludovic Marie d223b383ee Merge pull request #798 from ludovic111/perf/block-entity-tickers
🤖 Agent: merged vanilla-order block entity ticking (new harness: 21,590/21,590 ticks match vanilla; block-entity share of the tick 19.5%→9.6% with 50 bots; 90/90 harnesses locally).
2026-10-18 03:59:14 +02:00
Ludovic Marie b651ca5f93 Merge pull request #795 from ludovic111/perf/sculk-fx-maps
🤖 Agent: merged sculk per-mob maps on the fast hasher (small win; keys are server-assigned ids).
2026-10-18 03:54:57 +02:00
Ludovic Marie 5ca2021223 Merge pull request #791 from ludovic111/perf/zlib-rs
🤖 Agent: merged zlib-rs compression (chunk deflate 1.98× faster, writer-thread deflate −60%; 222/222 harnesses locally, verify green).
2026-10-18 03:54:54 +02:00
Ludovic Marie 50daf6ecfc Merge pull request #792 from LookImEcho-Rick/codex/tooltip-display-parity
🤖 Agent: merged LookImEcho-Rick's tooltip_display parity, with its Java oracle and Rust probe.
2026-10-18 03:33:38 +02:00
Ludovic Marie f32e696eb9 Merge pull request #794 from lazy-importer/fix/creative-flight-save-persistence
🤖 Agent: merged lazy-importer's creative flight persistence across save and reload (fixes #777).
2026-10-18 03:33:33 +02:00
rdbtCVS eb3789038e Record repository PR self-check 2026-10-18 02:28:17 +01:00
rdbtCVS ce77a4189d Specialize unary density evaluation per bulk buffer 2026-10-18 02:26:03 +01:00
Ludovic MarieandClaude Opus 5.5 118bfa2be2 fix(game): finish every consumable as vanilla's Consumable.onConsume
Follow-up to #773 (consume effects on food):

- ApplyStatusEffectsConsumeEffect always draws its float and skips the
  effects when the draw is >= probability (#773 skipped the draw at 1.0
  and applied on <=).
- on_consume_effects run for every consumable, not only food: milk can be
  drunk (clear_all_effects, then the bucket), honey removes poison and
  leaves its glass bottle, stews their bowl (UseRemainder).
- The consumable's own timing, sound, animation and particles
  (consume_seconds, sound, has_consume_particles) through horizon-world's
  Consumable.emit, in vanilla's draw order; FoodProperties.onConsume's
  sound and burp; the used statistic; the eat or drink game event after
  the effects.
- teleport_randomly (chorus fruit) through horizon-world's verified
  TeleportConsume port, with LivingEntity.randomTeleport for the player
  (border clamp, entities_can_teleport_to ground, collision and liquid
  checks), the teleport game event and sound, level event 2017's
  directional particles and entity event 46's portal particles.
- tools/agent_control_e2e.py: a consume scenario (golden apple at full
  hunger, milk, honey, chorus fruit).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:14:29 +02:00
Ludovic MarieandClaude Opus 5.5 209b898c02 The console's and players' command feedback reaches the operators
CommandSourceStack.broadcastToAdmins beyond a guest on a dedicated server:

- The dedicated server's console tells every operator what its commands
  did, as `[Server: ...]`, when `broadcast-console-to-ops` is on
  (DedicatedServer.shouldInformAdmins). This covers the game's commands
  and the dedicated-only ones vanilla broadcasts (op, deop, ban, pardon,
  kick, whitelist, save-all, save-on/off, stop).
- In a world open to LAN, the host, an operator when commands are
  allowed, hears of its guests' commands, and op guests hear of the
  host's.
- A player's name in the line is their display name, with the `/tell`
  suggestion, the entity hover and the insertion (Player.getDisplayName).

New harness test_server_ops_feedback_parity: two vanilla-codec
operators' system chat while the console and one of them run commands,
deop and op, toggle send_command_feedback and save, with
broadcast-console-to-ops on and off. LocatorBarClient can record system
chat (`chat on`). lan_e2e's feedback check also asserts the host is told.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:02:11 +02:00
Ludovic MarieandClaude Opus 5.5 4ac29bf1c1 docs(hosting): players travel between dimensions on their own
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:01:56 +02:00
Ludovic MarieandClaude Opus 5.5 9a2d87ec97 fix(server): move players to another dimension with execute in ... run tp
On a dedicated server, `execute in <dimension> run tp @s ...` from a player
moved them inside the level they were in. It now does what vanilla's
TeleportCommand does: ServerPlayer.teleport into the other level, with the
Respawn packet keeping all data and no portal sound, then the feedback. The
players there are tracked to them and they are removed from the ones they
left.

tools/test_server_dimension_players_parity.py joins two players to a vanilla
and a Horizon server and compares, step by step, the players each is sent and
loses and the dimensions it respawns into, while they go to the Nether and
the End by command and through an End portal. LocatorBarClient gains
`players on` and `still NAME` (the position reminder a still client sends).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:01:56 +02:00
Ludovic MarieandClaude Opus 5.5 a1af7fdad5 Worlds without structures generate none
`generate-structures=false` (server.properties, stored by #733) and the
world screen's Generate Structures were written to the world's settings
but the generator ignored them. VanillaTerrain now builds its generator
from the world's `generate_structures`
(OverworldGenerator::for_world): without structures there are no starts,
terrain adaptation or structure pieces, as in
ChunkStatusTasks.generateStructureStarts, and `locate structure` and eyes
of ender find nothing (ChunkGenerator.findNearestMapStructure).

The server world gen harness's world without structures now also
compares `locate structure` for a village and a mineshaft and the saved
chunks' structure starts and references.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:01:55 +02:00
Ludovic MarieandClaude Opus 5.5 5e1abf2b4e Clients of a server show its primed TNT, falling blocks, projectiles and crystals
A Horizon client of a server places what PR #766 sends: primed TNT with
its fuse, falling blocks with their block state and start position,
arrows (critical, in the ground), snowballs, eggs, pearls, fireballs,
wither skulls, wind charges and llama spit, tridents, firework rockets
and end crystals with their beam and base. They interpolate like mobs,
turn with the move packets, and are cleared with the connection, a
respawn or a dimension change.

`status` lists them (`projectiles`), and lan_e2e has a `projectiles`
check: the guest sees the host's primed TNT, end crystal and landed
arrow where the host has them, and a falling block lands on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 03:00:42 +02:00
Ludovic MarieandClaude Opus 5.5 946bf84818 Load summoned and saved primed TNT as live TNT; step the entity harness
`/summon minecraft:tnt` and primed TNT in a saved chunk were kept as an
unsimulated entity. They now become primed TNT with the fuse (80 when
absent) and motion they were saved with (PrimedTnt.readAdditionalSaveData).

The entity harness runs its scene for a fixed 200 ticks (`tick step`),
so what still changes when the client stops recording (a fuse) ends the
same on both servers. Its scene has two primed TNT (one falling), a
floating falling block, an arrow that lands and an end crystal. Unit
tests cover arrows' position and rotation, fireballs' bundled power and
precise position syncs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 02:57:12 +02:00
Ludovic MarieandClaude Opus 5.5 866e772227 Server: track primed TNT, falling blocks, projectiles, tridents, fireworks and crystals
These entities had no id in Horizon, so a server could not send them to
its clients. Each now has one, given from the entity counter before it is
first tracked, and goes to guests as vanilla's ServerEntity sends it:

- primed TNT with its fuse (sent each tick it changes) and falling blocks
  with their block state and start position; both sync their exact
  position while they touch a block (requiresPrecisePosition);
- arrows and tridents send their position and rotation every update
  (AbstractArrow), with their flags, pierce level, in-ground state,
  loyalty and foil; their owner is the spawn data;
- fireballs, wither skulls and wind charges bundle their velocity with
  ClientboundProjectilePowerPacket (new in horizon-protocol);
- snowballs, eggs, pearls, llama spit, shulker bullets and splash potions;
- firework rockets with their item, the player they boost and whether
  they were shot at an angle; end crystals with their beam and base.

The entity harness's scene gains primed TNT, a falling block, an arrow, a
snowball and a trident that land, a fireball, a firework and a crystal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 02:57:12 +02:00
Ludovic MarieandClaude Opus 5.5 583502f0f4 Clients of a server show its boats, minecarts, armor stands, frames and paintings
ClientPacketListener.handleAddEntity for the objects a server tracks: a
Horizon client of a server (a LAN host, a dedicated server, single
player's server thread) now places the boats, minecarts, armor stands,
item frames and paintings it is sent, where the server says.

- Spawn: carts by kind, boats and rafts by wood (with a chest), armor
  stands, frames and paintings at their block with the direction from the
  spawn data.
- Entity data: frame items and rotation, painting variants (a registry
  holder), armor stand flags and health, boat paddles, vehicle hurt.
- Equipment: what an armor stand wears and holds.
- Moves interpolate like mobs'; rotation and ground state follow.
- They are cleared with the connection, a respawn or a dimension change.

`status` lists them (`objects`), and lan_e2e has an `objects` check: the
host summons them, the guest sees each as the host has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 02:54:25 +02:00
Ludovic MarieandClaude Opus 5.5 43e8b54918 Server: track boats, minecarts, armor stands, frames and paintings to clients
A server sent its clients only mobs and dropped items. Boats, every kind of
minecart, armor stands, item frames, paintings and the entities Horizon keeps
without simulating are now tracked as ServerEntity does: the AddEntity data
(hanging entities at their block, with their direction), the synched data that
differs from the defaults, equipment, passengers (ClientboundSetPassengersPacket
at pairing and on change; a passenger syncs only its rotation and resyncs its
position when it dismounts), and the type's update interval and range.

Also vanilla-exact on the way, as the new harness found:
- mobs pair with their health (always non-default) and Mob flags (no AI, left-handed);
- /summon without NBT summons any type from an empty compound;
- armor stands move before gravity (LivingEntity.travelInAir), so a stand at rest
  keeps vanilla's -0.0784 vertical motion.

tools/test_server_entities_parity.py compares, from a vanilla-codec client, what
vanilla's dedicated server and Horizon's track for a scene of 20 entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 02:50:14 +02:00
Ludovic MarieandClaude Opus 5.5 2c244f1888 Creaking hearts: scan only sections whose palette holds a heart
scan_for_hearts read all 4,096 blocks of every non-air section of each new
chunk on the server thread to find creaking hearts. A section whose palette
holds no heart state (PalettedContainer.maybeHas) cannot have one, so it is
skipped. Same block entities, same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 02:48:45 +02:00
Ludovic Marie ce571f789c Merge pull request #786 from LookImEcho-Rick/codex/issue-780-open-mod-folder
🤖 Agent: merged LookImEcho-Rick's Open Mod Folder buttons (fixes #780).
2026-10-18 02:33:20 +02:00
LookImEcho-Rick 2faa99e5f1 Fix item tooltip display parity 2026-10-17 19:30:23 -05:00
Ludovic Marie 43d43df71d Merge pull request #787 from ludovic111/bm2/combined
🤖 Agent: merged the six LAN guest fixes for #696 (#722–#727), combined on main and checked locally (clippy, tests, parity lan/dedicated_server/mob_ai, lan_e2e clicks/sleep/push/eat/xp).
2026-10-18 02:09:22 +02:00
Ludovic MarieandClaude Opus 5.5 01f4c56dc1 Cull boats, paintings, item frames, armor stands, TNT and tridents like vanilla
These entities were drawn every frame wherever they were in the loaded
world: every armor stand, item frame and painting of every loaded village
or base went through its model, item and per-vertex occlusion test even
far behind the camera. EntityRenderer.shouldRender now decides, as for
mobs and minecarts:

- Entity.shouldRenderAtSqrDistance from the current position: 64 times the
  bounding box's mean edge times the entity distance option (boats about
  71 blocks, primed TNT 63, paintings by size); item frames 1024 blocks;
  armor stands 256 times the box's mean edge whatever the option (4 for a
  marker's empty box); tridents ten times the default (AbstractArrow);
  firework rockets 64 blocks.
- the interpolated culling box grown by 0.5 in the view; tridents grow it
  by 1.5 more (ThrownTridentRenderer), armor stands with a dragon head by
  0.5 (LivingEntityRenderer), item frames holding a map use the block-wide
  box.

The fishing bobber is unchanged: vanilla never frustum-culls it
(FishingHookRenderer.affectedByCulling) and Horizon only draws the
player's own, always within its 64 blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 02:04:30 +02:00
Ludovic MarieandClaude Opus 5.5 c78bf74744 Cave culling: search on a worker thread, frames never wait for it
The section occlusion search (SectionOcclusionGraph) ran on the render
thread whenever a section was meshed or the camera entered another
section: at render distance 32 that is a breadth-first walk over about
124,000 cells, and while exploring it ran nearly every frame.

As in vanilla 26.3, the full search now runs off the render thread. A
worker keeps its own copy of every section's face connectivity, fed the
changes with each job, and searches over dense arrays around the camera.
Frames use the last finished graph and never wait for the next. The search
starts at the top of the frame, as soon as the camera is known, so it
usually lands before the frame builds its visible list. A section meshed in
between propagates into the graph the frame has (vanilla's
runPartialUpdate), so a wall that opens shows what is behind it at once; a
section that closes goes at the next full search. A graph that never
reached the camera's section (after a teleport, say) is not used: until the
next one lands every section in the frustum is drawn.

The search itself is unchanged: the same box, steps and first-entry rule,
checked cell for cell against the previous code in a test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 02:04:21 +02:00
lazy-importer 3bb973efae fix(game): persist creative flight status across world save and reload (#777) 2026-10-18 01:57:22 +02:00
LookImEcho-Rick 1ffdb5f3f5 Add open mod folder buttons
Expose the Horizon mods directory from both the Classic and Horizon mods screens, creating it if needed before opening it.

Closes #780
2026-10-17 18:55:26 -05:00
Ludovic Marie 86d745853a Merge remote-tracking branch 'origin/main' into bm2/combined 2026-10-18 01:51:50 +02:00
Ludovic Marie e4f8154cb4 Merge pull request #783 from LookImEcho-Rick/codex/issue-730-horizon-scrollbar
🤖 Agent: merged LookImEcho-Rick's Horizon options scrollbar dragging (fixes #730).
2026-10-18 01:40:18 +02:00
rdbtCVS 68475af653 Startup stage 3d: make the menu interactive before world core loads (#712) 2026-10-18 00:35:17 +01:00
Ludovic Marie 730c43e7b0 Merge pull request #773 from lazy-importer/fix/always-edible-food-effects
🤖 Agent: merged lazy-importer's always-edible food and consume effects (fixes #750). Vanilla gaps listed in the review are followed up by the agent.
2026-10-18 01:32:49 +02:00
LookImEcho-Rick d33929c8fa Fix Horizon options scrollbar dragging 2026-10-17 18:22:40 -05:00
rdbtCVS 33c6578f5a Log whether the tail is ready when the live backdrop fades in 2026-10-18 00:14:51 +01:00
rdbtCVS 08532d6b1f Report the active tail phase while world entry waits 2026-10-18 00:14:51 +01:00
rdbtCVS a5edc14b97 Startup stage 3c: keep world entry responsive while joining resources (#712) 2026-10-18 00:14:50 +01:00
rdbtCVS 9004019800 light: pack the flood fill and precompute neighbour blocking
light_chunk's sky and block spreads read LightTables::cells for every
neighbour, and the fill and both spreads dominated lighting. Pack each
cell into two bytes (occlusion type and dampening) with a per-type
blocking mask, step neighbours by linear offset with an axis bounds
check, and precompute the per-column present table.

1156-column probe: 1.81 -> 1.31 ms/col (~28%). Vanilla light parity is
unchanged (1,179,648 sky/block values, 0 differences).
2026-10-18 00:12:51 +01:00
rdbtCVS 4e854f8ee3 Format launcher assertions for the shared telemetry checks 2026-10-18 00:10:59 +01:00
lazy-importer ecdff7ad2d fix(game): apply absorption effect attribute modifiers and health (#750) 2026-10-18 00:57:04 +02:00
lazy-importer 9779eb7271 fix(game): allow always-edible food when full and apply consume effects (#750) 2026-10-18 00:57:04 +02:00
rdbtCVS a3cf97f2fa Build the core renderer before starting audio 2026-10-17 23:50:46 +01:00
rdbtCVS d9686faf35 Startup stage 3b: publish world core before the simulation tail (#712) 2026-10-17 23:47:53 +01:00
Ludovic MarieandClaude Opus 5.5 0eec25d14b Sculk: Fx hashing for the per-mob step and damage maps
entity_motion_events looked up each moving mob in a SipHash map every
tick. The keys are mob ids the server gives out, so the fast hasher is
safe there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:45:49 +02:00
Ludovic Marie 437d5272b5 Merge pull request #772 from lazy-importer/fix/hopper-minecart-display-offset
🤖 Agent: merged lazy-importer's hopper minecart display offset fix (#741).
2026-10-18 00:41:12 +02:00
rdbtCVS 42d65465a4 Startup stage 3a: split shared resources into core and rest (#712) 2026-10-17 23:39:11 +01:00
Ludovic MarieandClaude Opus 5.5 5253e94262 Dedicated server: skip the client ticks of spawners, trial spawners and vaults
Their particles, ambient sounds and the turning display mob are for a
window. A dedicated or integrated server has none, yet made a display
mob for every spawner it ticked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:29:46 +02:00
Ludovic MarieandClaude Opus 5.5 7de8c51f4e Block entity tickers: sweep the list only after a slot is freed
The list kept its slots for the whole tick; sweeping it every tick cost
two hash lookups per ticker. Now it is swept only when a slot was freed
or a block entity was taken out and not brought back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:29:46 +02:00
rdbtCVS 05ecd873d4 Startup: show the title before the world bundle finishes loading (#712) 2026-10-17 23:24:05 +01:00
rdbtCVS 5c55e7d855 Paletted container access: skip the storage revalidation a section already did
BlockSection::get and set check the section index and the state id once,
then read and write through new PackedStorage::read/write, which skip the
storage-level bounds and value checks and the Result that get/set keep for
external callers. A section index is below 4,096 and a palette id always
fits the width bits_for_palette chose; both methods debug_assert that
contract. Used for the hot read, the hot write and the growth re-encode.

Benchmark (ignored tests, best of alternating-order rounds, fast profile):
BlockSection::set 13.15 -> 9.12 ns/write (1.44x), get 2.33 -> 1.45 ns/read
(1.61x). See docs/performance/paletted-container-access-results.md.
2026-10-17 23:23:15 +01:00
Cosmic 594929b795 Validate LOD loading, shader depth and current terrain draw paths 2026-10-17 18:13:20 -04:00
lazy-importer e92f74f01d fix(minecarts): use per-kind display offset in cart geometry (#741) 2026-10-18 00:12:39 +02:00
Ludovic MarieandClaude Opus 5.5 ff94490992 Block entities: tick only those with a ticker, in vanilla's order
World::block_entities keeps vanilla's Level.blockEntityTickers beside the
map: a slot when a block entity whose state has a ticker is added, kept
when it is replaced, freed when it is removed, unloaded or its state loses
the ticker; a loaded chunk's in its block entity map's order. The tick
visits that list once, in block-ticking chunks within the world border,
instead of scanning the whole map once per kind.

New harness block_entity_tickers: real LevelChunks of a server level
against BlockEntities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:08:44 +02:00
Cosmic 9ce739796e Merge remote-tracking branch 'origin/main' into feat/terrain-lod
# Conflicts:
#	crates/horizon-client/src/gfx.rs
#	crates/horizon-client/src/graphics.rs
2026-10-17 18:06:36 -04:00
Cosmic bae7975757 Add bounded hierarchical surface terrain LODs for distant views (#718) 2026-10-17 18:05:39 -04:00
Ludovic Marie cf63dcd39f Merge remote-tracking branch 'pr/724' into bm2/combined
# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
#	tools/lan_e2e.py
2026-10-17 23:57:19 +02:00
Ludovic Marie 40e9e9660b Merge remote-tracking branch 'pr/727' into bm2/combined
# Conflicts:
#	crates/horizon-client/src/game/agent.rs
#	tools/lan_e2e.py
2026-10-17 23:55:19 +02:00
Ludovic Marie e74d5207dc Merge remote-tracking branch 'pr/726' into bm2/combined
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	tools/lan_e2e.py
2026-10-17 23:54:59 +02:00
Ludovic Marie 9bb22caa22 Merge remote-tracking branch 'pr/725' into bm2/combined
# Conflicts:
#	tools/lan_e2e.py
2026-10-17 23:54:22 +02:00
Ludovic Marie 6f5a3319ee Merge commit '123e8286ac55894a0e9f61c78b3844fd46241e2a' into bm2/combined
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	tools/lan_e2e.py
2026-10-17 23:54:21 +02:00
Ludovic Marie a72e2a2f0e Merge commit '69dd4feb316616eef6255a4f41dc3b680a6a43e6' into bm2/combined 2026-10-17 23:52:39 +02:00
Ludovic Marie af3e75fece Merge pull request #740 from rdbtCVS/t3code/08bad3fd
🤖 Agent: merged by Ludovic's AI agent. CI parity passed (harness paths stay synchronous). Smoke test of the new path on current main (macOS, --offscreen, normal launch): resources loaded on the worker in 9.3 s, renderer swapped, title screen up and running for 60 s, no panic. Thanks @rdbtCVS!
2026-10-17 23:52:27 +02:00
Ludovic MarieandClaude Opus 5.5 90053d15aa docs/modpacks.md: memory, NeoForge comparison, worldgen and Chipped status
The NBT tables stream, the fluid tables reuse the game's facts and the content
root is kept between starts (#753, #754, #756). Horizon with ATM10's 40 data-tier
mods against the same mods on NeoForge 21.1.259. Worldgen carry-over and what it
does not cover yet (#763, #764). Chipped's harness row with its self-drop tables
(#762). The overlay cache line said it was cached; it is now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 23:45:43 +02:00
Ludovic Marie 5f5781b17e Merge pull request #745 from rdbtCVS/perf/worker-headroom
🤖 Agent: merged by Ludovic's AI agent. Measured by perf-ticks-6 (comment above): ThinkPad 4 threads RD12 1% low 17→29 FPS (31→40 with --server-thread), FPS up everywhere; in-frame spawn load ~15% slower (32.5→37.2 s); Mac 14 threads within noise. Thanks @rdbtCVS!
2026-10-17 23:44:17 +02:00
rdbtCVS 56a251eb13 Paletted container access: one palette probe per write, dispatched unpack
BlockSection::set resolved the old cell through get and then probed the
section palette three times per non-trivial write: keying the old state by
id, testing the incoming state and finding it. The slot at the written index
already is the old state's id, so read it straight from storage and resolve
only the incoming state, with a single map probe. Palette reuse, free slots
and growth are unchanged, so palette, counts, free list and NBT are identical.

PalettedContainer::unpack and unpack_u16 matched the palette variant inside
the per-cell loop; dispatch Single/List/Global once before it.

Measured against copies of the pre-optimization code (ignored tests):
BlockSection::set 12.73 -> 11.37 ns/write (1.12x), unpack list 1.956 ->
1.860 us/call (1.05x), global 2.255 -> 2.172 (1.04x). See
docs/performance/paletted-container-access-results.md.

set_matches_baseline_under_random_edits drives both implementations over the
same 50k-edit streams for 1/2/16/17/24/64 distinct states, with periodic
whole-section collapses, and asserts equal results, bits, palette, counts,
free list and NBT.
2026-10-17 22:38:35 +01:00
Ludovic MarieandClaude Opus 5.5 9afb6d76de Carry: structure mods' worldgen in vanilla's types, with the loader's biome tags
Structure mods build on libraries whose structure, placement and pool element
types are vanilla's with extra rules: Lithostitched's, Moog's Structure Lib's
and Repurposed Structures' jigsaw structures, their advanced random spreads,
versioned and ocean-bottom single pool elements. Carry rewrites them as
vanilla's types; the extra rules (no start in liquid, a minimum distance from
the origin) are lost, so a structure may start where the mod would not. Types
with no vanilla counterpart (YUNG's, Formations', the nether variants) are left
as they are and counted in the carry report.

The structures' biomes name the loader's convention tags (#c:is_plains). fetch
now also gets the pack's NeoForge jar from NeoForge's Maven repository, checked
against its SHA-1, and carry copies the convention biome tags each mod's
structures use, with those they include, into the mod.

ATM10's 42 carried mods: 1,061 files rewritten, 103 convention biome tags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 23:32:48 +02:00
Ludovic MarieandClaude Opus 5.5 d3673b1509 Carry: write the self-drop loot table a mod registers in code
Chipped registers its blocks' loot in code: its jar ships 7 loot tables for
about 7,000 blocks, so carried over they dropped nothing, while in the NeoForge
oracle every one drops itself. When a declared block has no loot table in the
jar and the oracle shows vanilla's dropSelf (one of itself with every tool,
nothing only where the state needs a tool it did not get), carry writes that
table in 26.3's shape. When only the states with one property value drop it
and the rest drop nothing (a door's lower half), the entry gets vanilla's
match_block condition on that value, as createDoorTable writes. Chipped:
6,843 tables added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 23:32:35 +02:00
rdbtCVS c4cc7954ce Portal scans: keep per-chunk section masks across ticks
The nether-portal section precheck from #661 answered "does this section's
palette hold a portal?" per section, per tick. Keep the answer per resident
chunk in `portal_sections`, keyed by a weak handle: an edit or a chunk
replacement rebuilds it, and it is dropped when a chunk unloads and cleared
when the block facts change, so a portal block always forces the sweep. The
entity sweep now finds portal blocks without the per-tick map.

This is a scan microbenchmark, not a whole-game claim: steady improves 2.4x
to 17x over the fixtures and the run parity-checks the cached and per-tick
answers. Rescanning every chunk every tick (cold, reload, edit_all) is slower
because a miss reads all 24 sections of a chunk; see
docs/portal-scan-benchmark.md.
2026-10-17 22:29:59 +01:00
Ludovic MarieandClaude Opus 5.5 a2f8952d05 Compression: flate2's zlib-rs backend instead of miniz_oxide
Halves the CPU of deflating a chunk column and makes small packets about
20 % cheaper. The compressed bytes change; what they inflate to doesn't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 23:18:12 +02:00
rdbtCVS e25672ed4f Startup: split block assets into block data and render assets (#712) 2026-10-17 22:16:45 +01:00
rdbtCVS f0215473e3 Biome zoom: mix the shared corner prefixes once in quart_at_block
getFiddledDistance restarts a six-step LinearCongruentialGenerator mix for
each of the eight quart corners. Its first two steps depend only on the X
and Y corner offsets, so quart_at_block now mixes the two X states and the
four XY states once, then runs the remaining steps across the eight corners.
next(m, salt + offset) == next(m, salt) + offset, so a coordinate folds into
its step with one addition.

Bit-identical to BiomeManager.getBiome and getFiddledDistance; a reference
oracle test pins the equivalence over a dense grid and wrapping extremes.
2026-10-17 22:08:17 +01:00
LookImEcho-Rick b9d61e5701 Make pr_ready portable to Windows 2026-10-17 15:48:49 -05:00
Ludovic Marie 9b97ba09de Merge pull request #737 from ludovic111/perf/lan-post-thread
🤖 Agent: merged by Ludovic's AI agent: perf-server-5 (combined check passed).
2026-10-17 22:48:42 +02:00
Ludovic Marie d65881a2f0 Merge pull request #739 from ludovic111/perf/chunk-packet-direct
🤖 Agent: merged by Ludovic's AI agent: perf-server-5. Before/after in the PR body; agent parity 87/87 stacked; orchestrator combined check with the perf-ticks-5 stack on current main: clippy clean, client+protocol tests pass (one flaky world-delete test, #749), dedicated_server/lan/chunk_stage/block_entity_storage PASS.
2026-10-17 22:47:47 +02:00
Ludovic Marie f548f5ce6c Merge pull request #748 from ludovic111/perf/shared-compressed-frames
🤖 Agent: merged by Ludovic's AI agent: perf-server-5 (combined check passed).
2026-10-17 22:47:42 +02:00
Ludovic Marie cb35d7e661 Merge pull request #738 from ludovic111/perf/hopper-scan-five-slots
🤖 Agent: merged by Ludovic's AI agent: perf-server-5. Before/after in the PR body; agent parity 87/87 stacked; orchestrator combined check with the perf-ticks-5 stack on current main: clippy clean, client+protocol tests pass (one flaky world-delete test, #749), dedicated_server/lan/chunk_stage/block_entity_storage PASS.
2026-10-17 22:47:27 +02:00
Ludovic Marie da0f954d9b Merge pull request #714 from ludovic111/feat/remote-recipe-book
🤖 Agent: merged by Ludovic's AI agent: perf-ticks-5 stack. Local on current main: clippy clean, horizon-client tests pass, parity 10/10 (dedicated_server lan tab_list chunk_stage locator_bar command_exec online_mode user_cache server_world_gen mob_ai); agent integration 88/88.
2026-10-17 22:40:31 +02:00
Ludovic Marie b807990c8e Merge pull request #708 from ludovic111/feat/server-command-tree
🤖 Agent: merged by Ludovic's AI agent: perf-ticks-5 stack. Local on current main: clippy clean, horizon-client tests pass, parity 10/10 (dedicated_server lan tab_list chunk_stage locator_bar command_exec online_mode user_cache server_world_gen mob_ai); agent integration 88/88.
2026-10-17 22:40:22 +02:00
LookImEcho-Rick 782924fcb7 Add settings search to video and controls 2026-10-17 15:36:13 -05:00
Ludovic Marie 8bfe19245b Merge commit 'bf1cd958686ffc95a6ff30cdbedc9a8de59315ad' into HEAD
# Conflicts:
#	tools/lan_e2e.py
2026-10-17 22:33:41 +02:00
Ludovic MarieandClaude Opus 5.5 bf1cd95868 Merge main into feat/server-command-tree
tools/lan_e2e.py: the check lists keep #700's effects and portals and this PR's commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 22:33:18 +02:00
Ludovic Marie bd60381883 Merge pull request #700 from ludovic111/fix/effects-every-player
🤖 Agent: merged by Ludovic's AI agent: perf-ticks-5 stack. Local on current main: clippy clean, horizon-client tests pass, parity 10/10 (dedicated_server lan tab_list chunk_stage locator_bar command_exec online_mode user_cache server_world_gen mob_ai); agent integration 88/88.
2026-10-17 22:11:26 +02:00
Ludovic Marie 2025408979 Merge commit 'b5abc95f' into HEAD 2026-10-17 22:05:28 +02:00
Ludovic MarieandClaude Opus 5.5 1c79addec1 Input capture test: on a server the chat is ChatScreen (#708)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 22:05:28 +02:00
Ludovic Marie a244200097 Merge commit '95bdb96f' into HEAD 2026-10-17 22:05:16 +02:00
Ludovic Marie 3a8ba56c61 Merge commit '7733984e' into HEAD 2026-10-17 22:05:16 +02:00
Ludovic MarieandClaude Opus 5.5 9240aeb9ff Input capture test: the mock Game has lan_acting (#687)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 22:05:16 +02:00
Ludovic MarieandClaude Opus 5.5 24fd51b186 Content mods: keep the built content root for later starts
resource_root built the content root again on every start. With ATM10's 40
carried data-tier mods that is about 25 s on a quiet machine (50 s on a busy
one) and the biggest memory peak of the start, above 3 GB of footprint. The
root now records a stamp of what it was built from (this build of the game,
the base's manifest and rewritten tables, every mod file by size and time) and
later starts with the same stamp use it as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:51:33 +02:00
Ludovic MarieandClaude Opus 5.5 580c2b1dd7 Motion: reuse the game's block facts for the fluid tables
FluidResources decoded gameplay-facts.json a second time for three per-state
columns the game's BlockFacts already has. With ATM10's carried mods that file
is 211 MB: the second decode took a few seconds and peaked about 500 MB above
what the game keeps. MotionResources::load_with_facts takes the game's facts;
load still decodes them itself for tests and tools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:46:38 +02:00
Ludovic Marie ce38c8d74f Merge pull request #743 from ludovic111/perf/terrain-draws
🤖 Agent: merged by Ludovic's AI agent: RD32 terrain draws 9,309 → 708 calls on Metal, avg 128 → 162 FPS, CPU 6.76 → 5.73 ms; screenshots identical (6/7 views 0 px, 7th = fading chat); parity 88/88. RD16 and below unchanged.
2026-10-17 21:42:50 +02:00
Ludovic Marie 4d04a997ce Merge pull request #735 from ludovic111/fix/minecart-render-culling
🤖 Agent: merged by Ludovic's AI agent: verify passed, parity proven (CI or perf-frame-2's local run), before/after numbers and screenshots in the PR body.
2026-10-17 21:42:45 +02:00
Ludovic Marie cd1725565a Merge pull request #742 from ludovic111/perf/metal-gpu-timer
🤖 Agent: merged by Ludovic's AI agent: verify passed, parity proven (CI or perf-frame-2's local run), before/after numbers and screenshots in the PR body.
2026-10-17 21:42:40 +02:00
Ludovic Marie f227d04f79 Merge pull request #715 from ludovic111/fix/vsync-windowed
🤖 Agent: merged by Ludovic's AI agent: verify passed, parity proven (CI or perf-frame-2's local run), before/after numbers and screenshots in the PR body.
2026-10-17 21:42:36 +02:00
Ludovic Marie b5abc95fe4 Merge commit 'a4feb6516102eaffb9e82437f808e32f4d05bde0' into HEAD 2026-10-17 21:41:03 +02:00
Ludovic MarieandClaude Opus 5.5 7c5bd06dfe lan_bot: the command tree arrives parsed (#708)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:41:03 +02:00
Ludovic Marie 95bdb96f16 Merge commit '805d5532e541deb1888216c8ebfc15775d8b2ea5' into HEAD 2026-10-17 21:40:51 +02:00
Ludovic MarieandClaude Opus 5.5 7733984e78 levels.rs: per-level block events carry u32 states (#674)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:40:51 +02:00
rdbtCVS 238278aa41 Startup: keep the title's atlas and panorama in one UI bundle (#712)
The title's GUI atlas and its panorama both come from the resource root and
are only needed to draw the title and menus, but they lived apart: the atlas
on Game and the panorama inside PackResources. Gather them into UiResources so
Stage 2 can load the title bundle before the world bundle. No behaviour change.

Part of #712 (stage 2a).
2026-10-17 20:40:10 +01:00
Ludovic MarieandClaude Opus 5.5 4ae51c1d5e perf_bench --warm: measure a later start, after a first one built the caches
A Horizon game with content mods builds its content root and block behaviour
snapshot on its first start, in its game folder, and reuses them afterwards.
perf_bench starts every game from an empty folder, so it only measured first
starts. --warm starts the game once to the title screen first, unmeasured.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:39:48 +02:00
Ludovic Marie ba7451cd31 Merge pull request #728 from Online5277/perf/block-entity-fields
🤖 Agent: merged by Ludovic's AI agent once CI parity passed; screenshots identical to main (perf-frame-2), CPU frame time about 40% lower on a chest field.
2026-10-17 21:38:00 +02:00
Ludovic Marie 1fe38223ac Merge pull request #736 from Online5277/fix/death-screen-save
🤖 Agent: merged by Ludovic's AI agent once CI parity passed.
2026-10-17 21:37:34 +02:00
rdbtCVS fb450342bc Worker threads: leave a core for the render and server threads 2026-10-17 20:37:20 +01:00
Ludovic Marie 8c01418247 Merge pull request #682 from Online5277/fix/blocked-hit-animation
🤖 Agent: merged by Ludovic's AI agent: CI parity and verify passed. Thanks @Online5277!
2026-10-17 21:37:03 +02:00
Ludovic MarieandClaude Opus 5.5 2e531f35e5 Per-state tables: check state identities while reading, never as a tree
occlusion.nbt, cached-collision.nbt, cached-support.nbt, block-properties.nbt and
movement-properties.nbt each carry the identity of every block state, and every
loader parsed them into an NBT tree first: 240 MB per table for ATM10's 153,511
states, about 1.4 GB allocated and freed while the game starts.

nbt::read_named_streaming hands a named list to a visitor element by element.
registry::BlockTableFile reads a table that way, checking each state against the
registry as it comes, and loaders build from a checked registry::BlockTable.
from_nbt stays for trees built in memory (tests, fixtures).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:25:11 +02:00
Ludovic MarieandClaude Opus 5.5 0605ee689f LAN: compress a large packet once for every connection
Each player's writer thread compressed every packet it sent, so a chunk
column sent to fifty players was deflated fifty times; compression was
most of the writer threads' time and about half of the server's CPU.
Bodies of 8 KiB or more (chunk columns, mostly) now go through a shared
cache of compressed frame contents keyed by their length, two
independently keyed 64-bit hashes and the threshold, holding the newest
32 MiB. The bytes on the wire are the same: frame::encode is split into
frame::contents and frame::prefixed, and ConnWriter::send_contents
sends a frame from its contents.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:06:22 +02:00
Ludovic MarieandClaude Opus 5.5 d746be2ca3 Merge main into feat/server-levels
game/lan.rs: #586's movement-check rules are set before the per-level passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 20:56:26 +02:00
rdbtCVS 4be192a4b8 Startup: load resources on a worker behind a loading screen (#712)
The synchronous startup loaded every resource before the first window
existed, so the game hung on a blank screen for seconds. An ordinary
launch now enters the event loop at once, opens the window, and draws a
loading screen with the current stage and a progress bar while a
resource-loader thread runs the existing load sequence. When it finishes,
audio is created on the render thread and the real renderer, game and
title screen replace the loading screen on the same window.

Harness, dedicated and scripted runs (--frames, --world-frames,
--benchmark, --control, --dedicated, HORIZON_MENU_STYLE=classic and the
parity fixtures) keep the fully-synchronous path unchanged, so their
output stays byte-identical. title_shown still fires on the real title,
not on the loading screen.
2026-10-17 19:54:10 +01:00
Ludovic Marie dc1a8a4f39 Merge pull request #734 from ludovic111/fix/guest-command-feedback
🤖 Agent: merged by Ludovic's AI agent. Combined with #733 on current main: workspace clippy clean, client 827 tests pass, server_world_gen + dedicated_server PASS (agent: parity --changed 89/89).
2026-10-17 20:43:37 +02:00
Ludovic Marie e2e0752489 Merge pull request #733 from ludovic111/fix/server-level-type
🤖 Agent: merged by Ludovic's AI agent. Combined with #734 on current main: workspace clippy clean, client 827 tests pass, server_world_gen + dedicated_server PASS (agent: parity --changed 89/89).
2026-10-17 20:43:34 +02:00
Cosmic 95b4b7422d Restore saved death and health on world entry (#729) 2026-10-17 14:25:35 -04:00
Ludovic Marie 502222d224 Merge pull request #683 from ludovic111/bench/server-2026-10-17
🤖 Agent: merged by Ludovic's AI agent: server benchmark data on 7bbbf6e9 (data and README only).
2026-10-17 20:25:32 +02:00
Ludovic Marie 4a56e505aa Merge pull request #671 from ludovic111/docs/host-a-server
🤖 Agent: merged by Ludovic's AI agent: hosting guide with the new benchmark numbers; docs only, verify passed.
2026-10-17 20:23:11 +02:00
Ludovic Marie 16d731ebda Merge pull request #710 from ludovic111/fix/release-zip-appledouble
🤖 Agent: merged by Ludovic's AI agent: release zips built without AppleDouble/extended attributes, with a check that fails the build if any slip in.
2026-10-17 20:23:07 +02:00
Ludovic MarieandClaude Opus 5.5 7da32100a8 Server benchmark 2026-10-17: rerun on main 7bbbf6e9 on a quiet machine, equal work, profile
Replaces the overloaded Mac mini data. Keeps the 002f14ce run as a before/after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 20:18:40 +02:00
Ludovic Marie 3dde7d9955 Merge remote-tracking branch 'origin/main' into sh4/bench-2026-10-17 2026-10-17 20:13:36 +02:00
Ludovic MarieandClaude Opus 5.5 68b0ad8199 Hosting guide: movement checks are in (#586), benchmark on main 7bbbf6e9
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 20:13:26 +02:00
Ludovic Marie 1e655afcab Merge pull request #721 from rdbtCVS/fix/launcher-prism-instance-folder
🤖 Agent: merged by Ludovic's AI agent once verify passed (the earlier failure was a flaky client test #721 doesn't touch; main passes it). Launcher tests run locally on main + #719 + #721: all pass. Thanks @rdbtCVS!
2026-10-17 19:42:04 +02:00
Ludovic Marie ddc7e466e5 Merge pull request #719 from rdbtCVS/feat/launcher-game-arguments
🤖 Agent: merged by Ludovic's AI agent. CI doesn't build the launcher on PRs, so launcher tests were run locally on main + #719 + #721 (cargo test --locked, macOS): all pass. Thanks @rdbtCVS!
2026-10-17 19:41:50 +02:00
Ludovic Marie 09e179804c Merge pull request #680 from ludovic111/perf/mod-content-memory
🤖 Agent: merged by Ludovic's AI agent. Parity 291/291 on the head; combined with main locally: workspace clippy clean, client/world tests pass, mob_ai/save_roundtrip/spawner PASS. ATM10 registry 249 → 125 MB, vanilla 46 → 24 MB.
2026-10-17 19:41:47 +02:00
Ludovic Marie 9403347d13 Merge pull request #732 from ludovic111/docs/website-screenshots
🤖 Agent: merged by Ludovic's AI agent: images, captions and LEGAL.md only; verify passed; screenshots reviewed at full size.
2026-10-17 19:40:35 +02:00
Ludovic MarieandClaude Opus 5.5 c12e1dcd91 Website and README: fresh in-game and menu screenshots from today's Horizon
Six scenes from one fixed-seed world (seed 8675309): a plains village among
savanna hills, sunset over it and the river, moonrise over its lit square, a
crimson forest with a zombified piglin, tropical fish over a warm-ocean coral
reef, and survival at dusk with a zombie and the full HUD. Plus Horizon's own
title, Singleplayer, Video options and pause screens.

Captured with horizon-ctl screenshot from the offscreen client (fullscreen
2560x1286 on the Mac mini, default graphics settings, GUI scale Auto), scenes
cropped to 16:9 and menus to 16:10 (left-aligned) and scaled to the old sizes
(1600x900, 1280x800). The scene sources are JPEGs in docs/media again, the
names scripts/assets.mjs already reads; the README's WebP copies are the
rebuilt website media. Alt texts and LEGAL.md follow the new pictures.

The launcher screenshots are unchanged: the Mac mini's session is locked, so
no window capture works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:38:37 +02:00
Cosmic d35f892b7f Cache closed chest geometry for block-entity fields (#717) 2026-10-17 13:36:09 -04:00
Ludovic Marie cafe59b72e Merge pull request #408 from Online5277/fix/sulfur-cube-inner-layer
🤖 Agent: merged by Ludovic's AI agent. CI parity passed on the head; combined with current main (and #680) locally: workspace clippy clean, horizon-client 826 + horizon-world tests pass, mob_ai/save_roundtrip/spawner PASS. Thanks @Online5277!
2026-10-17 19:30:36 +02:00
Cosmic 41a0b3a9e8 Use the exact standard shield yaw constant 2026-10-17 13:14:16 -04:00
Cosmic 1a6fd4e17d Merge remote-tracking branch 'origin/main' into fix/blocked-hit-animation
# Conflicts:
#	crates/horizon-client/src/mobs/anim.rs
2026-10-17 13:09:02 -04:00
Cosmic cfed77c227 Pose shields in the active hand and preserve local held item data 2026-10-17 13:08:42 -04:00
rdbtCVSandClaude Opus 5.5 4b7812296e launcher: reach Prism's and MultiMC's shared libraries from an instance folder (#693)
A Prism or MultiMC instance keeps no jar of its own: the client sits in the
launcher root's shared libraries, two or three folders above `instances/`,
and the game is downloaded only once the instance is launched. Choosing
`%APPDATA%\PrismLauncher\instances` (or an instance's `minecraft`/`.minecraft`
folder) as the Minecraft folder failed Prepare when the jar was not there yet,
and the error only listed every launcher without saying what to do.

resolve now also looks in the launcher root above an `instances` folder, so a
folder deeper than the four walked above the chosen one still finds the
libraries. When nothing is found and the chosen folder is inside a launcher
Horizon knows, the error names it and says to launch the instance once so it
downloads the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:57:26 +01:00
Ludovic Marie 39ddc8db3d Merge pull request #674 from ludovic111/feat/wide-block-states
🤖 Agent: merged by Ludovic's AI agent after local checks on a6592eb1: workspace clippy clean; horizon-client/world/protocol tests pass; lan, chunk_stage, dedicated_server, block_entity_storage, chunk_generation PASS.
2026-10-17 18:41:58 +02:00
rdbtCVSandClaude Opus 5.5 dd77fb8016 Launcher: custom arguments for the game's command line
Settings gains a one-line Game arguments field, kept as `game_args` in
settings.json and appended to the client's command line after the
launcher's own, so a player can pass things like `--server-thread` or
`--render-distance 40`. Quotes group a word with spaces in it.

`horizon-launcher game-args [<line>]` shows or sets the line,
`play --arg <ARG>` passes one for a single run, and the MCP launch_client
tool takes an args array. The same splitter feeds all three.

Closes #716.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:36:21 +01:00
Ludovic MarieandClaude Opus 5.5 2f82becb9c Hoppers: look up the block only under five-slot containers
tick_hoppers read the block under every plain container block entity
each tick (chests, barrels, shulker boxes, dispensers, droppers) to find
the hoppers. A hopper's block entity is the only five-slot one
(BlockEntity::for_block, and BlockEntity::id already names it the
hopper), so only those are looked up now; a position still needs a
hopper block to tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:35:42 +02:00
Ludovic MarieandClaude Opus 5.5 d1d36f4121 Guests: experience orbs fly to them and give them their points
On a LAN world or a server, guests never got experience: orbs followed and
were taken only by the host's own player (none on a dedicated server), and
guests weren't sent orbs at all.

- Each orb follows the nearest living, non-spectator player within 8
  blocks, the host or a guest (ExperienceOrb.followNearbyPlayer).
- A guest takes an orb as Player.aiStep does: one of those in its box grown
  by 1, 0.5, 1, at random, at most every 2 ticks (takeXpDelay), repairing
  with Mending first; the rest goes to the guest's experience (sent with
  SetExperience as before), the level-up sound every fifth level.
  TakeItemEntity goes to the players around, the pickup sound plays at the
  orb.
- Orbs have entity ids and are tracked to guests (AddEntity, DATA_VALUE).
  The client shows a server's orbs, moves them with the server's packets and
  flies them to their collector.

tools/lan_e2e.py xp: three 7-point orbs near the guest: it sees three and
ends at level 2 (21 points); the host gets none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:13:55 +02:00
Ludovic MarieandClaude Opus 5.5 22dc3e5954 Guests: touching mobs push them on their own client, as in vanilla
A mob's push of a guest went to the guest as SetEntityMotion carrying the
server copy's velocity, which nothing damps: every tick of contact added
to it and replaced what the guest's client was doing. Vanilla pushes
players on their own client only (EntitySelector.pushableBy): the server
no longer sends those pushes, and a server client now pushes its player
away from every living entity it touches (Entity.push, team rules, no
spectators, bats or passengers of the same vehicle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:13:55 +02:00
Ludovic MarieandClaude Opus 5.5 1c2f7664c3 Cull minecarts by distance and view like vanilla
Minecarts were drawn every frame wherever they were: at render distance 32
every chest minecart of every loaded mineshaft went through the rail
lookups, display block and model, and the per-vertex terrain occlusion
test. Vanilla draws a cart only within 64 times its bounding box's mean
edge (about 57 blocks at entity distance 100%), measured from its current
position, and only when its interpolated box, grown by 0.5 and stretched
up by the display block's offset, is in the view
(EntityRenderer.shouldRender, AbstractMinecartRenderer
.getBoundingBoxForCulling). The cart the player rides always draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:10:34 +02:00
Ludovic MarieandClaude Opus 5.5 69dd4feb31 Server clients: mobs' and players' heads turn smoothly, not flicker
On a LAN world or a server, other entities' heads flickered between two
directions. The client wrote ClientboundRotateHeadPacket's head yaw into
the entity's yRot and the move packets' yRot into the body yaw, never moved
the head's previous yaw and pitch on, and never ran the body rotation, so
each frame interpolated the head from stale values.

As vanilla: move packets interpolate the position, yRot and xRot over three
ticks (LinearInterpolationHandler), RotateHead the head yaw over three
(lerpHeadTo), each tick keeps the previous head rotation for the frames, and
the body follows (a mob's BodyRotationControl, a player's walk direction
within 50 degrees of the look). AddEntity sets yRot and xRot and puts the
body and head at the packet's head yaw (recreateFromPacket).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:52:01 +02:00
Ludovic MarieandClaude Opus 5.5 123e8286ac Guests: container clicks reach the server (chests, furnaces, crafting drags)
On a LAN world or a server, a guest's clicks that went through the menu
engine (placing a carried stack, quick craft drags, double clicks, number
keys, Q) ran only on the guest's own copy and were never sent: the item
showed in the chest or furnace, the server still had it in the inventory,
and the next menu brought it back.

menu_ops now sends every click of the server's open menu as vanilla's
MultiPlayerGameMode.handleContainerInput does: the engine predicts it on
the client's copy, then a ContainerClick goes out with the slots it changed
and the carried stack after it (a drag is its three kinds of clicks). The
plain inventory and crafting table are predicted too, the Shift double click
sends each QUICK_MOVE, and a prediction no longer drops, crafts or makes maps
on the client (the server does).

tools/lan_e2e.py clicks: real mouse presses put a stack into a chest and a
furnace, drag planks over the crafting grid and craft; the server has each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:51:33 +02:00
Ludovic MarieandClaude Opus 5.5 d99cf24400 LAN: wake the writer threads from one post thread, once per tick
At the end of each tick the game thread handed every player's held
packets to that player's writer thread, waking each one: a system call
per player, and on a busy machine often the rest of the core's time
slice to the woken writer. Now the batches go to one post thread in a
single send, and it wakes the writers. A packet sent to a player while
its batch is still with the post thread goes the same way, so each
player's packets keep their order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:51:00 +02:00
Ludovic MarieandClaude Opus 5.5 43f598fd4e LAN: write chunk packets straight into one buffer
ChunkEncoder::body_in writes the LevelChunkWithLight body that
Clientbound::encode would give, into one buffer sized up front: the
cached section bytes and light layers are copied once, uniform light
layers are filled in place, and the heightmaps are shared rather than
cloned. Before, each chunk sent built a packet (growing its buffer,
cloning every 2048-byte layer), then encode copied all of it again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:50:32 +02:00
Ludovic MarieandClaude Opus 5.5 ce62f9c57f Server clients: eating plays on the player's own screen
A server client sent UseItem and never started the use itself, so food
showed no eating animation or crumbs, and holding right click sent a new
UseItem every 4 ticks. Like MultiPlayerGameMode.useItem, the client now
starts eating (Consumable.startConsuming), counts the use down for the
animation and its particles, and starts no other use until it ends; the
server still eats the item. Looking at another player no longer stops
the item from being used (Player.interactOn passes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:42:19 +02:00
Ludovic MarieandClaude Opus 5.5 28d218b3a2 Guests: a bed puts the guest to sleep, not the host
The bed's sleeping state, timer and respawn point were the host's fields,
so a guest clicking a bed put the host to sleep and moved the host's
spawn. as_guest now swaps them with the guest's own; the guest gets its
sleeping pose and bed (entity data 14), wakes on STOP_SLEEPING with the
WAKE_UP animation, and the night skips only once enough players sleep
(players_sleeping_percentage). Guests respawn at their own bed, else the
world spawn. Server clients lie in the bed from the synced position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:40:40 +02:00
Ludovic MarieandClaude Opus 5.5 51b58f3897 Metal: draw terrain sections as one index list per page run
wgpu replays an indirect multi-draw on Metal as one draw call per entry, so
every frame encoded a draw per visible face range and translucent section
(9,300 at render distance 32). Where multi-draw is emulated (Metal) and a
frame has at least 4,096 section draws, a compute pass now copies the
frame's section draws into one index list, in draw order, and each run of
sections in the same arena page is one draw call: 9,309 draw calls become
about 750 at RD32. Smaller frames keep the per-section draws, where the
list's GPU cost (about 0.4 ms on an M4 Pro at RD16) outweighs the CPU saved.

The picture is unchanged by construction: the same triangles from the same
vertices in the same order through the same shader code. Only where a
vertex finds its section's origin changes, from the instance attribute to
a table: a section's vertices fill whole 256-vertex blocks of its page, each
block names the section, and the section's camera-relative origin is
computed on the CPU exactly as before.

Vulkan keeps the native multi-draw. HORIZON_FLAT_DRAWS=1 draws every frame
flat (any backend that supports it), 0 never; the control command
`section-draws flat|indirect` switches in a running game for A/B checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:34:52 +02:00
Ludovic MarieandClaude Opus 5.5 a6592eb188 Merge main into feat/wide-block-states
lan/chunks.rs: #706's per-section block entity lists carry u32 states.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:31:24 +02:00
Ludovic Marie 7bbbf6e97f Merge pull request #670 from ludovic111/feat/server-bench-equal
Server benchmark against vanilla 26.3 on equal work
2026-10-17 17:27:43 +02:00
Ludovic Marie 0354c43f62 Merge pull request #586 from ludovic111/feat/server-move-checks
Dedicated server: vanilla's chunk pacing and movement checks
2026-10-17 17:27:33 +02:00
Ludovic MarieandClaude Opus 5.5 fc6db37b6a GPU frame time on Metal, render spans and section draw counts
Apple GPUs time only pass boundaries (no TIMESTAMP_QUERY_INSIDE_ENCODERS),
so the GPU timer was off on every Mac and benchmarks reported no GPU time.
Where only pass boundaries can be timed, the frame is now bracketed by two
empty compute passes that write the timestamps.

HORIZON_SPIKES also names the renderer's parts (occlusion, visibility,
encode, submit), and the status reply counts the frame's section draws and
the draw calls that submit them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 17:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 897cd7a1b7 Hosting guide: names you add are accounts (#589, #694), the locator bar is in, HORIZON_ONLINE
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:57:47 +02:00
Ludovic Marie 900e0af776 Merge commit 'HEAD@{1}' into HEAD 2026-10-17 16:51:55 +02:00
Ludovic MarieandClaude Opus 5.5 bbde190f06 Merge main into feat/server-move-checks
TickCtx gained `collision` here; main's two newer initializers
(lan_actions respawn, server_travel's server_ctx) now pass it too, as
every other one does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:51:53 +02:00
Ludovic Marie 17dcb6f793 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-17 16:50:12 +02:00
Ludovic MarieandClaude Opus 5.5 eda6e4b510 Merge feat/wide-block-states (with origin/main) into perf/mod-content-memory
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:32:46 +02:00
Ludovic MarieandClaude Opus 5.5 a79baaa031 server_world_gen: an unknown biome logs vanilla's 'Unknown biome, defaulting to plains'
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:32:37 +02:00
Ludovic Marie 3b8cc9fcc8 Merge pull request #713 from ludovic111/perf/advancement-tree-prefetch
Advancements: read the tree on another thread while the world loads
2026-10-17 16:31:31 +02:00
Ludovic Marie bdfa466bd9 Merge pull request #707 from ludovic111/perf/hub-fx-player-sets
Server: Fx hashing for each player's sent chunks and tracked entities
2026-10-17 16:31:26 +02:00
Ludovic Marie 9198e36c7c Merge pull request #706 from ludovic111/perf/chunk-block-entity-cache
Chunk packets: keep each section's block entity blocks with its bytes
2026-10-17 16:31:21 +02:00
Ludovic Marie fb536df24d Merge pull request #705 from ludovic111/perf/chunk-occupied-lookups
Chunk packets: one cache lock per column for its non-air sections
2026-10-17 16:31:17 +02:00
Ludovic Marie 689c262b37 Merge pull request #704 from ludovic111/perf/entity-tracking-ranges
Entity tracking: each entity type's range once per tick, not per player
2026-10-17 16:31:13 +02:00
Ludovic Marie 83afec2d52 Merge pull request #703 from ludovic111/perf/generation-scan-union
Chunk streaming: look at each column once when players' areas overlap
2026-10-17 16:31:07 +02:00
Ludovic Marie 655e171d9a Merge pull request #702 from ludovic111/perf/hopper-tick-prefilter
Hoppers: look up the block only under container block entities
2026-10-17 16:31:02 +02:00
Ludovic Marie c32c00aeb2 Merge pull request #701 from ludovic111/perf/advancements-flush-idle
Server: skip the advancement flush for players whose progress didn't change
2026-10-17 16:30:57 +02:00
Ludovic MarieandClaude Opus 5.5 3cae8d1d73 Dedicated server: level-type, generator-settings and generate-structures for a new world
DedicatedServerProperties.WorldDimensionData: a world that doesn't exist
yet gets level-type's preset (flat or normal; an unknown name warns and
falls back to minecraft:normal), a flat world's generator-settings
(FlatLevelGeneratorSettings: layers, biome, lakes, features; a layer that
doesn't parse is logged and left out, and settings without layers use the
preset's classic ones) and generate-structures, written to
world_gen_settings.dat before the world opens, with vanilla's log lines.
Horizon ignored all three and always made a normal world.

New harness server_world_gen compares world_gen_settings.dat and the lines
logged with vanilla's for five server.properties.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:15:30 +02:00
Ludovic MarieandClaude Opus 5.5 a4feb65161 Show the server's recipe book on a server and place its recipes
On a server, single player's server thread included, the inventory
had no recipe book: the window ignored the recipe book packets, and
the server ignored a guest's ServerboundPlaceRecipePacket.

- horizon-protocol decodes the recipe book's packets and the recipe
  displays they carry: RecipeBookAdd / Remove / Settings,
  PlaceGhostRecipe and the serverbound PlaceRecipe. Every 26.3
  `recipe_display` and `slot_display` type is decoded (shaped,
  shapeless, furnace, stonecutter and smithing displays; empty,
  any_fuel, with_any_potion, only_with_component, item, item_stack,
  tag, dyed, smithing_trim, with_remainder and composite slots).
- The remote client keeps the book the server sends (ClientRecipeBook:
  added, highlighted, removed, replaced), shows the unlock toast,
  applies the open and filter settings, and counts the menu the
  server last sent for the craftable filter. Clicking a recipe sends
  PlaceRecipe; a ghost recipe from the server fades in.
- The server handles a guest's PlaceRecipe in the inventory grid and
  the crafting table (ServerPlaceRecipe on the menu engine, for known
  recipes only) and answers with the slots, or a ghost recipe when the
  items are missing.
- The screen state reports the recipe book's buttons.

lan_e2e.py gains a `recipes` check: the guest gets oak logs, opens the
book, clicks the planks recipe, and a log lands in the grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:09:58 +02:00
Ludovic Marie 4fffbaa1c7 Merge origin/main into docs/host-a-server 2026-10-17 16:06:56 +02:00
Ludovic MarieandClaude Opus 5.5 59fe284c7d Commands name the guest who runs them, not "Player"
/give, /clear, /gamemode, /experience, /advancement, /playsound and the
whisper-to-yourself lines printed the host's placeholder name for the
acting player. They now use the acting player's name (a guest's own while
their command runs), as target_name and the scoreboard do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:06:26 +02:00
Ludovic MarieandClaude Opus 5.5 8912d5d269 dedicated_server harness: compare the op's command feedback on the console, and a score on @s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:41:19 +02:00
Ludovic MarieandClaude Opus 5.5 e2f2177b60 Scoreboard: a guest's @s is the guest, not "Player"
While a guest's command or action runs, the score holder, team, display
name and death and kill criteria of the acting player use the guest's own
name (ScoreHolder.getScoreboardName), as on vanilla's server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:41:19 +02:00
Ludovic MarieandClaude Opus 5.5 91a4290f50 Guests' command feedback reaches the console and the other operators
CommandSourceStack.broadcastToAdmins: on a dedicated server, a guest's
command success (sendSuccess with broadcast) is logged on the console as
"[Name: message]" when log_admin_commands is on, and sent gray and italic
to every other operator when send_command_feedback is on. The queries and
lists vanilla sends with broadcast false (commands/admin.rs) stay with the
player who asked. Lines are queued while the guest acts and sent once the
command returns.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:41:19 +02:00
Ludovic MarieandClaude Opus 5.5 7d50e879da Release zip: no macOS ._ metadata files
ditto without --norsrc --noextattr --noqtn stores an AppleDouble ._ entry
beside every file that has extended attributes, so the macOS release zip
held hundreds of them (horizon-macos-arm64/._horizon-ctl, tools/._*.py).
Package it as packaging/macos/package.sh already does, and fail the job if
any ._ or __MACOSX entry is left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:40:07 +02:00
Ludovic MarieandClaude Opus 5.5 e4741b7793 Decode the server's command tree and complete commands with it
The window dropped ClientboundCommandsPacket ("argument nodes are not
decoded"), so on a server, single player's server thread included,
the chat was a bare input line without completion, colouring or usage.

- horizon-protocol decodes argument nodes: the 26.3
  `command_argument_type` ids and every parser's properties
  (ArgumentTypeInfo.deserializeFromNetwork), plus the custom
  suggestion provider.
- command::network turns the tree into a Dispatcher. A node at a path
  the client's vanilla tree also has takes that node's argument type
  and suggestion provider; any other node takes the type its parser
  and properties describe.
- On a server the chat is ChatScreen over that tree, so Tab completes,
  arguments are coloured, usage and errors show, and Enter sends the
  command to the server. Player-name suggestions come from the
  server's player list. The old bare input line is gone.
- The screen state reports the chat input (`chat_input`).

lan_e2e.py gains a `commands` check: the guest completes `/gamem` to
`/gamemode` and `/gamemode cr` to `/gamemode creative` with Tab, and
the command runs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:34:24 +02:00
Ludovic Marie 1fc1b75007 Merge pull request #699 from ludovic111/ci/launcher-release-paths
Launcher releases: count every crate the launcher builds in
2026-10-17 15:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 805d5532e5 Give mob, beacon, sculk and splash effects to every player
On a server (the integrated server thread and the dedicated server)
the host has no player of its own, so effects that only touched the
host's player reached nobody; on a LAN world they reached only the
host. Beacons, the warden's and shriekers' darkness, the elder
guardian's mining fatigue, splash and lingering potions' splash and
the raid victory's Hero of the Village now run for each player in
the level, guests through `as_guest`.

The elder guardian's curse sends the guest the GUARDIAN_ELDER_EFFECT
game event, which the remote client now shows (the apparition and
the curse sound).

lan_e2e.py gains an `effects` check: a speed beacon next to both
players must give both of them speed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:04:04 +02:00
Ludovic MarieandClaude Opus 5.5 f537c84ebb VSync on waits for the display whatever the present mode (#684)
With an explicit Present Mode (Mailbox, Immediate), the VSync option did
nothing: the setting replaced vanilla's choice even with VSync on, so a
window kept running uncapped. Vanilla's VSync means Fifo, in a window and in
fullscreen alike. Now VSync on always presents with Fifo, and the Present
Mode setting chooses only while VSync is off (Auto keeps vanilla's choice:
Immediate, else Mailbox, else Fifo). The choice is one tested function,
window::chosen_present_mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:04:00 +02:00
Ludovic MarieandClaude Opus 5.5 a60519eb63 Merge origin/main into feat/wide-block-states
The LAN chunk encoder's section cache (#623) and dimension range (#668)
are kept; a cached section's wire bytes use the registry's palette width,
set once per process before any encoder exists, so the cache stays keyed
on the section alone. The sculk scan's palette check takes the u32 state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:02:53 +02:00
Ludovic Marie f90f33b6a9 Merge pull request #698 from ludovic111/launcher-win-fix
Launcher: Windows tests pass again (releases blocked since launcher-v0.4.0)
2026-10-17 15:02:37 +02:00
Ludovic MarieandClaude Opus 5.5 f49af8d995 Launcher releases: count every crate the launcher builds in
LAUNCHER_PATHS and launcher.yml's push paths now list horizon-bench,
horizon-friends, horizon-mods and horizon-port-pack too, the launcher's
path packages per cargo metadata. A change there (like the horizon-mods
dependency that left launcher/Cargo.lock stale) then builds and releases
the launcher instead of going unnoticed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:59:01 +02:00
Ludovic Marie 002f14ce53 Merge pull request #692 from ludovic111/feat/locator-bar
Locator bar for vanilla clients on Horizon servers (waypoint packets as vanilla sends them)
2026-10-17 14:56:09 +02:00
Ludovic Marie 732e77a7ad Merge pull request #694 from ludovic111/feat/server-usercache-2
usercache.json, and console names resolved through it as vanilla does (stacked on #589)
2026-10-17 14:56:05 +02:00
Ludovic Marie 851d34f762 Merge pull request #589 from ludovic111/feat/server-online-profiles
Online mode: players named in commands are their accounts
2026-10-17 14:56:01 +02:00
Ludovic MarieandClaude Opus 5.5 b2784363a1 Mobs see only the guests in their own level
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:49:29 +02:00
Ludovic MarieandClaude Opus 5.5 bff5891073 Launcher: Cargo.lock has horizon-port-pack's horizon-mods dependency
horizon-port-pack now depends on horizon-mods (1f1d5a5a), which the
launcher's own lock file didn't have, so every --locked build of the launcher
failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:46:16 +02:00
Ludovic MarieandClaude Opus 5.5 a67a323959 Server: one level per dimension, so players can be in different dimensions
A server (the dedicated server and single player's server thread) now
holds a level per dimension, like vanilla's `MinecraftServer.levels`. Each
server tick runs every level with players in it, one at a time, by swapping
its fields into place (game/levels.rs), so the world tick code runs
unchanged. Before this, a portal did nothing while two or more players were
online.

- Levels: the parked levels keep the world, streaming, save, block ticks,
  entities, block entity state, raids, sculk and creaking scans. The
  Overworld stays loaded; another level is saved and closed after 300 ticks
  with nobody in it. Saving saves every level.
- Hub: `tick` is split into `poll` (the network, once per tick),
  `tick_level` (the packets, chunks, block changes, containers and entities
  of the players in the level in place) and `end_tick` (latency). Each guest
  has a dimension; time, weather, sounds and events reach only the players
  in their level, and the clock and weather last sent are kept per level.
- Travel: every player has their own portal state; a portal moves only that
  player into the destination's level (`ServerPlayer.teleport` packets as
  before). Respawning brings a player back to the Overworld's level.
- Player data saves and loads each player's dimension, and a returning
  player is placed in their own level.
- tools/lan_e2e.py --server-thread --check portals: the guest goes to the
  Nether while the host stays, then the host goes to the End while the guest
  is in the Nether, and both come back.

The in-frame game keeps its one level that follows its player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:46:03 +02:00
Ludovic MarieandClaude Opus 5.5 05821d4e62 Launcher: Windows tests check behaviour, not symlink paths
On Windows the launcher links another launcher's client jar with a hard link
(a copy across volumes) and its libraries and assets with a junction, since
symlinks need developer mode. canonicalize() of a hard link is its own path,
so the sources tests, which compared canonical paths, failed on Windows and
blocked every launcher release since launcher-v0.4.0. They now check that the
linked jar is the launcher's file (a byte written through one shows in the
other), which holds for a symlink and a hard link alike.

The modpack profile test compares paths as paths rather than strings ending
in "a/carry".

mklink /J's paths are always quoted: Rust only quotes arguments with spaces,
and cmd reads an unquoted & or ^ in a user folder as its own syntax.

CI runs every launcher test target, not just the library's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:45:52 +02:00
Ludovic Marie 799ec775e1 Merge pull request #679 from Online5277/fix/sleeping-player-state
Fix sleeping input, camera height and wake fade
2026-10-17 14:34:56 +02:00
Ludovic Marie 995bd56cdf Merge pull request #691 from ludovic111/fix/trident-throw-pose
Trident throw pose in third person
2026-10-17 14:19:07 +02:00
Ludovic Marie 3e31144362 Merge pull request #690 from ludovic111/fix/mob-float
Mobs float in water instead of bouncing
2026-10-17 14:19:03 +02:00
Ludovic Marie 6d40915b63 Merge pull request #689 from ludovic111/fix/swim-pose
Swimming pose and stroke on the player model
2026-10-17 14:18:58 +02:00
Ludovic MarieandClaude Opus 5.5 d989d4033f Advancements: read the tree on another thread while the world loads
The first criterion trigger of a world's first tick loaded the
advancement tree: about 1,400 JSON files read and parsed on the game
thread, about 60 ms of a 125 ms first tick. open_world now starts that
read on a thread, and the first advancement_tree() takes its result,
the same tree as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:16:54 +02:00
Ludovic MarieandClaude Opus 5.5 45cbb4f7d6 LAN: a guest's chest, crafting table or sign no longer takes the host's mouse (#687)
A guest's action runs on the host's game with the guest's state swapped in
(`as_guest`). The swap restored the open screen but not what opening one
does to the window: `capture(false)` released the host's pointer, so the
cursor showed and the camera stopped whenever a guest opened a chest, a
crafting table or any other menu. `keys.clear()` dropped the keys the host
was holding, and a sign, book, command block or mount menu the guest opened
was drawn on the host's screen.

- `capture` leaves the window alone while a guest acts.
- `as_guest` keeps the host's held keys, sign editor, book, command block
  screen and mount menu, as it already kept the open screen.
- `state` reports `pointer_grabbed` (mouse movement turns the camera).
- tools/lan_e2e.py: `chest` and `crafting` check that the host keeps its
  screen and its mouse look; a new `sign` check places a sign as the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 14:13:46 +02:00
Ludovic Marie 78e3206133 Merge pull request #673 from Online5277/fix/render-distance-settings-cap
Allow render distances up to 64 chunks in settings
2026-10-17 14:13:30 +02:00
Ludovic Marie 6026893e88 Merge pull request #686 from ludovic111/perf/sprite-blend-round
Blend animated sprite frames without a roundf call per byte on x86
2026-10-17 14:13:06 +02:00
Ludovic Marie a1c20290ae Merge pull request #688 from ludovic111/fix/rd32-gpu-oom
Keep terrain in system memory when video memory is full instead of crashing (#600)
2026-10-17 14:13:01 +02:00
Ludovic Marie 980ce75d63 Merge pull request #676 from Online5277/fix/reclaim-terrain-gpu-memory
Release unused terrain GPU buffers
2026-10-17 14:12:57 +02:00
Ludovic Marie 7fb54b8150 Merge pull request #661 from ludovic111/perf/entity-portal-precheck
Entity portal checks: skip the sweep where no section holds a portal
2026-10-17 13:33:19 +02:00
Ludovic MarieandClaude Opus 5.5 042f634c7a Hosting guide: link the locator bar PR
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:32:45 +02:00
Ludovic MarieandClaude Opus 5.5 1ac9c72ea3 Server: Fx hashing for each player's sent chunks and tracked entities
The hub tests every player's sent-chunk set for every entity and every
column of the view each tick, and each player's tracked-entity map for
every entity: tens of thousands of SipHash lookups a tick with 50
players. Both now use the Fx hash. Their iteration order was random
before and carries no meaning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:15:36 +02:00
Ludovic MarieandClaude Opus 5.5 a7af5b76cf Chunk packets: keep each section's block entity blocks with its bytes
Every chunk packet scanned all 4,096 blocks of each section holding a
block entity block to list them, though the section's wire bytes were
already cached. The cache now keeps those blocks (index and state, in
scan order) beside the bytes, made in the same pass that encodes them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:03:47 +02:00
Ludovic MarieandClaude Opus 5.5 4cdad5aae6 Chunk packets: one cache lock per column for its non-air sections
encode_in asks whether each of the nine surrounding columns' 24 sections
holds anything, to decide which light layers exist: 216 cache lookups,
each locking the cache and SipHashing a section's address. The column's
sections now share one lock, and the cache uses the Fx hash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:00:27 +02:00
Ludovic MarieandClaude Opus 5.5 66a8fa1181 Entity tracking: each entity type's range once per tick, not per player
The hub looked up an entity's tracking range (a binary search over type
names) for every player and every entity each tick: about 87,000 string
comparisons a tick with 50 players. The ranges are now worked out once
per entity per tick; the visibility test is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:56:30 +02:00
Ludovic MarieandClaude Opus 5.5 690e656048 Chunk streaming: look at each column once when players' areas overlap
The generation scan walked every area's square of columns, so with 50
players standing near each other it tested about 100,000 columns a tick
(each against every player's range) to find the few hundred around them.
The squares are now merged into one run per row first. The candidates
are the same: they were sorted and deduplicated before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:56:30 +02:00
Ludovic MarieandClaude Opus 5.5 47a00d283d Hoppers: look up the block only under container block entities
tick_hoppers read the block under every block entity in the loaded world
each tick to find the hoppers. A hopper's block entity is always a plain
container (BlockEntity::Chest), so only those need the lookup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:56:30 +02:00
Ludovic MarieandClaude Opus 5.5 c98a4f7a58 Server advancements: flushDirty skips players whose progress didn't change
PlayerAdvancements.flushDirty only works when it is the first packet or
progress changed. Horizon walked the whole advancement tree for every
network player every tick to find nothing new: a fifth of the server
tick with 50 players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:56:29 +02:00
Ludovic Marie fa2536c120 Merge pull request #623 from ludovic111/perf/server-chunk-encoding
Dedicated server: encode each chunk section once, not once per player
2026-10-17 12:43:29 +02:00
Ludovic Marie 3135e1e3d1 Merge pull request #681 from ludovic111/fix/carry-unreadable
Carry: leave out the files the content loader would refuse
2026-10-17 12:39:21 +02:00
Ludovic Marie 87e500fa8b Merge pull request #663 from ludovic111/feat/launcher-import-modpack
Launcher: Import modpack, its coverage report, port and play it as a profile
2026-10-17 12:39:16 +02:00
Ludovic Marie ed3ad72588 Merge pull request #653 from ludovic111/feat/port-pack-fetch
horizon-port-pack: a library, fetch and port; CurseForge zips name their mods
2026-10-17 12:39:13 +02:00
Ludovic Marie 4b76566863 Merge pull request #685 from ludovic111/fix/latency-update-cadence
Server: send tab-list latency every 601 ticks, as vanilla
2026-10-17 12:28:13 +02:00
Ludovic MarieandClaude Opus 5.5 15b26795e6 Blend animated sprite frames without a roundf call per byte on x86
Interpolated animated sprites (`"interpolate": true`) mix two frames into
every mip level with `f32::round`. Baseline x86-64 has no rounding
instruction (it needs SSE4.1), so each byte was a call into libm's roundf:
7.8 to 8.5% of the render thread's samples on a Kaby Lake laptop.

Round with a truncation and an exact fraction test instead. The result
is bit-identical to `f32::round` for every value the blend can produce,
checked over all frame byte pairs at 144 blend times.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:52:45 +02:00
Ludovic MarieandClaude Opus 5.5 c3e5a20488 Dedicated/LAN server: send tab-list latency every 601 ticks, as vanilla
PlayerList.tick broadcasts UPDATE_LATENCY once ++sendAllPlayerInfoIn > 600;
the hub sent it every 100 ticks. The extra updates landed in the
dedicated_server harness's respawn window and failed it at random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:51:51 +02:00
Ludovic MarieandClaude Opus 5.5 74da721249 Hosting guide: online mode is merged, the 10-17 benchmark, working links
Online mode checks accounts since v2026.10.17.07; until #589, players added
before they join get offline UUIDs, with a workaround. Portals follow a
lone player since v2026.10.17.09. Copied game files need v2026.10.17.07 or
newer. Section 10 links the 2026-10-17 benchmark and says plainly that 25
moving players are too many for now. Links to other docs are absolute, so
they work both on GitHub and on the site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:37:16 +02:00
Ludovic Marie 98dc7b17b7 Merge pull request #608 from rdbtCVS/perf/versioned-behaviours-cache
Cache resolved block behaviours in versioned binary snapshots
2026-10-17 11:33:47 +02:00
Ludovic Marie cf00d588c2 Merge pull request #660 from ludovic111/perf/pathfinder-caches
Pathfinder: Fx-hashed nodes, and each path type worked out once per query
2026-10-17 11:33:06 +02:00
Ludovic MarieandClaude Opus 5.5 b859b22111 Keep usercache.json and find console players through it, as vanilla
Port CachedUserNameToIdResolver to the dedicated server, in place of the
in-memory name cache: usercache.json in the universe folder is loaded at
startup (bad entries skipped, a bad date warned about), players are added
when they join and kept for a month, and the file keeps the 1000 most
recently used, newest first, as Gson writes it.

The console's op, deop, ban, pardon and whitelist find players by name
through it, in any case. A name it doesn't hold is asked of the profile
service (profiles::find_by_name) on offline servers too, as vanilla does;
one the service doesn't know is the offline player of the lower-case name
on an offline server, and "That player does not exist" on an online one.

tools/test_user_cache_parity.py runs vanilla's server and Horizon's with a
mock profile service and compares the replies, the names looked up and the
files; the dedicated-server harness now compares usercache.json too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:33:02 +02:00
Ludovic Marie c2a82d4618 Merge pull request #658 from ludovic111/fix/sculk-scan-palette
Sculk: find a loaded chunk's sensors from its palettes, not every block (#489)
2026-10-17 11:33:01 +02:00
Ludovic Marie af0cfa9d3f Merge pull request #657 from ludovic111/fix/structure-starts-on-load
Build a saved chunk's structure starts when it loads, not on the game thread (#489)
2026-10-17 11:32:55 +02:00
Ludovic MarieandClaude Opus 5.5 22bb18d67a Carry: the oracle's facts only for the mods it loaded
With oracle/facts.json, a mod the oracle did not load (a library it
needs is missing) lost every block: none of its ids were in the facts.
It now keeps the guesses from its blockstates, as without an oracle.
ATM10: Regions Unexplored (TerraBlender is CurseForge-only) gets its
805 blocks back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:24:31 +02:00
Ludovic MarieandClaude Opus 5.5 b6603122d7 Publish the 2026-10-17 server benchmark against vanilla, with the machine's load
horizon-bots and tools/server_bench.py at 1, 10, 25, 50 and 100 bots,
walk and explore, Horizon (main at 123987c4) and vanilla 26.3 taking turns.
The Mac mini was overloaded by other work (load 55-141), so only the 1-
and 10-bot rows are a fair comparison; the README says so and gives the
load of every run. It also records where Horizon's server thread goes at
25 walking bots: 96 % in ChunkEncoder::encode_in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 11:24:03 +02:00
Cosmic e0364da4eb Avoid hurt animation for fully blocked shield hits 2026-10-17 05:11:42 -04:00
Ludovic MarieandClaude Opus 5.5 814b70b8c2 Keep terrain in system memory when video memory is full instead of crashing (#600)
At render distance 32 the section arena holds about 1.5 GB of vertices
(2.1 GB of GPU memory in all while flying). Vulkan drivers fail an
allocation once video memory is full instead of paging like OpenGL
drivers, and wgpu's default error handler turns that into a panic: a
4 GB laptop GPU that also drives the desktop crashed a few seconds into
an RD32 world.

Arena pages are now created inside an out-of-memory error scope. A page
that doesn't fit in video memory is created mappable, which the
allocator places in host-visible system memory, so the terrain still
draws (more slowly) as OpenGL drivers do. A page that can't grow keeps
its contents and a smaller new page is tried; with no memory left at
all, the section is skipped rather than the game ending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 10:56:26 +02:00
Ludovic MarieandClaude Opus 5.5 1f1d5a5aa4 Carry: leave out the files the content loader would refuse
Vanilla skips a resource with an invalid path, a data file that does not
parse, one at a time; Horizon's content loader refuses the whole mod
set. carry now keeps them in outside/ with the reason (report
`unreadable`): upper-case paths, JSON that does not parse, shared
tags with "replace": true. A byte-order mark is dropped.

ATM10's 42 carried mods: 19 such files in 7 mods (Mekanism Tools'
ry_UA lang, CTOV's empty tag, Towns and Towers' replacing tags, broken
template pools, recipes and a lang file), and the 42 load as carried.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 10:53:46 +02:00
Ludovic MarieandClaude Opus 5.5 3d21289df7 Merge main into perf/server-chunk-encoding
lan/chunks.rs: keep the per-section encode cache and the cached
heightmaps, applied to the level's section range from #668. Heightmaps
use client_heightmaps_in with the level's min_y and height, and
set_dimension drops cached heightmaps. The light layers go over the
level's sections and drop sky light where the level has none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 10:44:07 +02:00
Ludovic MarieandClaude Opus 5.5 ed9a25ad49 Measure where content mods' memory goes; drop the registry's second copy
- perf_bench.py samples the physical footprint on macOS as well as
  RSS. RSS counts the pages the allocator freed and marked reusable,
  so the six mods' "+1.1 GB" is about 0.5 GB of footprint.
- examples/resource_memory.rs: live heap, peak, RSS and footprint after
  each resource loader, for a prepared or a content-mod root.
- BlockRegistry indexes ids by a hash of the state, checked against
  the states, instead of keeping every state twice: -22 MB live for
  vanilla, -124 MB for ATM10's 42 mods (193,762 states).
- docs/modpacks.md: the per-loader breakdown and the next cut (the
  per-state tables parsed whole at startup).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 10:44:05 +02:00
Cosmic 394bc68702 Merge remote-tracking branch 'origin/main' into fix/sleeping-player-state 2026-10-17 04:41:31 -04:00
Cosmic 516661592f Keep sleeping input camera and HUD in bed state 2026-10-17 04:41:31 -04:00
Ludovic Marie 1af78463eb Merge pull request #677 from Online5277/fix/sweep-attack-particles
Show sweep particles on charged sword hits
2026-10-17 10:36:11 +02:00
Ludovic Marie d5837931aa Merge pull request #675 from Online5277/fix/respawn-after-save
Use the configured respawn point after saving
2026-10-17 10:32:35 +02:00
Ludovic Marie fda706d17f Merge pull request #668 from ludovic111/feat/server-dimensions
Server: the level follows its lone player through Nether and End portals
2026-10-17 10:31:04 +02:00
Ludovic Marie 51db0894bf Merge pull request #662 from ludovic111/feat/network-player-progress
Network players keep advancements, statistics and a recipe book on the server
2026-10-17 10:31:01 +02:00
Ludovic Marie 5b922172ac Merge pull request #664 from ludovic111/feat/client-advancements
Network client: show the server's advancements and statistics
2026-10-17 10:30:57 +02:00
Ludovic Marie 46c7daad92 Merge pull request #655 from ludovic111/fix/client-server-packets
Network client: apply block entity data, difficulty, spawn, chunk centre, tick rate and animations from the server
2026-10-17 10:30:53 +02:00
Cosmic 74860c0312 Emit sweep particles for eligible sword hits 2026-10-17 04:28:04 -04:00
Ludovic MarieandClaude Opus 5.5 171fbeb8ac Send vanilla clients the locator bar on dedicated and LAN servers
Port 26.3's ServerWaypointManager for the players of a served world: each
player who receives waypoints gets ClientboundTrackedWaypointPacket for
every other player who transmits one, as a block (in view), a chunk (one
chunk out) or an azimuth (really far), with the update thresholds,
breaking rules and team colours vanilla uses. Crouching, invisibility and
a carved pumpkin or mob head hide a player; spectators see everyone and
are seen by no one; the locator_bar game rule turns it all off and on.

tools/test_locator_bar_parity.py runs vanilla's server and Horizon's with
two vanilla-codec players and compares every waypoint packet each receives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 10:14:34 +02:00
Cosmic b47f565849 Reclaim unused terrain GPU arena pages 2026-10-17 04:04:25 -04:00
Ludovic MarieandClaude Opus 5.5 69a0d13eda Raise the trident over the shoulder while charging a throw
AvatarRenderer.getArmPose gives THROW_TRIDENT to the hand using a
trident: HumanoidModel lifts that arm over the head (xRot * 0.5 - pi)
and leaves the other arm's pose alone (affectsOffhandPose). Horizon
used the plain item pose, so the held trident, drawn with its throwing
model, pointed down through the body. An aggressive drowned raises its
trident the same way (DrownedRenderer), and zombie arms leave that arm
alone (AnimationUtils.animateZombieArms).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 09:45:18 +02:00
Ludovic MarieandClaude Opus 5.5 62f96e9d65 Mobs float in water instead of bouncing
FloatGoal's constructor lets the navigation float, so Mob.jumpInLiquid
adds 0.04F a tick; Horizon set that flag only for a few species, and
every other FloatGoal user (pigs, cows, wolves, cats, horses, witches,
illagers...) got the non-floater 0.3 and bounced out of the water.
Register it from the goal (and from the constructors that set it:
allay, armadillo, camel, goat, parrot, wither), and add 0.04 as the
float vanilla adds.

Mobs that drop back into water now splash (Entity.doWaterSplashEffect
from updateFluidInteraction, skipped on the first tick), with their
swim splash sound. The new mob_ai deep_water scenarios also caught:
horses register addBehaviourGoals last (none for the skeleton horse, no
mount panic for the zombie horse), RandomStandGoal waits the horse
ambient interval (400 for every AbstractHorse), and the cat's sit-on-
block goal draws its delay only for tame cats once the last ran out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 09:42:46 +02:00
Cosmic e87e5f0cd2 Discard saved login position when respawning after death 2026-10-17 03:41:55 -04:00
Ludovic Marie 5d027877ec Merge pull request #672 from Online5277/fix/audio-output-limiter
Avoid hard clipping when explosion sounds overlap
2026-10-17 09:32:44 +02:00
Ludovic Marie 09c039c423 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-17 09:14:21 +02:00
Ludovic Marie bc69b88c5d Merge pull request #587 from ludovic111/feat/server-online-mode
Dedicated server: online mode, and refused logins in the log
2026-10-17 09:14:16 +02:00
Ludovic MarieandClaude Opus 5.5 4d1596fe3b Load ATM10's 42 carried mods: room past 8,192 textures and tags
With the wide state ids, ATM10's 42 data-tier mods (193,762 states)
reach the world once these loaders stop refusing them:

- Block sprites: eight texture arrays of 2048 layers instead of four
  (9,790 block textures for the 42).
- Per-state tables read as NBT: the table limits cover 2^20 states, and
  the collision table in level data uses them.
- Worldgen block tags: 2^20 blocks instead of 8,192 (mineable/pickaxe
  holds every stone block of a pack).
- As vanilla's loaders do, a blockstate definition that does not bind,
  a missing or broken block model of a mod, and a recipe that does not
  parse are logged and skipped instead of dropping every mod (or
  stopping the game).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 09:12:41 +02:00
Ludovic MarieandClaude Opus 5.5 f48a54da09 Block state ids past 16 bits
Chunk sections keep block states as u32 and size the global palette
to the registry: 16 bits for vanilla's 35,723 states, wider when
content mods take the registry past 65,536. The content-mod limit
goes from 2^16 to 2^20 states.

The LAN host writes chunks at the registry's width, so with vanilla
(or mods within 16 bits) the wire is unchanged. Remote chunks are read
at the server's width, 16 from a vanilla server, and widened to ours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 09:12:41 +02:00
Ludovic Marie 03de0e8e13 Merge branch 'feat/network-player-progress' into feat/server-dimensions 2026-10-17 08:58:12 +02:00
Ludovic Marie f58a9bdf29 Merge remote-tracking branch 'origin/main' into feat/network-player-progress 2026-10-17 08:58:06 +02:00
Ludovic Marie ee0f382bdf Merge branch 'fix/client-server-packets' into feat/client-advancements 2026-10-17 08:58:06 +02:00
Ludovic MarieandClaude Opus 5.5 aea0dc4c93 Network client: keep #656's ChangeDifficulty handler
#656 handles ClientboundChangeDifficultyPacket and refreshes the World
Options screen too; this branch's own arm became unreachable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:58:03 +02:00
Cosmic 310047c225 Allow render distances up to 64 chunks in settings and saved options 2026-10-17 02:55:27 -04:00
Cosmic b771c0b48e Limit overlapping audio without hard-clipping the mixed waveform 2026-10-17 02:51:56 -04:00
Ludovic Marie cc8249d2ce Merge remote-tracking branch 'origin/main' into fix/client-server-packets 2026-10-17 08:50:15 +02:00
Ludovic MarieandClaude Opus 5.5 57ee531c63 Host a server: a guide in docs/hosting.md, and the site's /server page built from it
What works on main today (install, game files, first run, server.toml, the commands, the web panel,
backups, services, Docker built by hand) and, plainly, what doesn't yet: offline mode only, no
published image, no secure chat or locator bar, the open PRs that are coming. The site renders the
guide into /server at build time, so it has one source. The CSP hashes now cover every page's inline
script, so the privacy page's theme script is allowed too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:45:48 +02:00
Ludovic MarieandClaude Opus 5.5 c7ad9b861f Swimming pose and stroke on the player model
The player's model never read the swimming pose: sprint-swimming and
crawling drew it upright and walking. Add LivingEntity.swimAmount
(eased 0.09 a tick toward isVisuallySwimming), AvatarRenderer's swim
rotation (tipped by the pitch in water, -90 out of it, laid along the
swimming box) and HumanoidModel.setupSwimAnimation (the 26-unit arm
stroke, head down, flutter kick). The avatar rotations now follow
LivingEntityRenderer.setupRotations' death and upside-down turns, as in
vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:45:23 +02:00
Cosmic 78e05bdc95 Merge latest main into fix/classic-menu-default 2026-10-17 02:41:52 -04:00
Ludovic Marie 4410c3ba44 Merge pull request #656 from LookImEcho-Rick/codex/fix-multiplayer-world-options
Fix multiplayer pause menu and World Options permissions
2026-10-17 08:39:44 +02:00
Cosmic a195b4849e Merge main into fix/classic-menu-default 2026-10-17 02:39:42 -04:00
Ludovic MarieandClaude Opus 5.5 d85a16daf8 Merge main into feat/server-online-mode
lan/conn.rs imports: keep this branch's AtomicU32 and main's Mutex/PoisonError.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:34:34 +02:00
Ludovic MarieandClaude Opus 5.5 94209a32fc Merge main into perf/versioned-behaviours-cache
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:25:56 +02:00
Ludovic Marie 123987c459 Merge pull request #649 from ludovic111/perf/server-batched-sends
LAN hub: hand a tick's packets to each connection together
2026-10-17 08:24:51 +02:00
Ludovic Marie 4d4e5ebe75 Merge pull request #590 from ludovic111/feat/server-prepare-reports
horizon-prepare: keep the registry and command reports with the prepared files
2026-10-17 08:24:47 +02:00
Ludovic Marie 1e7517dc3d Merge pull request #667 from Online5277/fix/chest-breaking-effects
Draw breaking cracks on chest models
2026-10-17 08:23:51 +02:00
Ludovic Marie 48cee5f6d4 Merge pull request #665 from Online5277/fix/player-death-experience
Reset experience on respawn and drop capped XP on death
2026-10-17 08:23:47 +02:00
Ludovic Marie ef3cd64475 Merge pull request #607 from rdbtCVS/perf/shared-block-json-20261017
perf: reuse decoded gameplay and behaviour facts at startup
2026-10-17 08:16:01 +02:00
Ludovic MarieandClaude Opus 5.5 0a69d6b7bc Merge main and #607 into perf/versioned-behaviours-cache
level/data.rs, level/mod.rs: load, load_with_behaviour_json and
load_with_gameplay all go through one cached() helper (try the snapshot,
build and store on a miss), so #607's client path keeps this cache.
Resolution from contrib-review-1's validated integration, without #601.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:15:49 +02:00
Ludovic MarieandClaude Opus 5.5 6b0b34098e Benchmark: Horizon's server against vanilla 26.3 on equal work
Walk and explore passes at 1 to 100 bots on the ThinkPad (bots on the Mac
mini), vanilla with the locator bar off, plus an explore pass at a rate both
servers keep up with. The README has the method, the tables, the profile of
what remains at 25 walking bots and the earlier runs; docs/server.md section
9 and the Scale row summarise it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:09:50 +02:00
Ludovic Marie f0295c1671 Merge remote-tracking branch 'pr/607' into HEAD 2026-10-17 08:01:45 +02:00
Ludovic Marie 23db75c429 Merge pull request #614 from LookImEcho-Rick/codex/fix-world-input-capture
Fix mouse capture after world loading
2026-10-17 08:01:18 +02:00
Ludovic Marie 9665a7c5b0 Merge pull request #609 from LookImEcho-Rick/fix/563-translucent-block-outline
fix(rendering): correct selection outline on translucent blocks
2026-10-17 08:01:13 +02:00
Ludovic MarieandClaude Opus 5.5 edffb857e1 LAN chunks: compute a column's light once, and keep sections' light layers
encode_in built the light layers twice (encode filled them for every
section, then encode_in replaced them with the ones that exist around the
column). On the ThinkPad at 25 walking bots light_data was 8 % of the server
thread. The light is now built once, and a section's packed DataLayers stay in
the section cache while the section is unchanged (relighting moves it to a new
address, as an edit does). Uniform light is filled directly, not kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 07:56:06 +02:00
Ludovic MarieandClaude Opus 5.5 6c6b58dd55 Server: the level follows its lone player through Nether and End portals
The server holds one level at a time, as the in-frame game does. While one
player is online (single player on the server thread, or a world with one
player in it), a Nether or End portal swaps the server's level the
in-frame way (switch_level, split out of enter_dimension) and the player
is moved as ServerPlayer.teleport moves it into another level: a Respawn
packet keeping all data, the difficulty, permission level, teleport,
abilities, level info, inventory, effects and the portal travel sound,
then a teleport to the exit once the destination has loaded (the Nether
portal found or built, the End platform, the Overworld's spawn column).
Respawning away from the Overworld takes the level back there first.

What the hub sends follows the level:
- Chunks carry the level's sections only (y 0 to 255 in the Nether and the
  End), without sky light where the dimension has none, and heightmaps
  from the level's floor. Until now every level went out with the
  Overworld's 24 sections, which a client in the Nether or the End reads
  64 blocks off.
- The dimension type follows the level the hub names; the default spawn
  position is the Overworld's.
- A level switch keeps the block change log (an open hub kept none after
  a portal) and the sculk watch list.

Worlds whose player is in the Nether or the End now open on the server
thread too.

The network client maps the End to the End (it drew it as the Overworld)
and shows the death screen on its own PlayerCombatKill, as
ClientPacketListener.handlePlayerCombatKill does, whatever health came
last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 07:54:18 +02:00
Cosmic 7ee9b0c81d Render chest breaking overlays on their animated models 2026-10-17 01:53:10 -04:00
Cosmic 67633fc789 Reset player experience on respawn and award capped death XP 2026-10-17 01:46:07 -04:00
Ludovic MarieandClaude Opus 5.5 56a4989560 Network client: show the server's advancements and statistics
ClientboundUpdateAdvancementsPacket and ClientboundAwardStatsPacket are
new in the protocol crate, and the window applies them as vanilla's client
does:

- ClientAdvancements.update: a reset replaces the tree, added advancements
  join it with their place on the tab (DisplayInfo x, y), removed ones go,
  each changed one takes the progress sent, and one that is done in a
  non-reset update with show set gets its toast. The advancements screen
  lays a server's tree out where the server placed it and shows everything
  it sent.
- handleAwardStats: the counters the server sends, by registry name.
  Opening the statistics screen asks for them (StatsScreen.init's
  REQUEST_STATS).

Joining and leaving a server start from empty advancements and statistics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 07:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 206b91e8aa Launcher: Import modpack, its coverage report, port and play it as a profile
A Modpacks page between Versions and Patch notes (docs/modpacks.md, the
working version's launcher step):

- Import a pack file (.mrpack, CurseForge zip) or an instance folder (Prism,
  MultiMC, CurseForge): horizon-port-pack's import, inventory (Modrinth by
  file hash) and report into ports/<slug>/ in the game's home.
- The coverage report: ported, partial, not yet and not needed, with every
  mod's class, tier, licence and reason, filtered by status.
- Port: the portable mods' jars (content, data tier) the player's own way
  (their folder, the pack's links, Modrinth's CDN; what only CurseForge has
  needs its key and is said so), carried over for the selected version's
  prepared tables; the pipeline's log shows while it runs.
- Play: the game as the pack's own profile, ports/<slug>/home/ (its saves,
  options.txt copied from the player's the first time) with the carried-over
  mods as dev mods. The session flow is shared with Play (start_client).
- The same from the command line: horizon-launcher modpack list | import |
  port | play | remove.

Nothing is uploaded or shared; jars and ports stay in the game's home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 07:00:45 +02:00
Ludovic MarieandClaude Opus 5.5 ee104603dd horizon-port-pack: a library, fetch and port; CurseForge zips name their mods
The launcher's "Import modpack" needs the pipeline as a library and a way to
get the jars a pack names without redistributing anything.

- The steps move into the library (pipeline.rs) with a line callback, so the
  command line and the launcher run the same code; main.rs is a thin CLI.
- fetch: the jars of the portable mods (content, data tier) into
  <work>/jars/, the player's own downloads: copied from a folder they have
  (by SHA-1, or a CurseForge app instance's minecraftinstance.json), else
  from the pack's own link (.mrpack hosts only) or the same file on
  Modrinth's public CDN found by SHA-1, kept only when the hash matches.
  What only CurseForge has is listed as needing a CurseForge API key.
  <work>/fetch.json records each file.
- port: every step after the import for the portable mods (fetch, the
  inventory again with the jars read, carry, report).
- A CurseForge zip whose overrides hold Crash Assistant's modlist.json (All
  the Mods 10 does) takes its jar names, mod ids and SHA-1s instead of bare
  project ids, so Modrinth answers for most files.
- inventory goes on offline when Modrinth cannot be reached.

All the Mods 10 8.3's CurseForge zip: 496 files named; of the 54 data-tier
content mods 42 download from Modrinth's CDN (84 MB) and 12 need the key or
the player's instance; 42 carried over (partial: the harness is a dev step).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 07:00:45 +02:00
Ludovic MarieandClaude Opus 5.5 6154eb95c3 Network players keep advancements, statistics and a recipe book on the server
Single player on its server thread, LAN guests and dedicated server players
are network players, and the server gave them none of these:

- PlayerAdvancements: each player's progress loads from and saves to
  players/advancements/<uuid>.json (an integrated server's owner keeps
  single player's file). The game's triggers, awards and the advancement
  commands work on the player the game acts for (as_guest swaps the
  progress in), every tick runs the tick, location and inventory triggers,
  and flushDirty sends ClientboundUpdateAdvancementsPacket with what became
  visible or hidden and the progress that changed. A player who has been
  here gets their saved advancements in the first packet; a new one gets
  the same first tick as before. Announcements go to everyone
  (PlayerList.broadcastSystemMessage).
- ServerStatsCounter: per-tick and movement statistics are counted for each
  player, saved to players/stats/<uuid>.json, follow the scoreboard
  criteria under the player's name, and go out in
  ClientboundAwardStatsPacket when the client asks (REQUEST_STATS), all of
  them the first time.
- ServerRecipeBook: the player file's recipeBook is kept, sent on joining
  (settings and every known recipe), and recipes unlocked later go out in
  ClientboundRecipeBookAddPacket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 06:53:44 +02:00
Ludovic MarieandClaude Opus 5.5 4a8355047e Merge main into perf/versioned-behaviours-cache
Main's startup now falls back to vanilla resources when packs fail; the
behaviours snapshot folder is set just before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 06:42:38 +02:00
LookImEcho-Rick aeaccb0413 Fix multiplayer pause menu and World Options permissions 2026-10-16 23:39:36 -05:00
Ludovic MarieandClaude Opus 5.5 cfcf69f0b9 Network client: apply the block entity data, difficulty, spawn, chunk centre, tick rate, animations and server data the server sends
With single player on its server thread, the window is the server's client
and dropped several packets the in-frame game never needed:

- ClientboundBlockEntityDataPacket and the block entities of chunk packets:
  the block entity of that type loads the tag (signs, spawners), as
  handleBlockEntityData and LevelChunk.replaceWithPacketData do.
- ClientboundChangeDifficultyPacket: the level data's difficulty and lock.
- ClientboundSetDefaultSpawnPositionPacket: the respawn data spawn
  compasses point at.
- ClientboundSetChunkCacheCenterPacket: ClientChunkCache's view centre;
  columns outside the storage (the server's radius + 3) are ignored or
  dropped, as ClientChunkCache.Storage does.
- ClientboundAnimatePacket: wake up, and the critical and magic critical
  hit emitters.
- ClientboundTickingStatePacket / ClientboundTickingStepPacket (new in the
  protocol crate): the level's TickRateManager. A frozen level holds its
  time and non-player entities, and the server's rate only ever slows the
  client timer down (Minecraft.getTickTargetMillis).
- ClientboundServerDataPacket: the server list entry keeps a valid icon
  (ServerList.saveSingleServer); single player has no entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 06:37:36 +02:00
Ludovic Marie 99d5ebea1a Merge pull request #626 from ludovic111/fix/server-block-break
Keep server blocks in sync with what guests dig
2026-10-17 06:11:12 +02:00
Ludovic Marie b9c6f318ac Merge pull request #644 from ludovic111/fix/furnace-on-server
Open furnaces, blast furnaces and smokers on a server
2026-10-17 06:10:51 +02:00
Ludovic Marie 03ff05c317 Merge pull request #652 from ludovic111/fix/launcher-complete-reports
Launcher: complete prepared versions that lack the run-time reports
2026-10-17 06:09:25 +02:00
Ludovic Marie 6a19e40092 Merge pull request #519 from Online5277/fix/equipment-attribute-tooltips
Show equipment attributes in inventory and Creative tooltips
2026-10-17 06:05:13 +02:00
Ludovic Marie b4481e9aad Merge pull request #627 from ludovic111/fix/offhand-on-server
Swap and sync the off hand on a server
2026-10-17 06:04:38 +02:00
Ludovic Marie 16721b0ab5 Merge pull request #651 from Online5277/fix/horizon-pause-hud
Hide survival bars consistently behind the Horizon pause menu
2026-10-17 06:04:16 +02:00
Ludovic Marie 543d04ba44 Merge pull request #650 from Online5277/fix/language-search-hint
Keep the language search hint clear of the caret
2026-10-17 06:04:12 +02:00
Ludovic Marie 2aba405281 Merge pull request #646 from Online5277/fix/resource-pack-reload-errors
Fix resource pack reload failures and broken Horizon menus
2026-10-17 06:04:07 +02:00
Ludovic MarieandClaude Opus 5.5 ed09e757ba Pathfinder: hash nodes with the Fx hasher, and work out each path type once
Path searches (vanilla's PathFinder with WalkNodeEvaluator) were 14 % of
the world tick with 80 villagers. Two costs vanilla doesn't have:

- the node index and the per-query path type and collision caches used the
  standard SipHash; vanilla keys them by packed position in fastutil maps.
  They use the Fx hasher now (nothing iterates them, so no order changes);
- a position's static path type (its block, the one below and up to 24
  neighbours, `getPathTypeStatic`) was worked out again for every mob box
  that covered it. Vanilla caches the block part level-wide
  (`PathTypeCache`); the finder now remembers each position's type for the
  query, during which the world doesn't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:57:47 +02:00
Ludovic MarieandClaude Opus 5.5 cceda07476 Sculk: find a loaded chunk's sensors from its palettes, not every block
When chunks arrive, the sculk index looks through them for sensors,
shriekers and catalysts. It unpacked and tested all 4,096 blocks of every
section that wasn't uniform: 10 to 18 ms in a tick after a world loaded,
more over a larger render distance (#489).

A section whose palette holds no sculk state has no sculk block, so it is
passed over without reading its blocks; the others are scanned as before,
and the same blocks are found.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:57:42 +02:00
Ludovic MarieandClaude Opus 5.5 1efc7f83d1 Entity portal checks: skip the sweep where no section holds a portal
Every tick, each item and mob that can use a portal sweeps the blocks its
box passed through since the last tick, looking for a nether portal block
(`Entity.checkInsideBlocks` reaching `NetherPortalBlock.entityInside`).
Each sweep allocated a visited set and read every block of the path.

Every block a sweep visits lies in the box covering the entity's box at
both ends. When no section that box touches has a nether portal state in
its palette, no visited block is a portal: the sweep is skipped. Sections
are checked once per tick, and a box over more than 64 sections (an
entity teleported far) is swept as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:55:41 +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 Marie 51703fdf3b Merge remote-tracking branch 'pr/626' into HEAD
# Conflicts:
#	crates/horizon-client/src/lan/guest.rs
2026-10-17 05:42:28 +02:00
Ludovic MarieandClaude Opus 5.5 c5851e77aa horizon-bots: --explore-speed, for an explore pass with equal work
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:42:27 +02:00
Ludovic MarieandClaude Opus 5.5 75ee9a3299 Build a saved chunk's structure starts when it loads, not on the game thread
A loaded world had none of its structure starts in the generator's cache.
The first time natural spawning (or anything else) asked which structures
hold a position in a chunk referencing a village, the game thread built the
village start: 60 to 120 ms in one tick, once right after the world opened
and again a few ticks later for the next start (#489).

Vanilla reads the starts with the chunk. The streaming worker that loads a
saved chunk now builds the starts it references into the same cache, so the
game thread finds them there. The starts are the same: the same function
builds them from the same seed and chunk, only earlier and on a worker.

The cache now keeps the first starts built for a chunk when two threads
build them at once, so every user shares one start and its pieces'
placement state, as vanilla's single StructureStart does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:35:24 +02:00
Ludovic MarieandClaude Opus 5.5 1a68d8ddf0 Merge main into fix/furnace-on-server
lan_actions.rs: clear the old body's potions (main), then respawn with
the tick context (this branch). hub.rs: keep main's effect and chunk
tracking reset before this branch's vanilla respawn sequence.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:35:16 +02:00
Ludovic MarieandClaude Opus 5.5 591e6f9161 Merge main into fix/equipment-attribute-tooltips
Picks up #572, the fix for main's broken build that this branch's last
main merge caught.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:30:34 +02:00
Cosmic 36ea3a4140 Hide the gameplay HUD consistently behind Horizon pause menus 2026-10-16 23:21:07 -04:00
Cosmic 8f4eda4d12 Hide the language search hint while the field has focus 2026-10-16 23:19:47 -04:00
Ludovic MarieandClaude Opus 5.5 3cd26c6d0b server_bench: --gamerule, more game rules for both servers
Horizon has no locator bar yet, so equal-work runs turn vanilla's off
(--gamerule locator_bar=false): its waypoint updates cost every move
a pass over every other player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:17:33 +02:00
Ludovic MarieandClaude Opus 5.5 c8003301bc horizon-bots: vanilla's chunk batch sizing, chunk-gated movement and chunk accounting
The bots acknowledge chunk batches with ChunkBatchSizeCalculator's
desired chunks per tick, from how long each batch takes to arrive,
instead of a fixed 25. A bot moves only while it holds the chunk it
stands in, as vanilla's client stops ticking its player in a chunk it
hasn't received, so no bot outruns a server's chunk sending. Each bot
keeps the chunks it holds, so the report separates chunks that entered
its view from chunks sent again while it still held them, and counts
the moves the server refused and the ticks it waited for its chunk.

server_bench.py runs one pass per behaviour (walk, explore), spawns
every bot on the world spawn itself (respawn_radius 0) so both servers
see the same paths, can run the bots on another machine (--bots-via,
--bots-bin, --connect) and the servers under nice, and reports new,
dropped and re-sent chunks per second, pull-backs and the server's
"moved too quickly" lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:17:33 +02:00
Ludovic MarieandClaude Opus 5.5 b347ceb195 Dedicated server: vanilla's chunk pacing and movement checks
PlayerChunkSender: one unacknowledged batch until the client's first
acknowledgement and ten after it, batches capped at the client's
desired chunks per tick (not twice that), and the quota reset to one
chunk whenever every batch is acknowledged.

ServerGamePacketListenerImpl.handleMovePlayer: a move further from
where the tick began (firstGood) than 100 blocks squared per move
packet (300 while gliding; the count falls back to 1 past 5 packets)
logs "<name> moved too quickly!" and pulls the player back, as the
player_movement_check and elytra_movement_check game rules allow.
NaN coordinates or non-finite angles and two position moves in one
client tick disconnect with invalid_player_movement, positions are
clamped to the world's limits, an unanswered teleport is sent again
after 20 ticks, and accepting a teleport runs the move it carries.

The move is then replayed against the blocks: a survival or adventure
player who ends more than 0.25 blocks short of the target (horizontally;
vanilla drops the vertical part) logs "<name> moved wrongly!" and is
sent back unless they were already stuck, and any move into a block
the old box did not touch (isEntityCollidingWithAnythingNew) is sent
back silently. Spectators and sleepers skip both. Angles wrap as
Mth.wrapDegrees does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:16:08 +02:00
Ludovic MarieandClaude Opus 5.5 5725ee0efa Give the shared-JSON profiler its own name
Every PR of the startup batch adds a different tools/profile_client_startup.py,
so any two of them conflict. This one becomes tools/profile_shared_json.py; its
guide and tools/profile_gameplay_decode.py follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 05:13:10 +02:00
Ludovic Marie bf851bfea1 Merge pull request #648 from ludovic111/feat/modpack-bench
Content mods: refuse more block states than chunks hold; the pack against NeoForge, measured
2026-10-17 05:01:42 +02:00
Ludovic Marie ead46669a5 Merge pull request #641 from Online5277/fix/container-mouse-hotkeys
Honor mouse-bound hotbar swaps in inventories
2026-10-17 04:58:55 +02:00
Ludovic MarieandClaude Opus 5.5 02dce1093b Give the behaviour profiler its own name
Every PR of the startup batch adds a different tools/profile_client_startup.py,
so any two of them conflict. This one measures only Behaviours::load, so it
becomes tools/profile_behaviour_cache.py, and --cache-dir passes the snapshot
folder to the example so warm loads can be measured.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:58:28 +02:00
Ludovic Marie 455967bd47 Merge pull request #647 from ludovic111/feat/sprite-arrays
Block textures: share identical sprites, and draw past 2048 layers
2026-10-17 04:58:13 +02:00
Ludovic Marie 1e514f8539 Merge pull request #633 from ludovic111/feat/port-pack-report
horizon-port-pack report: coverage per mod, and ATM10 measured
2026-10-17 04:58:09 +02:00
Ludovic Marie b0e31eb14f Merge pull request #632 from ludovic111/feat/neoforge-oracle-harness
Per-mod parity harness against a dev-only NeoForge oracle
2026-10-17 04:58:05 +02:00
Ludovic Marie 303c8d4452 Merge pull request #631 from ludovic111/feat/port-pack-oracle-carry
horizon-port-pack carry: templates and numbers from the NeoForge oracle, 1.21.1 loot upgraded
2026-10-17 04:58:00 +02:00
Ludovic Marie 03ca95e8af Merge pull request #630 from ludovic111/feat/mod-content-real-mods
Content mods: load real carried-over mods (large tables, convention tags, mod recipes)
2026-10-17 04:57:55 +02:00
Cosmic 1e597f356e Merge main and preserve resource reload and Mods screen tests 2026-10-16 22:56:10 -04:00
Ludovic MarieandClaude Opus 5.5 2822d4e1b7 Content mods: refuse more block states than chunks hold; the pack against NeoForge, measured
Loading the eight carried-over mods in the game (not only the probe) showed
two limits real packs reach. Block textures past 2048 layers are another PR;
this one handles block states and measures a pack.

- Chunk sections store a block state in 16 bits. Vanilla 26.3 has 35,723
  states; Macaw's four mods add 36,154, the eight 41,389. Past 65,536 ids
  wrapped onto other blocks (a trapdoor became a hanging sign).
  mod_content::build now refuses a set of mods past MAX_STATES and names the
  states per mod; the game then starts without content mods, as for any
  refused content.
- tools/mod_port_harness.py probes each mod on its own against vanilla, as a
  player adding it would load it.
- tools/perf_bench.py --game neoforge: Minecraft 1.21.1 on NeoForge 21.1.259
  with --mod-jars and --deps, driven by a dev-only NeoForge port of the bench
  mod (mods/horizon/src/neoforge-bench); --port-work loads a work folder's
  carried-over mods into Horizon. tools/neoforge_oracle.py installs the
  client once under .local/neoforge-oracle (the installer, then the vanilla
  libraries it inherits, sha1-checked) and builds the driver. A NeoForge
  start that crashes before the driver loads (FML reading fml.toml while
  creating it) is retried once.

Measured (docs/modpacks.md): six ported mods in Horizon against the same
mods on NeoForge, Mac mini M4 Pro, 2 games per row on a loaded machine. The
world is ready 3.1x sooner at 8 and 16 chunks, the flights run 1.7x and
3.6x the frames, and Horizon's RSS is not lower (+1.1 GB with the mods).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:55:20 +02:00
Ludovic Marie 5f9dba6011 Merge pull request #639 from Online5277/fix/classic-extras
Make Extras accessible from Classic Options
2026-10-17 04:53:35 +02:00
Ludovic MarieandClaude Opus 5.5 103d44f72f Merge main into fix/container-mouse-hotkeys
game.rs: keep this branch's container hotbar-mouse check, then #637's
mouse-bound sort key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:50:43 +02:00
Ludovic MarieandClaude Opus 5.5 5118d824a6 LAN hub: hand a tick's packets to each connection together
Every packet sent to a guest woke its writer thread, and at 25 walking bots
those wake-ups (mpsc notify, semaphore_signal) were about a quarter of the
server thread's tick. While tick_lan runs, packets for each guest are held
and handed over in one Out::Packets at the end; a send outside a tick, or a
disconnect, first hands over what is held, so the bytes and their order are
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:47:35 +02:00
Ludovic MarieandClaude Opus 5.5 928f31e428 Merge main into fix/classic-extras
options_ui.rs: #585 put Mods... in the telemetry slot; keep it and put
Horizon Extras... in the credits slot as this branch intended.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:47:11 +02:00
Cosmic 7575719d11 Reuse the GPU device and presentation mode across pack reloads 2026-10-16 22:46:47 -04:00
Ludovic Marie 6c5b04694e Merge pull request #642 from Online5277/fix/redstone-ore-attack
Light both redstone ore variants when mining starts
2026-10-17 04:42:19 +02:00
Ludovic Marie 65e5640350 Merge pull request #640 from Online5277/feat/fullbright-extra
Add an off-by-default Fullbright toggle in Extras
2026-10-17 04:42:15 +02:00
Ludovic Marie b0a82da895 Merge pull request #638 from Online5277/fix/inventory-preview-layout
Correct inventory player scale, labels and equipment overlap
2026-10-17 04:42:11 +02:00
Ludovic Marie f3dd2c27e5 Merge pull request #637 from Online5277/fix/extras-mouse-bindings
Support mouse buttons for Extras sorting and zoom
2026-10-17 04:42:07 +02:00
Ludovic Marie 6eba3f034e Merge pull request #585 from Online5277/fix/classic-mod-management
Add mod management to Classic Options
2026-10-17 04:42:03 +02:00
Cosmic e0d6e56281 Release Wayland surface before replacing the pack renderer 2026-10-16 22:41:33 -04:00
Cosmic 5515cbab33 Handle resource pack reload failures and reset menu atlas cache 2026-10-16 22:34:01 -04:00
Ludovic Marie 107671b1e8 Merge pull request #547 from Online5277/fix/banner-item-components
Fix Ominous Banner data, tooltips and breaking effects
2026-10-17 04:31:41 +02:00
Ludovic MarieandClaude Opus 5.5 2e98f6a18a Let the behaviour profiler use a snapshot folder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:25:02 +02:00
Ludovic MarieandClaude Opus 5.5 e7f7236c34 Open furnaces, blast furnaces and smokers on a server
Right-clicking a furnace as a guest did nothing: the host only handed
chests, barrels, hoppers, dispensers and shulker boxes over. It now opens
the furnace's own menu (minecraft:furnace, blast_furnace or smoker),
runs the guest's clicks through the host's AbstractFurnaceMenu (fuel slot,
take-only result, shift-clicks by recipe and fuel, a new input restarts
cooking) and keeps the client's progress arrows in step with
ContainerSetData. The dedicated server harness now opens a furnace and
smelts an ore in it on both servers.

Fixes #540

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:17:54 +02:00
Ludovic MarieandClaude Opus 5.5 40970589be Wrap mixed translation arguments once, so vanilla clients can read /give
component_nbt wrapped the numbers of a mixed list under the empty key, and
the NBT writer then wrapped those wrappers again, as ListTag.wrapIfNeeded
does. A vanilla client reading /give's reply ("Gave 1 [Raw Iron] to ...")
unwrapped once, found a {"": 1} argument and dropped the connection. The
list is now left to the writer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:17:54 +02:00
Ludovic Marie 8ff9247edb Merge pull request #643 from ludovic111/ci/parity-timeout
CI parity: raise the per-harness timeout to 2400 s
2026-10-17 04:14:57 +02:00
Ludovic MarieandClaude Opus 5.5 0da61e660b CI parity: raise the per-harness timeout to 2400 s
world_spawn now takes about 1480 s on a loaded Mac mini runner, just
under the old 1500 s limit, so PRs failed on a timeout rather than a
mismatch (#567, #514).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:08:08 +02:00
Ludovic Marie d9dc5de701 Merge pull request #567 from ludovic111/perf/villager-poi-index
Villagers search an index of POI blocks instead of scanning every block
2026-10-17 04:07:54 +02:00
Ludovic Marie 892b8ca743 Merge pull request #591 from ludovic111/perf/stronghold-ring-cache
Returning to the Overworld reuses its stronghold ring positions
2026-10-17 04:07:42 +02:00
Ludovic MarieandClaude Opus 5.5 0789bf0719 Keep the behaviours snapshot in the game folder, keyed to the build
The resource folder is shared: worktrees and parity harnesses link one copy,
and the launcher copies a prepared folder to each new compatible version. A
snapshot there would be read by builds that resolve the tables differently
while the VERSION constant stays the same. Keep it in the game's own
.horizon-cache (set by the client; tools, tests and servers don't cache), add
the running executable to its key, and skip hashing the resources when there
is no snapshot. Postcard without default features drops heapless and the
unmaintained atomic-polyfill from the lockfile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 04:06:23 +02:00
Cosmic 6d8b174216 Depth-test inventory preview equipment and clip it with a scissor 2026-10-16 22:03:57 -04:00
Ludovic Marie 1e45fd3697 Merge pull request #604 from ludovic111/perf/sp-server-state
Integrated server: control socket and chat read the server's side
2026-10-17 03:58:42 +02:00
Ludovic Marie d6d97e087f Merge pull request #603 from ludovic111/perf/sp-open-to-lan
Integrated server: Open to LAN
2026-10-17 03:56:44 +02:00
Ludovic MarieandClaude Opus 5.5 5ad2d80908 Merge perf/sp-open-to-lan into perf/sp-server-state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:51:11 +02:00
Ludovic MarieandClaude Opus 5.5 c2f2e15643 Merge main into perf/sp-open-to-lan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:51:10 +02:00
Ludovic Marie 1d79cf69e1 Merge pull request #602 from ludovic111/perf/vanilla-view-distance
Draw the columns vanilla draws (ChunkTrackingView.isInViewDistance)
2026-10-17 03:48:27 +02:00
Ludovic Marie 604d83ddcd Merge pull request #596 from ludovic111/perf/sp-server-effects
Network players have status effects
2026-10-17 03:48:19 +02:00
Ludovic Marie 2ea6689f46 Merge pull request #570 from ludovic111/perf/sp-pause-labels
Integrated server: single-player pause menu labels
2026-10-17 03:48:15 +02:00
Ludovic Marie c2c7d7f4bb Merge pull request #569 from ludovic111/perf/sp-server-fonts-atlas
Integrated server: build only the font, not the whole HUD atlas
2026-10-17 03:48:11 +02:00
Cosmic cf7ed868b9 Light redstone ore when mining begins 2026-10-16 21:46:47 -04:00
Cosmic d98ee9a760 Route mouse-bound hotbar keys through container swaps 2026-10-16 21:44:57 -04:00
LookImEcho-Rick cde0703645 Merge main and preserve capture and mining release behavior 2026-10-16 20:44:16 -05:00
Cosmic 8fa5c38663 Add an optional Fullbright extra 2026-10-16 21:42:01 -04:00
Ludovic MarieandClaude Opus 5.5 803aee8beb Merge perf/sp-open-to-lan (with main) into perf/sp-server-state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:41:16 +02:00
Ludovic MarieandClaude Opus 5.5 bbed107a6a Merge main into perf/sp-open-to-lan
integrated.rs: keep both the LAN port flag and #594's smoothed tick time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:41:08 +02:00
Cosmic fab2a7cb06 Expose Horizon Extras in Classic Options 2026-10-16 21:40:07 -04:00
rdbtCVS 2a593ea64f Merge remote-tracking branch 'origin/main' into perf/shared-block-json-20261017 2026-10-17 02:37:52 +01:00
rdbtCVS ef633b252f test: run parity from worktrees and document startup profiles 2026-10-17 02:37:44 +01:00
Cosmic b174299f25 Match vanilla player render scale and inventory title height 2026-10-16 21:37:36 -04:00
Cosmic b2f0af559b Accept and handle mouse bindings for Extras 2026-10-16 21:34:18 -04:00
Ludovic Marie ac87384749 Merge pull request #594 from Online5277/fix/debug-packet-rates
Show live packet rates and server brands in F3
2026-10-17 03:31:54 +02:00
rdbtCVS 7b7c4eb083 Merge remote-tracking branch 'origin/main' into perf/shared-block-json-20261017 2026-10-17 02:27:02 +01:00
rdbtCVS 73ac737ccf perf: release startup decode buffers and profile parsing tradeoffs 2026-10-17 02:26:56 +01:00
Ludovic MarieandClaude Opus 5.5 24ac4e149b Swap and sync the off hand on a server
On a server the F key swapped the hands only on the client, and the
inventory menu's off-hand slot (InventoryMenu slot 45) was missing from the
client's slot table, so clicks on it went nowhere and the server's updates
of it were dropped (#543).

- F sends ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND and predicts
  nothing, as vanilla's Minecraft.handleKeybinds does (not for spectators).
- Inventory menu slot 45 is the off hand; SetPlayerInventory slot 40 too.
- The server swaps only for non-spectators and stops the item in use
  (ServerGamePacketListenerImpl.handlePlayerAction).

Fixes #543

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:17:16 +02:00
Ludovic MarieandClaude Opus 5.5 256ecc4daa Keep server blocks in sync with what guests dig
A guest's STOP_DESTROY_BLOCK cleared the digging before the host's own
count was done, so a block the client broke stayed on the server and the
player fell through or froze on it. The host now follows
ServerPlayerGameMode: it breaks the block when its progress since START is
at least 0.7 and otherwise finishes the dig alone (the delayed destroy).
Block acks go out after the tick's block updates, and the client keeps
predicted blocks until the server acknowledges them
(BlockStatePredictionHandler), so a refused break comes back instead of
being lost.

Fixes #500

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:16:38 +02:00
Ludovic MarieandClaude Opus 5.5 66d187ed06 Respawn on a server in vanilla's order, so vanilla clients leave the loading screen
A vanilla 26.3 client that died on a Horizon server stayed on its loading
screen after pressing Respawn (#566): ClientPacketListener.handleRespawn
starts a LevelLoadTracker that waits for the level info's "level chunks
load start" game event and the chunk under the player, and Horizon's
respawn sent neither the level info nor the chunk cache centre.

The hub's respawn now follows PlayerList.respawn: the entities paired with
the dead player are removed (one packet each), its chunks are dropped
without ForgetLevelChunk (as PlayerChunkSender does for a dead player) and
sent again, the abilities, then Respawn (with the last death location),
the teleport to the spawn facing its direction, the default spawn,
difficulty, experience, post effects, the level info, the permission
level, the chunk cache centre and the inventory. A dying guest also gets
the death animation event and no health update while dead, as vanilla's
ServerPlayer.die does.

Guests' commands also ran at the host's permission level: an operator on
a dedicated server could not run operator commands (and LAN guests ran
the host's level). They now run at the guest's own level, as the command
tree they are sent says.

The dedicated server harness covers it: after trading, the vanilla-codec
client is made an operator by the console, runs /kill, respawns, and the
packets from the death screen to the respawned inventory are compared
with vanilla's, then the level must load where it respawned. The client
also stops sending a movement packet beside each teleport acceptance
during the respawn: the 26.3 client sends the acceptance alone, and
vanilla's server kicks a second position in the same tick.

Fixes #566

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 03:11:16 +02:00
Ludovic MarieandClaude Opus 5.5 943dd210d7 Dedicated server: encode each chunk section once, not once per player
Chunk packets took nearly all of the server's tick with players moving
about: every send re-encoded the column for that player (each section
unpacked and re-paletted with linear searches, light layers packed a
nibble at a time, heightmaps primed again) and unpacked all 9 x 24
sections around it to find which light layers exist. At 50 walking
bots that was ~85 % of the tick, 280-470 ms ticks against vanilla's
33 ms on the benchmark machine.

The encoder now keeps what a resident section gives the wire (its
bytes, whether it holds block entity blocks, whether it is all air) by
the section's address. Sections are immutable behind Arc and edits go
through Arc::make_mut, so an edited section moves to a new address,
and the cache's Weak keeps an old address from being reused: the same
address is the same unchanged section. Heightmaps are kept per column
the same way. Block entity tags are still read for every packet. The
bytes sent don't change.

Also: Paletted::from_values uses an index table instead of linear
palette searches (the same palette order and bits), the all-air check
reads the palette first and stops at the first block, uniform sections
skip unpacking, and light layers pack two blocks per byte in one pass.

On a busy Mac mini with 50 walking bots (`tick query`): about 400 ms
ticks with the faster palette and air checks alone, 14 ms with the
caches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 02:59:44 +02:00
rdbtCVS cd61e11b56 perf: intern gameplay projection strings and verify resource overrides 2026-10-17 01:42:34 +01:00
Ludovic MarieandClaude Opus 5.5 1d3759bb77 Drop a redundant binding flagged by clippy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 02:40:33 +02:00
Ludovic Marie ad5f05a8c7 Merge pull request #606 from Online5277/fix/inventory-break-progress
Clear block breaking progress when opening inventory
2026-10-17 02:39:14 +02:00
LookImEcho-Rick 3230b6b88d Fix mouse capture when world loading completes 2026-10-16 19:24:05 -05:00
rdbtCVS cf837118e9 perf: defer behaviour construction and test fresh resource loads 2026-10-17 01:18:24 +01:00
LookImEcho-Rick 5d614f1cf6 fix(rendering): correct selection outline on translucent blocks 2026-10-16 19:03:48 -05:00
rdbtCVS 78791b79ce Cache resolved block behaviours across launches 2026-10-17 01:03:21 +01:00
rdbtCVS a01d47f28f perf: share decoded block facts during client startup 2026-10-17 01:02:09 +01:00
Cosmic 1fc3f6724e Cancel block mining immediately when opening screens 2026-10-16 19:59:23 -04:00
Ludovic Marie e422bd138a Merge pull request #595 from ludovic111/fix/prepare-again-wording
Name the launcher's real button in the incomplete-resources error
2026-10-17 01:46:40 +02:00
Ludovic MarieandClaude Opus 5.5 f03990f663 Integrated server: control socket and chat read the server's side
With single player on its server thread, the window is the server's
client, and some of what it answered was its own empty side:

- `horizon-ctl save` and `save-all` returned ok without saving: the
  window's save skips worlds it is connected to. They now save on the
  server thread (`Integrated::call`).
- `horizon-ctl chat-log` read the window's local chat, which never
  showed what the server said (command feedback, joins). It reads the
  connection's chat, as the agent's chat query already did.
- The game's own messages (Open to LAN, screenshots...) went to that
  local chat, which is not drawn while connected: they now join the
  connection's chat.
- `state` names the world (the server thread's `LevelName`).
- The client follows its permission level (EntityEvent 24..28,
  LocalPlayer.setPermissionLevel), which the commands the chat offers
  and checks depend on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:45:15 +02:00
Ludovic MarieandClaude Opus 5.5 2b1cd3e96e Name the launcher's real button in the incomplete-resources error
The launcher offers "Prepare again", not a Repair action.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:42:02 +02:00
Cosmic a024409a1a Show live connection packet rates and server brands in F3 2026-10-16 19:41:02 -04:00
Ludovic MarieandClaude Opus 5.5 34d1ef6510 Launcher: complete prepared versions that lack the run-time reports
The game reads the data generator's registries.json (and commands.json)
next to the resources (#573). Versions whose resources the launcher copied
from another installed version, or that were prepared before the preparer
put the reports there, have them only in the scratch output of the version
that ran the data generator, if anywhere. At start, on `versions`/`update`
and before starting the game or a server, the launcher now copies them in
from the version's own scratch output or any installed version pinning the
same server jar, so existing installs can join servers again without
preparing again.

Complements #576 (the preparer and the client side of #573).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:40:47 +02:00
Ludovic MarieandClaude Opus 5.5 7e430564a6 Returning to the Overworld reuses its stronghold ring positions
Each dimension change builds the destination's generator again, and with
it a new structure state, so the 128 stronghold ring searches ran again on
every return to the Overworld (about 1.5 s on two workers) and held up the
first new chunks. The ring positions depend only on the shared generator
data, the seed and the dimension's generator definition, so the generator
data now keeps them by those (the last eight), and later generators for
the same world and dimension share them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:39:02 +02:00
Ludovic Marie 64a4c72ebd Merge pull request #574 from Online5277/fix/direct-connect-address
Remember Direct Connection server addresses
2026-10-17 01:37:47 +02:00
Ludovic Marie 69d03e546a Merge pull request #575 from Online5277/fix/quit-button-sound
Preserve quit button sounds when leaving a world
2026-10-17 01:37:43 +02:00
Ludovic Marie 0f3a9e1508 Merge pull request #576 from Online5277/fix/prepared-registry-reports
Package registry reports and show recoverable server resource errors
2026-10-17 01:37:39 +02:00
Cosmic ac2cc43aa6 Add mod management to Classic Options 2026-10-16 19:35:47 -04:00
Ludovic MarieandClaude Opus 5.5 dc42425c01 Integrated server: Open to LAN
Single player on its server thread could not be opened to the LAN: the
window is a client of that server, and Open to LAN, /publish and
`horizon-ctl lan` tried to start a second hub on the window's side.

The server thread now publishes itself, as IntegratedServer.publishServer
does:

- The window asks its server with `Integrated::call` (executeBlocking):
  a task run between two ticks, while paused too, with its answer.
- Publishing binds the port at once (a busy port fails right away), then
  the hub starts listening and announcing the world (LanServerPinger)
  with the MOTD "<owner> - <world>". Until then only the owner may log
  in; afterwards anyone may, and status pings are answered.
- Unpublishing stops the listener and sends every player but the owner
  away (multiplayer.disconnect.server_shutdown).
- /publish and /unpublish run on the server for the player who types
  them, and the integrated server's command tree (with publish, without
  the dedicated-only commands) is what its players get.
- The owner's level follows Allow Commands; other players get game
  master commands only when guests may use commands and the world allows
  them (IntegratedServer.getCustomPermissionLevel). Every player's
  command runs at their own level.
- The world options screen reads and applies its settings on the
  server: difficulty, default and personal game mode, Allow Commands and
  the LAN settings; the window shows the outcome.
- A published world does not pause (Minecraft.pause needs
  !isPublished), and F3+Escape follows the same rule.
- Memory connections are never compressed, whatever the threshold
  (Connection.isMemoryConnection), so network players get the usual 256.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:33:34 +02:00
Ludovic MarieandClaude Opus 5.5 ae22d5dd96 Online mode: players named in commands are their accounts
An online-mode server checks players by their account's UUID, so op,
whitelist, ban and pardon now resolve a name as vanilla's
CachedUserNameToIdResolver does: a player on the server, then one seen
since the start (in memory; vanilla also keeps usercache.json), then
the services' profiles/getByName endpoint through the discovery
document. A name with no account is "That player does not exist", with
vanilla's "Couldn't find profile with name" warning. Offline servers
keep the offline profiles.

horizon-server does the same while the server is stopped (op,
whitelist add, and the players init writes), and removes entries by
the name already on the list. `init --offline` (or HORIZON_ONLINE=false)
sets online-mode off; init's note now warns about offline servers only.

The online-mode harness adds a sixth login: `whitelist add` for the
player who joined earlier needs no lookup and lets them in, and an
unknown name is looked up and refused, on vanilla's server and on
Horizon's (43 checks).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:31:19 +02:00
Ludovic MarieandClaude Opus 5.5 4f2674275d horizon-prepare: keep the registry and command reports with the prepared files
A dedicated server reads registries.json and commands.json (the data
generator's reports) next to the prepared resources, and otherwise only
finds them in the .local/datagen folder of a development checkout. A
prepared folder copied or mounted on its own, as the Docker image
expects, stopped the server at start with "registries.json not found
next to the resources". The prepare step now copies both reports into
the folder (again whenever the server jar changes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:31:19 +02:00
Ludovic MarieandClaude Opus 5.5 e7003e7d49 Dedicated server: online mode, and refused logins in the log
online-mode=true now authenticates players as vanilla's
ServerLoginPacketListenerImpl does. The server makes a 1024-bit RSA key
pair at startup ("Generating keypair"), sends it with a random challenge,
checks the challenge and decrypts the shared secret from the client's key
packet, encrypts the connection with AES/CFB8, and asks the session
service's verify endpoint (hasJoined, through the services discovery
document) whether the player joined with the server's hash, from the
"User Authenticator #N" thread. A verified profile keeps its UUID and
properties, so skins show in the player list; an unverified one is
refused with "Failed to verify username!", and an unreachable service
with authservers_down. The offline-mode warnings are only printed in
offline mode.

Refused logins are logged as vanilla logs them: "Disconnecting
<name> (/<address>): <reason>" and "<name> (/<address>) lost
connection: <reason>", with log-ips respected.

horizon-protocol gains RSA key generation (Miller-Rabin primes,
e = 65537), the X.509 encoding of the public key and PKCS#1 v1.5
decryption. test_online_mode_parity.py runs its logins against Horizon's
server too, plus a verified player the whitelist refuses, and compares
the two servers' log lines about the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:31:18 +02:00
Cosmic 6285a15cf3 Default to Classic menus while preserving explicit preferences 2026-10-16 19:15:02 -04:00
Cosmic d652b25ead Package registry reports and recover from incomplete server resources 2026-10-16 19:12:40 -04:00
Ludovic MarieandClaude Opus 5.5 7384db307d Block textures: share identical sprites, and draw past 2048 layers
Every block sprite is a layer of one texture array, and GPUs give an array
at most 2048 layers (Metal, most Vulkan drivers). Vanilla takes 1250; eight
real NeoForge mods carried over (docs/modpacks.md) take 2183, and the game
stopped at start with "GPU supports 2048 texture layers; 2200 required".

- Sprites: a still texture with the same pixels and mip settings as one
  already loaded reuses its layer (mods copy vanilla's textures under their
  own names): 17 layers fewer for those mods, none for vanilla.
- The renderer splits the layers over up to four arrays of 2048 (8192
  sprites). terrain.wgsl picks the array per fragment with derivatives taken
  in uniform control flow; a pipeline constant keeps vanilla, which needs one
  array, on the same single textureSample as before.
- HORIZON_SPRITE_SPLIT (dev only) forces a smaller split: vanilla drawn from
  three arrays of 512 renders the same blocks as from one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 01:10:35 +02:00
Cosmic 7f4636fcb5 Merge origin/main into equipment attribute tooltips 2026-10-16 18:53:05 -04:00
Cosmic 43d5ed8246 Play menu button clicks after world teardown 2026-10-16 18:52:00 -04:00
Cosmic e6068b1246 Remember Direct Connection addresses in both menus 2026-10-16 18:51:25 -04:00
Ludovic Marie cf4f95f3d8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-17 00:48:02 +02:00
Ludovic Marie f535d1b48c Merge pull request #556 from ludovic111/feat/horizon-bots
horizon-bots and tools/server_bench.py: the first server benchmark against vanilla
2026-10-17 00:47:54 +02:00
Ludovic Marie ee3a160cbd Merge pull request #555 from ludovic111/feat/horizon-server-panel
horizon-server: the web panel
2026-10-17 00:47:51 +02:00
Ludovic Marie 652e547b53 Merge pull request #554 from ludovic111/feat/horizon-server-packaging
horizon-server: install scripts, a Docker image, and boot services
2026-10-17 00:47:47 +02:00
Ludovic Marie f3f2301dce Merge pull request #553 from ludovic111/feat/horizon-server-backups
horizon-server: backups, restore and automatic backups
2026-10-17 00:47:43 +02:00
Ludovic Marie f04562c640 Merge pull request #552 from ludovic111/feat/horizon-server-control
horizon-server: start, stop, status, cmd and logs through a local control API
2026-10-17 00:47:39 +02:00
Ludovic Marie 000fafa9bd Merge pull request #551 from ludovic111/feat/horizon-server
horizon-server: server.toml, init, and a supervisor that runs the game
2026-10-17 00:47:35 +02:00
Ludovic Marie 7d57523c63 Merge pull request #572 from ludovic111/fix/falling-damage-pack
Fix main build: falling anvil wear reads blocks from self.pack
2026-10-17 00:47:32 +02:00
Ludovic MarieandClaude Opus 5.5 3c28f55ce3 Merge main into fix/banner-item-components
Resolve game.rs and titles.rs against the resources split (#537): layer
names and item names now come from self.pack, the atlas from self.atlas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:47:22 +02:00
Ludovic MarieandClaude Opus 5.5 2e2645ba9d Fix main build: falling anvil wear reads blocks from self.pack
#528 was tested before #537 moved the asset registry from Resources to
the reloadable pack, so main stopped compiling after both merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:35:45 +02:00
Ludovic Marie b324ee7e3a Merge pull request #528 from Online5277/fix/falling-anvil-damage
Apply falling anvil impact damage and wear
2026-10-17 00:31:30 +02:00
Cosmic e5fcdaaec6 Show full equipment tooltips in Creative item tabs 2026-10-16 18:26:12 -04:00
Ludovic MarieandClaude Opus 5.5 361c994d98 Draw the columns vanilla draws: ChunkTrackingView.isInViewDistance
Vanilla draws a section when its column is within the render distance of
the camera's column with a column of slack on each axis
(SectionOcclusionGraph.isInViewDistance → ChunkTrackingView): 533
columns at render distance 12. Horizon meshed a circle of radius 12 (441
columns) and drew sections whose centre lay within 16 x distance + 24
blocks, so the edge of the world, mostly in fog, missed columns vanilla
shows, and fell short of vanilla's corners.

`streaming::in_view_distance` is vanilla's test. Streaming meshes the
columns it accepts around the player (and forced ones), and the
renderer's column walk draws the ones it accepts around the camera.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:23:02 +02:00
Ludovic MarieandClaude Opus 5.5 a71d148ca5 Network players have status effects
A player who joins over the network, which is now single player's own
player on its server thread, had no status effects: what they drank, ate
or were given went to the host, and their client never heard of any.

- Each guest has their own effects. `as_guest` trades them, and what
  their body was given for them (attribute modifiers, armor protection,
  enchantment modifiers), with the host's while the guest acts.
- They tick on the server with the guest's body (LivingEntity.baseTick's
  tickEffects), spectators included.
- The client is told as vanilla's ServerPlayer does: every effect when
  placed (PlayerList.sendActivePlayerEffects), then added (blended),
  updated, refreshed every 600 ticks left, and removed. An effect that
  only counted down is not sent again.
- The client keeps its own effects from those packets
  (LivingEntity.forceAddEffect), counts them down (tickClient), and its
  predicted movement, HUD icons and night vision follow them. A respawn
  starts without effects on both sides.
- Guests save and load them in their player file.

Saving also stops writing effects that have run out: the loaded player
compound kept the old `active_effects`, and they came back on reopen.
Potions in flight now tick on a server without a local player too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:22:53 +02:00
Ludovic MarieandClaude Opus 5.5 65a6ab7fa4 Merge main into fix/sulfur-cube-inner-layer
Resolve minecarts.rs against the resources split (#537): the block model
catalog now lives in self.pack. Fill MobData's new villager_hats and pois
fields in the sulfur cube render test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:21:04 +02:00
Ludovic MarieandClaude Opus 5.5 30d11d6a2e Integrated server: single-player pause menu labels
On its server thread a single-player world is joined like a server, so
the Horizon pause menu named it "Multiplayer" with a "Disconnect" button,
and F3+Escape opened the full menu as on a server. The server thread now
hands the window the world's name with its connection, the pause menu
shows that name and "Save and quit", and `has_singleplayer_server`
(Minecraft.hasSingleplayerServer) covers both ways of running a world for
the pause checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:12:08 +02:00
Ludovic MarieandClaude Opus 5.5 bd0dd979a0 Integrated server: build only the font, not the whole HUD atlas
The integrated server draws nothing, but it built the window's full HUD
atlas (GUI, item, block, entity and particle textures plus the run-time
slots). It now builds an atlas of the font sheets only, reads the glyph
widths from it and drops the pixels: text still measures the same (books
split their pages with it), without holding the textures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:12:08 +02:00
Ludovic Marie 19aac92bb7 Merge pull request #545 from ludovic111/perf/integrated-memory-connection
Integrated server: join through memory instead of a socket
2026-10-17 00:11:50 +02:00
Ludovic Marie 44c3ebb0d8 Merge pull request #544 from ludovic111/perf/integrated-server-thread
Run single player on an integrated server thread (opt-in)
2026-10-17 00:11:47 +02:00
Ludovic Marie 4e9495175c Merge pull request #538 from ludovic111/perf/game-constructor
Build the game in Game::new instead of inside run()
2026-10-17 00:11:42 +02:00
Ludovic Marie 9726af184b Merge pull request #537 from ludovic111/perf/resources-shared-split
Split Resources into shared read-only data and per-game pack state
2026-10-17 00:11:38 +02:00
Ludovic MarieandClaude Opus 5.5 0560bbf876 horizon-port-pack report: coverage per mod, and ATM10 measured
`report <work>` writes coverage.json and coverage.md: per mod ported,
partial, not yet or not needed with the reason, and the totals by class,
tier and licence family. It reads the inventory, each mod's carry report
and its harness result: ported is a green harness with nothing left out
(blocks with a class of their own are counted and named); partial lists
the failing checks, the blocks left out and what the templates miss; not
yet names the API tier or the dependency it waits for; not needed covers
libraries, performance mods and pack tooling. Unclassified mods are not
yet, never not needed.

`import` also reads Crash Assistant's modlist.json, the list ATM10
publishes in its repository (mod ids, names, versions, Modrinth hashes,
the loader entry), and the inventory uses its mod ids and names when
there is no jar.

ATM10 (496 files, the eight mods we downloaded carried and checked):
ported 1, partial 7, not yet 367, not needed 121.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 23:28:43 +02:00
Ludovic MarieandClaude Opus 5.5 32f5f01e22 Per-mod parity harness against a dev-only NeoForge oracle
The harness the modpack design calls for, for data-tier mods:

- tools/neoforge_oracle.py installs a NeoForge 21.1.259 server (ATM10's)
  under .local/neoforge-oracle, builds the exporter in
  mods/horizon/src/neoforge-oracle (our code, never shipped), runs it
  headless with the user's jars and writes every block (class chain,
  states, per-state hardness, light, tool need, map colour, sounds,
  outline and collision; drops for hand, diamond tools, shears and silk
  touch; placement; BlockSetType and WoodType), item, recipe and tag of
  the mods' namespaces, plus the vanilla blocks for templates. About 30 s.
- examples/mod_port_probe.rs builds the content root from the carried
  mods and dumps what Horizon's loaders make of the same things.
- tools/mod_port_harness.py compares them per mod and writes
  <work>/mods/<id>/harness/result.json. 1.21.1 sound events map to 26.3's
  through vanilla blocks both have, and vanilla items newer than 1.21.1
  are not counted against a recipe.

On ATM10's own jars (MIT): Macaw's Trapdoors passes everything; Doors,
Fences, Paths, Glassential, Factory Blocks and Cloud Glass pass every
drop, recipe, tag and placement and fail only on what API 0.2 cannot say
(shapes and sound events of their own, blocks of classes no vanilla block
shares). Jars, facts and results stay in the work folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 23:27:20 +02:00
Ludovic MarieandClaude Opus 5.5 886def4360 horizon-port-pack carry: templates and numbers from the NeoForge oracle, 1.21.1 loot upgraded
Carrying over seven real ATM10 mods (Cloud Glass, Factory Blocks,
Glassential, Macaw's Doors, Fences, Paths and Trapdoors) showed what the
guesses missed:

- Loot tables: 1.21.1's `conditions` lists, `functions` and
  `condition`/`function` kinds are now 26.3's single `condition`,
  `modifier` and `type`; silk-touch and shears tool matches become
  `minecraft:tool/can_silk_touch` and `can_shear`. Before, every drop
  condition was ignored (glass dropped itself by hand, upper door halves
  dropped doors, double slabs dropped one).
- With the oracle's facts (<work>/oracle/facts.json, from
  tools/neoforge_oracle.py in the next PR), every registered block gets
  the vanilla template of the same vanilla class and states that matches
  its shapes, sounds, BlockSetType and WoodType best, and the mod's own
  hardness, blast resistance, light, tool need, friction, map colour and
  sounds. Items get stack size and rarity. The carry report lists what a
  template does not reproduce and why a block was left out.
- Convention tags a recipe uses (c:dyes/red) are written from the
  oracle's tags, vanilla and own members only; the jar's own c tags stay
  inside the mod.
- Without the oracle, blockstates that omit properties their models
  ignore (powered, waterlogged) still find their template, and names pick
  between fences and panes, doors and trapdoors.
- `version = "${file.jarVersion}"` is read from MANIFEST.MF.
- `--resources <prepared>` limits templates to blocks Horizon has.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 23:25:54 +02:00
Ludovic MarieandClaude Opus 5.5 ddc115e969 Content mods: load real carried-over mods (large tables, convention tags, mod recipes)
Loading seven real NeoForge mods carried over by horizon-port-pack (about
1,000 blocks) broke the content root in four places:

- The per-state NBT tables (cached collision, occlusion, movement, worldgen
  block properties and support) passed the NBT reader's 1M-node cap once
  mod states were appended. They are trusted prepared tables with a row
  per state, so they now read and write with Limits::tables() (16M nodes,
  256 MiB) everywhere they are loaded.
- The overlay linked the base root by the path it was given, so a relative
  base gave dangling links. The base is made absolute first.
- Carried-over tags name blocks the port left out and other mods' items.
  Vanilla fails such a tag as a whole; entries naming ids nobody registers
  (or tags nobody defines) are now dropped, so the rest loads.
- Mods use the convention tags (c:dyes/red, c:glass_blocks/colorless).
  Content mods may add to data/c/tags/ as to vanilla's, and with content
  mods loaded the tag loaders read the c namespace and the mods' own.

Stonecutter and smithing recipes in mod namespaces load too (ids
<ns>:<name>), as crafting and cooking ones already did. With no content
mod nothing changes: the loaders read the minecraft namespace only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 23:24:34 +02:00
Ludovic Marie 5dac64f770 Merge pull request #561 from ludovic111/perf/section-culling-columns
Renderer: cull sections by chunk column from a dense list (section walk 5x faster, exact)
2026-10-16 23:04:53 +02:00
Ludovic Marie 89740d39e0 Merge pull request #560 from ludovic111/perf/skip-indirect-validation
Renderer: skip wgpu's indirect-draw validation in release builds (−0.05..0.11 ms/frame of render thread at RD 32)
2026-10-16 23:04:01 +02:00
Cosmic 53955abf99 Hide ominous banner pattern descriptions in tooltips 2026-10-16 16:56:14 -04:00
Ludovic MarieandClaude Opus 5.5 167339c03c Renderer: cull sections by chunk column, from a dense list
The per-frame section walk iterated a SipHash map of every resident
section, then looked each visible one up twice more by key. Resident
sections now live in a dense list grouped by chunk column: one distance
and frustum test rejects a whole column (exact: a section's box lies in
its column's box and the plane test rounds monotonically), the rest are
reached by slot, and the visible list sorts on integer keys with ties
broken by section key, so the order no longer depends on hash order.
FaceGroups looks its axes up in a table instead of searching for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:54:17 +02:00
Cosmic 86ccd5d299 Render banner breaking cracks and oak-plank particles 2026-10-16 16:40:29 -04:00
Ludovic Marie 9b2825df34 Merge pull request #517 from Online5277/fix/world-list-text-details
Correct Classic world-list search hints and text placement
2026-10-16 22:32:01 +02:00
Ludovic Marie 7a27781ab5 Merge pull request #512 from Online5277/fix/classic-graphics-duplicates
Remove duplicated settings from Classic graphics submenus
2026-10-16 22:31:55 +02:00
Ludovic Marie af15fb1a98 Merge pull request #511 from Online5277/fix/cloud-face-alpha
Preserve cloud opacity when shading faces
2026-10-16 22:31:49 +02:00
Ludovic Marie 4ff55f7a99 Merge pull request #510 from Online5277/fix/fullscreen-all-screens
Handle fullscreen shortcut across menus and text fields
2026-10-16 22:31:42 +02:00
Ludovic MarieandClaude Sonnet 5.5 6b86fa9bef Merge feat/horizon-server-panel into feat/horizon-bots
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:30:45 +02:00
Ludovic MarieandClaude Sonnet 5.5 b9d4ef3194 Merge feat/horizon-server-packaging into feat/horizon-server-panel
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:38 +02:00
Ludovic MarieandClaude Sonnet 5.5 949751656a Merge feat/horizon-server-backups into feat/horizon-server-packaging
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:35 +02:00
Ludovic MarieandClaude Sonnet 5.5 500241cbc6 Merge feat/horizon-server-control into feat/horizon-server-backups
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:18 +02:00
Ludovic MarieandClaude Sonnet 5.5 2483f90779 Merge feat/horizon-server into feat/horizon-server-control
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 22:28:06 +02:00
Ludovic Marie 07f7e3aeab Merge remote-tracking branch 'origin/main' into srv-fix
# Conflicts:
#	Cargo.toml
2026-10-16 22:27:25 +02:00
Ludovic Marie a1b0d95db1 Merge pull request #557 from ludovic111/docs/server-design
docs/server.md: hosting a Horizon server, the design
2026-10-16 22:25:05 +02:00
Ludovic MarieandClaude Opus 5.5 9c03535011 docs/server.md: hosting a Horizon server, the design
What the dedicated server does today (measured against vanilla 26.3), the horizon-server supervisor and
its packaging, server.toml, the CLI and TUI, the web panel, backups and updates, the control API and MCP,
the bot benchmark plan against vanilla and Paper, the working version for 2026-10-23, and the PRs that
build it (#551 to #556).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:22:56 +02:00
Ludovic MarieandClaude Opus 5.5 820936b975 horizon-bots and tools/server_bench.py: the first server benchmark against vanilla
horizon-bots is a load client on horizon-protocol for any 26.3 server: offline login, configuration,
keep-alives, pings, teleports, chunk-batch acknowledgements, and movement at 20 Hz. The bots play in
creative and fly above their spawn point, so they need no world of their own and no server refuses their
moves; idle, walk, explore and mixed behaviours are deterministic from a seed.

tools/server_bench.py runs the same bots on Horizon's server (horizon-server run) and on the pinned vanilla
jar, each fresh from the same server.properties, and measures MSPT from each server's own /tick query,
RSS and CPU of the process tree, the chunks the bots receive, and the start time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:21:19 +02:00
Ludovic MarieandClaude Opus 5.5 7f77772e15 horizon-server: the web panel
One page embedded in the binary (crates/horizon-server/panel: HTML, CSS and JS, no framework or CDN): status,
live TPS, MSPT, RAM and CPU graphs over the last 10 minutes, players with op and kick, the console, and
backups with restore, plus restart and stop.

It listens on [panel] bind, 127.0.0.1:8130 by default; a tailnet address reaches it from the owner's
devices, and any other address is refused unless allow_public is set. `horizon-server panel` prints a
one-time login link (10 minutes) that becomes an HttpOnly, SameSite=Strict session cookie. The Host header
must name the panel, every POST needs X-Horizon: 1, failed logins are rate-limited, and the page is served
with a strict Content-Security-Policy. The control API gains GET /api/v1/metrics and, with the token
only, POST /api/v1/panel/login.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:18:46 +02:00
Ludovic Marie 36b3fef0ed Merge pull request #533 from ludovic111/feat/port-pack-carry
horizon-port-pack carry: a jar's data and assets as a content mod
2026-10-16 22:13:21 +02:00
Ludovic Marie e21919c114 Merge pull request #532 from ludovic111/feat/port-pack
horizon-port-pack: import a modpack and classify its mods
2026-10-16 22:13:13 +02:00
Ludovic Marie cff7a66881 Merge pull request #531 from ludovic111/feat/mod-content-game
Content mods in the game: loot, recipes, models, names and icons from mod namespaces
2026-10-16 22:13:05 +02:00
Ludovic Marie 0c532fc1b0 Merge pull request #530 from ludovic111/feat/mod-content-root
Content mods in the resource root: template rows appended to every block table
2026-10-16 22:12:57 +02:00
Ludovic Marie cbb8c6a239 Merge pull request #523 from ludovic111/feat/mod-content
Content mods (API 0.2): .hmod assets/ and data/, blocks and items from vanilla templates
2026-10-16 22:12:48 +02:00
Ludovic MarieandClaude Opus 5.5 db45b6f87a Integrated server: join through memory instead of a socket
The window now joins its server thread through an in-memory pipe, as
vanilla's integrated server player does (LocalChannel), rather than a TCP
socket on 127.0.0.1. The server opens no port until it is opened to the LAN.

- horizon-protocol: Connection runs over TCP or one end of a Pipe (a
  blocking byte pipe with read timeouts and shutdown; dropping an end
  closes it).
- Lan: NetConfig.listen false starts no listener; connect_local serves the
  other end of a pipe on its own thread, through the same handshake, login
  and configuration as a socket.
- Session::connect_local joins through a pipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:13 +02:00
Ludovic MarieandClaude Opus 5.5 56dc4352d2 Run single player on an integrated server thread (opt-in)
With --server-thread or HORIZON_SERVER_THREAD=1, opening a single-player
world starts a game of its own on a "Server thread" over the shared
resources, as vanilla's IntegratedServer does, and the window joins it as a
player over a local connection. The world tick then never runs inside a
frame.

- The server listens on 127.0.0.1 only, lets in the window's profile alone,
  without compression, and makes it an operator when the world allows
  commands.
- The window's player is the world's own player: it loads from and saves to
  players/data/<singleplayer_uuid>.dat, so the world opens the same way in
  either mode.
- A pause screen pauses the server (IntegratedServer.tickServer), and
  leaving the world or quitting waits for the server to save.
- The integrated server does not mesh sections it never draws.
- Worlds whose player is outside the Overworld open the usual way for now.
- The bench summary reports the 0.1% low.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:12 +02:00
Ludovic MarieandClaude Opus 5.5 c2f4ecc188 Build the game in Game::new instead of inside run()
run() loads the shared resources and the pack, then hands them to a
constructor that builds a game with no world open. The same constructor will
build an integrated server's game on its own thread. The struct literal moves
unchanged; Options derives Clone so a server can start from the client's.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:12 +02:00
Ludovic MarieandClaude Opus 5.5 c57c409f40 Split Resources into shared read-only data and per-game pack state
Resources now holds only what is loaded once and never changes (blocks,
items, recipes, biomes, mobs, collision, motion) and is Send + Sync behind
an Arc, so an integrated server thread can share it with the client.

What a resource pack reload replaces moves to PackResources (assets,
meshing context, item renderer, names, panorama, clouds, forcefield), built
by one loader used at startup and on reload. The client-only mutable state
(the HUD atlas with its font cache and skin slots, fog state, screen effects)
becomes the game's own fields.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 22:06:12 +02:00
Ludovic Marie a08add231a Merge pull request #521 from Online5277/fix/villager-hat-metadata
Honor villager and zombie villager hat metadata
2026-10-16 21:33:06 +02:00
Ludovic Marie c7ee8baa25 Merge pull request #522 from Online5277/fix/classic-option-tooltips
Show translated hover tooltips in Classic options
2026-10-16 21:32:59 +02:00
Ludovic Marie 47182eccbf Merge pull request #546 from Online5277/fix/chest-slot-nine
Show the tenth storage slot in chests
2026-10-16 21:32:52 +02:00
Cosmic 8f86098a02 Show ominous banner names and rarity in HUD and Creative tooltips 2026-10-16 15:07:19 -04:00
Ludovic MarieandClaude Opus 5.5 05d1b92d56 Renderer: skip wgpu's indirect-draw validation in release builds
wgpu validates every indirect draw with a compute pass and a per-draw
batcher before the render pass, for untrusted WebGPU content. The section
draws are the only indirect draws and are in bounds by construction; the
validation cost about 0.1 ms of the render thread per frame at render
distance 32 on Metal. Debug builds keep it, and
WGPU_VALIDATION_INDIRECT_CALL=1 turns it back on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 21:04:17 +02:00
Ludovic MarieandClaude Opus 5.5 a2cce7486d Villagers search an index of POI blocks instead of scanning every block
AcquirePoi (job sites, beds, meeting points) and the bed searches read
every block of the sections in a 48-block sphere. Like vanilla's
PoiManager, each section now keeps the list of its POI blocks, rebuilt
from its blocks after an edit (a weak reference detects edited and
replaced sections). The searches visit the same positions in the same
order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:48:50 +02:00
Cosmic 3db9a250be Keep equipment attribute tooltip translations at runtime 2026-10-16 14:45:14 -04:00
Ludovic Marie cce44ad97d Merge pull request #514 from ludovic111/perf/stronghold-rings-on-all-workers
Stronghold ring searches run on every worker that needs them
2026-10-16 20:44:18 +02:00
Cosmic 4218a496b2 Preserve ominous banner components through placement and drops 2026-10-16 14:40:28 -04:00
Ludovic MarieandClaude Opus 5.5 24bb3f97aa Stronghold ring test: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:35:59 +02:00
Cosmic bfb6a0458d Restrict crafter result rendering to crafter menus 2026-10-16 14:33:44 -04:00
Ludovic MarieandClaude Opus 5.5 ed6af00ff8 horizon-server: install scripts, a Docker image, and boot services
- packaging/server/install.sh (Linux, macOS) and install.ps1 (Windows) install the latest release (or
  --version) from GitHub, check the SHA-256 GitHub publishes for it, and keep releases side by side:
  <dir>/current points at the one in use, so an update or a rollback is one link, and the game files
  prepared from the owner's Minecraft stay in <dir>/game-files across updates.
- packaging/server/Dockerfile builds the server and the engine; the prepared game files are mounted
  read-only, and the first start writes server.toml from HORIZON_EULA, HORIZON_WORLD, HORIZON_SEED,
  HORIZON_PORT, HORIZON_WHITELIST, HORIZON_PLAYERS and HORIZON_OP (`init` reads them too).
- `horizon-server service install|uninstall|print` writes and loads a systemd unit (user, or --system),
  a launchd agent, or a Windows task at logon. It runs the installer's launcher, so updates apply on
  the next restart.
- Releases build horizon-server and ship it beside horizon-ctl and horizon-prepare; PLAY.txt says how
  to run a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic MarieandClaude Opus 5.5 3510764cc6 horizon-server: backups, restore and automatic backups
`backup [NAME]` zips the world and the settings into backups/; on a running server it turns saving
off, flushes, zips and turns saving back on, so the game stays up. `restore BACKUP` puts a backup's
world in place and keeps the current one in backups/replaced-<time>/; a running server restarts with
it. `[backups] every = "6h"`, `keep = 12` in server.toml back up on a schedule and prune the
automatic backups only. The control API gains GET/POST /api/v1/backups and POST /api/v1/restore.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic MarieandClaude Opus 5.5 e8832190d1 horizon-server: start, stop, status, cmd and logs through a local control API
The supervisor serves a small HTTP API on 127.0.0.1 (a token in .horizon/api.json, mode 0600, and a
Host check): status, logs, console commands, stop and restart. `start` runs the server in the
background and returns once the game is up; `restart` and `cmd` wait for it too. A sampler reads the
game's tick rate, tick times, players and chunks every second over the existing control endpoint
(`--control-socket`), and RAM and CPU from the system.

The dedicated loop now records its tick times in the game clock, so `/tick query` reports real
numbers on a dedicated server, as it does in single player. The changes to game/dedicated.rs are
additive: the log capture for command replies, the tick counter and the control requests
(game/dedicated/status.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic MarieandClaude Opus 5.5 9d21209ae8 horizon-server: server.toml, init, and a supervisor that runs the game
The first part of hosting a Horizon server with one small program
(docs/server.md). horizon-server is a new 1.3 MB binary with no window,
GPU, audio or controller code; it runs the game headless
(horizon-client --dedicated) as its engine.

- horizon-server-files: server.properties, eula.txt and the user lists,
  moved out of horizon-client unchanged so both programs share vanilla's
  exact reading and writing; horizon_client::server re-exports it.
- server.toml: typed settings for what owners change, [properties] for
  any other vanilla key, vanilla 26.3's defaults (a test checks them
  against ServerProperties). It is written into server.properties before
  every start; keys the game changes there itself (/whitelist on) are
  copied back afterwards, keeping the owner's comments (toml_edit).
  `import` turns a vanilla server.properties into server.toml.
- init: the EULA (only a yes writes eula=true), world, seed, port (checked
  free), whitelist and first operator; every question has a flag, so it
  also runs unattended.
- run: the console on stdin, a clean `stop` on Ctrl-C, SIGTERM or SIGHUP
  (the game runs in its own process group), and a restart when the game
  crashes: 5 s, doubling to 5 min, giving up after 5 crashes in 10 min.
  One supervisor per folder (a file lock).
- op, deop and whitelist for a stopped server, with vanilla's messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:09 +02:00
Ludovic Marie b4ad65386c Merge pull request #507 from ludovic111/perf/collision-scan-empty
Collision scans: skip blocks with no shape, build the entity shape lazily (−6 % tick time)
2026-10-16 20:01:11 +02:00
Ludovic Marie 9d75d839aa Merge pull request #524 from ludovic111/perf/datapack-walk
Data packs: walk only the folders functions, tags and predicates live in
2026-10-16 20:00:55 +02:00
Ludovic Marie 38640fc845 Merge pull request #509 from ludovic111/perf/poi-state-table
Villagers: each block state's POI type from a table built once (−11 % CPU at world load)
2026-10-16 20:00:49 +02:00
Ludovic Marie 586acadd81 Merge pull request #508 from ludovic111/perf/mob-push-grid
Mobs: find the mobs to push through a grid, not a scan of every mob (−14 % tick time)
2026-10-16 20:00:42 +02:00
Ludovic Marie 72ec93a089 Merge pull request #506 from ludovic111/perf/supporting-block-cells
Mobs: shape only the cells that can support them (−11 % tick time)
2026-10-16 20:00:36 +02:00
Ludovic Marie 020e01746f Merge pull request #516 from ludovic111/perf/streaming-bench
streaming_bench: the chunk pipeline on its worker pool, without a window
2026-10-16 19:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 be6a9d2cb5 Stronghold ring test: wrap the seed + 1 for i64::MAX
The threaded test added seed + 1 for seed = i64::MAX, which overflows in debug
builds (CI's verify).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:32:45 +02:00
Ludovic Marie 6754c52e82 Merge pull request #520 from ludovic111/perf/frame-uploads
Per-frame uploads through the staging belt; skip unchanged lightmaps
2026-10-16 19:27:32 +02:00
Ludovic MarieandClaude Opus 5.5 673ac84724 horizon-port-pack carry: a jar's data and assets as a content mod
carry <work> [<mod id>...] turns each content mod with a jar into a folder
content mod (API 0.2) in <work>/mods/<id>/carry/, for local use:

- assets/<id>/, data/<id>/ and data/minecraft/tags/ are copied; other
  namespaces and vanilla data go to outside/ for review; no class file is
  copied.
- Vanilla-type recipes get 26.3 ingredients ("id", "#tag", lists);
  custom types and NeoForge conditions are kept and listed.
- Item models get the items/ client definitions 1.21.4 introduced.
- Each block is declared from a vanilla template guessed from its
  blockstate properties; unknown property sets are listed. Items
  without a block are declared from minecraft:stick.

The test builds a small NeoForge jar, carries it over, and reads the
result with horizon-mods' own Content reader.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:25:28 +02:00
Ludovic MarieandClaude Opus 5.5 85858610bf horizon-port-pack: import a modpack and classify its mods
A CLI for the first steps of docs/modpacks.md's pipeline:

- import: a Modrinth .mrpack, a CurseForge zip or a Prism / MultiMC
  instance (or a folder of jars) to pack.json: Minecraft and loader
  versions, each file with its hash and sides, and the overrides.
- inventory: Modrinth metadata by SHA-1 (cached in modrinth.json, so
  reruns work offline), and the jars when the user's instance has them
  (neoforge.mods.toml / mods.toml / fabric.mod.json and a file census,
  without decompiling). Each mod gets a class and API tier from the
  reviewed ATM10 table, then rules on categories, sides and the census,
  and a dependency layer. Writes inventory.json and inventory.csv.

Only metadata is downloaded; jars are never fetched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:25:04 +02:00
Ludovic MarieandClaude Opus 5.5 ac7b7f98c0 Content mods in the game: loot, recipes, models, names and icons from mod namespaces
The game builds the content root from the enabled content mods at startup.
Loaders that only read the minecraft namespace now also read the content
mods' namespaces (from the root's marker file): block loot tables, recipes,
blockstates, language files, and the HUD atlas's item sprites. /give and the
command registries know the new items and blocks. A block without a
blockstate of its own takes its template's, so every state has a model.

Adds examples/ruby-content, a folder content mod: a ruby block and a ruby
item with recipes, loot, tags, models, textures and names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:23:35 +02:00
Ludovic MarieandClaude Opus 5.5 002cfcd859 Content mods in the resource root: template rows appended to every block table
horizon_client::mod_content builds the resource root for content mods as
an overlay over the base root (vanilla, or the resource-pack overlay):
- the base is linked;
- the block and item tables are rewritten, each mod block getting its
  template's rows appended. That covers 13 JSON tables (facts, gameplay,
  behaviours, models, render, landing, items, command registries, mob
  data, worldgen flags, structure, survival) and 6 NBT ones (occlusion,
  collision, support, movement, worldgen block properties). Names,
  translation keys and in-table state ids move over, and the declared
  overrides apply;
- each mod's own assets/ and data/ are copied in;
- tag additions are merged into the tag files and both block-tag bundles.

Vanilla state ids never move: mod states come after them, mods and blocks
in id order. Rewritten JSON keeps its key order. With no content mod the
root is the base itself. The game doesn't call this yet.

Tested over the real prepared resources: every loader reads the overlay
(Assets, BlockFacts, Items, collision, visibility, light, motion, worldgen
GeneratorData). The mod blocks carry their templates' facts with the
overrides applied, and vanilla's rows and ids are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:23:35 +02:00
Ludovic MarieandClaude Opus 5.5 746c888cd3 horizon-mods: content mods (API 0.2), blocks and items declared from vanilla templates
A .hmod may carry assets/ and data/ beside mod.wasm, or instead of it. The
manifest needs api = "0.2" and the new registries capability ("add blocks
and items to your worlds"). Content mods have no WIT interface: code still
links API 0.1.

horizon_mods::content::Content reads a mod's content and checks it:
- plain resource paths only;
- 65,536 files, 16 MiB each, 512 MiB in all;
- files in the mod's own namespace, plus additions to vanilla tags;
- blocks and items declared in data/<id>/horizon/{block,item}/*.json, each
  copying a vanilla template with typed overrides (hardness, resistance,
  light, tool, friction, map colour, sounds; stack size, rarity), unknown
  keys refused.

Mods::load refuses an enabled mod whose content fails these checks, with
the reason on its row. A content-only mod is on with no engine and no hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:23:35 +02:00
Cosmic c7073db8b5 Apply falling anvil impact damage and wear 2026-10-16 12:47:49 -04:00
Ludovic Marie 71635fbab7 Merge pull request #518 from ludovic111/docs/modpacks
Modpacks design: port any pack, with ATM10's real mod list classified
2026-10-16 18:31:56 +02:00
Ludovic Marie dd7282c9da Merge pull request #513 from Online5277/ci/skip-rust-for-docs-and-website
Skip Rust verify steps for website and documentation PRs
2026-10-16 18:31:28 +02:00
Cosmic 4c3b2b789f Draw translated hover tooltips for Classic options 2026-10-16 12:20:41 -04:00
Ludovic MarieandClaude Opus 5.5 0981e0abcf Data packs: walk only the folders functions, tags and predicates live in
Loading a world read every file name under a folder pack's data/ to find
functions, function tags, predicates and item modifiers. The built-in pack
holds about 9,900 other data files, so this walk took 190-340 ms on the
render thread at every world load. A folder pack now walks
data/<namespace>/<kind>/ for those four kinds only, in the same order as
before; zip packs keep filtering their index.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 18:19:46 +02:00
Ludovic Marie 0cdcbd17ba Merge pull request #502 from ludovic111/fix/animated-texture-tick-spikes
Animated textures: cache every animation frame's mip chain
2026-10-16 18:16:10 +02:00
Cosmic c47dc83eab Honor villager texture hat metadata in type layers 2026-10-16 12:16:05 -04:00
Cosmic 1ee64ded55 Show equipment attributes in hover tooltips 2026-10-16 12:10:45 -04:00
Ludovic MarieandClaude Opus 5.5 a06367edf1 docs: modpacks design, with ATM10's real mod list classified
docs/modpacks.md: how any pack (ATM10 down to small Fabric packs) gets to
Horizon: import (.mrpack, CurseForge zip or instance, Prism, packwiz),
inventory and classification, dependency order, mechanical carry-over of
data and assets, one agent per mod for code with a per-mod harness against
a dev-only NeoForge/Fabric oracle, and a coverage report. Also the mod API
gaps against 0.1, the registries 0.2 design (template blocks through the
overlay root), licence handling, how "10x" is measured against NeoForge,
and the scope for 2026-10-23.

docs/modpacks/atm10-mods.csv: ATM10's 495 jars from the pack's own
crash_assistant modlist (2026-10-15), with Modrinth metadata looked up by
hash: class, API tier, licence, sides. Metadata only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 18:08:23 +02:00
Cosmic 5bc981e577 Match world-list search hints and detail placement 2026-10-16 12:07:39 -04:00
Ludovic Marie bd8dff2076 Merge pull request #515 from ludovic111/fix/release-smoke-world-frames
Release smoke run: count frames drawn in the world
2026-10-16 18:00:07 +02:00
Ludovic MarieandClaude Opus 5.5 69b3eb1864 streaming_bench: the chunk pipeline on its worker pool, without a window
Fills an area with N workers, optionally flies the centre along +x, and
reports wall time, chunks generated, sections meshed, how often a column
near the player is still unlit, and what Streaming::update costs the
calling thread. Allocations can be counted with
HORIZON_BENCH_COUNT_ALLOCATIONS=1 at build time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:53:49 +02:00
Ludovic MarieandClaude Opus 5.5 ece9ab2b79 Release smoke run: count frames drawn in the world
The macOS release build has failed since 05:54 UTC on 2026-10-16: on the
self-hosted Mac mini runner the game drew its 1500 frames in about 2.5 s,
before the world had started generating (chunks 0, player null), so no
release has been published since v2026.10.16.03.

--world-frames N exits after N frames drawn with the player in the world.
It is otherwise a frame-limited run (classic menus, --timeout-seconds), and
--frames keeps its meaning for the harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:53:47 +02:00
Ludovic MarieandClaude Opus 5.5 b9d6d1c7d9 Stronghold ring searches run on every worker that needs them
The 128 stronghold ring positions each need a biome search over a 57x57
quart area. The first worker to ask computed all of them inside a OnceLock
while every other worker blocked on it: about 2 s on an M4 Pro with 12 of
13 workers idle, after every world load and every return to the Overworld.

Vanilla's generateRingPositions runs each search as its own task on the
background executor. Now the ring chunks and search randoms are planned
serially (cheap), and every thread that needs the rings takes searches
until none are left, then waits for the rest. Positions are unchanged:
each search only uses its own forked random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:52:23 +02:00
Cosmic b27e4b9323 Skip Rust verify steps for content-only pull requests 2026-10-16 11:46:49 -04:00
Cosmic 65f9819686 Limit Classic graphics submenus to additional settings 2026-10-16 11:43:31 -04:00
Cosmic e86b05333e Keep cloud alpha independent of face shading 2026-10-16 11:40:17 -04:00
Cosmic 18dc7e7d8a Handle fullscreen shortcut before screen input 2026-10-16 11:38:12 -04:00
Ludovic Marie 8c5efc54d8 Merge pull request #503 from ludovic111/perf/section-face-culling
Skip section face groups that face away from the eye
2026-10-16 17:31:08 +02:00
Ludovic MarieandClaude Opus 5.5 f7d042c6d0 Skip section face groups that face away from the eye
The mesher groups each section's solid quads by the axis side they face
(Sodium's ModelQuadFacing) and records each group's nearest plane. When
the eye is behind every plane of a group, back-face culling would discard
all of it after vertex shading, so the renderer leaves the group out of
the section's draws. Seen from the village path, 29-36% fewer solid quads
reach the GPU at render distances 8-32; the picture is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:56:43 +02:00
Ludovic MarieandClaude Opus 5.5 55945ee975 Collision scans: skip blocks with no shape, build the entity shape lazily
BlockCollisionScan asked every cursor cell for its collision shape,
moved it into place and intersected it with Shapes.create(box), even
for air. Mob movement and every other Entity.collide spent most of
their time there. Now:

- CollisionBlock carries empty_shape, set by the registry for states
  with no contextual rule and an empty cached shape (air, plants): the
  scan skips them without asking. Providers that build CollisionBlock
  themselves (the query-order fixture) leave it false, so their query
  logs are unchanged.
- A shape whose bounds miss the box is skipped before moving it:
  joinIsNotEmpty(AND) of the two is false anyway.
- Shapes.create(box) is built the first time a shape is not the unit
  cube (finite bounds always make one), not for every scan.
- SceneCollisions hands the scan the resident chunk itself, so a cell
  costs one section read instead of a chunk-map lookup.

Results are unchanged: running both paths side by side over 900,000
mob moves in a 600-mob village gave bit-identical movement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 bc4d0cc806 Villagers: know each block state's POI type from a table built once
PoiTypes.forState ran per palette entry of every section a villager
searched (and per block of the sections that hold one), with a string
match on the block name each time and a per-search hash map in front.
At world load, when every villager looks for a job site, bed and
meeting point at once, that was 16 % of the tick. The POI type of
every state is now worked out once, the first time a villager looks,
and read from a table after that.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 8a48f3b383 mob_ai probe: keep the snapshot grid as the game does
The probe builds MobWorld itself; it now keeps the same SeenGrid over
its snapshot for the mob ticks, so the mob_ai harness checks pushes
through the grid against vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 54acb709e4 Mobs: shape only the cells that can support them
Entity.checkSupportingBlock runs findSupportingBlock after every move of
a mob on the ground. Horizon looked up and built the collision shape of
every cell of the cursor (27 to 48 per call, twice when it falls back
to where the mob came from). Like BlockCollisions' cursor, a block whose
shape stays inside its own cell (neither hasLargeCollisionShape nor a
moving piston) can only meet the area when its cell does, so only those
cells, and large shapes such as fences, are shaped now. The pick is a
total order, so visiting column by column finds the same block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Opus 5.5 4f1735170e Mobs: find the mobs to push through a grid, not a scan of every mob
LivingEntity.pushEntities compared each mob's box with every other mob
of the tick's snapshot: quadratic, about 1 ms per tick with 600 mobs.
The snapshot is now also kept in a grid of 4-block columns by each box's
minimum corner, updated as each mob finishes its tick, and the push
reads only the columns its box can reach. Candidates come back in
snapshot order, so the pushes, their order and their sums are the same:
over 800,000 pushes in a 600-mob village both ways gave the same
targets in the same order. A query wider than the grid (or not finite)
falls back to the full scan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:40:22 +02:00
Ludovic MarieandClaude Sonnet 5.5 dbe28edc9b Merge origin/main: use #501's staging belt, keep only the animation cache change
Drop this branch's own staging buffer (gfx/uploads.rs); the sprite uploads
go through the StagingBelt from #501. What stays is the animation mip-chain
cache holding every vanilla frame, with tests.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 16:40:11 +02:00
Ludovic MarieandClaude Opus 5.5 baaa5e7374 Per-frame uploads through the staging belt; skip unchanged lightmaps
Renderer::render's buffer writes (globals, section origins and draws,
entity, cloud, celestial, border, debug-line and screen-effect vertices)
and the HUD's vertices now go through the frame's staging belt instead of
one Queue::write_buffer staging buffer each. The lightmap is uploaded only
when its pixels change instead of every frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:39:01 +02:00
Ludovic Marie e4b336fe6d Merge pull request #501 from ludovic111/perf/animated-sprite-uploads
Upload animated sprites through a reused staging belt
2026-10-16 16:31:11 +02:00
Ludovic MarieandClaude Opus 5.5 1e1c3b79e5 Stop animated textures from slowing every tick's frame
Every frame that crossed a tick re-uploaded the animated block sprites, and
that took about 1.8 ms on an M4 Pro (more than the world tick itself):

- The mip-chain cache held 8 MiB, which fits 256 of the 594 vanilla
  animation frames (64-pixel layers, chains stored with spare capacity), so
  the other 338 frames had their resize and mip chain rebuilt on the render
  thread whenever their animation reached them. The budget is now 32 MiB and
  chains are allocated at their exact size: all 594 frames fit in 13 MB.
- Each changed sprite mip level went through its own Queue::write_texture,
  which allocates and frees a GPU staging buffer per call (a few hundred a
  tick). They are now packed into one reused buffer, written once, and
  copied with one submission per tick.

The texels are unchanged: a GPU test compares the batched copies with
write_texture over several flushes, and the existing cache tests compare
cached and generated chains.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:09:21 +02:00
Ludovic MarieandClaude Opus 5.5 9e73a8417f Upload animated sprites through a reused staging belt
Queue::write_texture creates a new staging buffer for every call, a kernel
call each on Metal. Animated sprites write every changed frame's mip
levels once per tick, so tick frames spent about 1.3 ms in buffer creation.
The copies now go through one wgpu StagingBelt recorded into the frame's
encoder; a GPU readback test checks they write the same texels as
write_texture on every mip level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 15:42:57 +02:00
Ludovic Marie 65e0ec9f97 Merge pull request #493 from ludovic111/docs/drop-sprint-known-issue
Release notes: drop the sprint-jumping known issue
2026-10-16 15:30:43 +02:00
Ludovic Marie 93ce080d71 Merge pull request #492 from rdbtCVS/patch-1
Add missing gitignore files
2026-10-16 14:31:22 +02:00
Ludovic MarieandClaude Opus 5.5 81243f7897 Release notes: drop the sprint-jumping known issue
Nobody could reproduce it, and no issue tracks it, yet every release since
2026-10-14 repeated it. Players noticed (rdbt on Discord).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 14:29:46 +02:00
rdbt 5c7fa1c575 Add missing gitignore files 2026-10-16 13:07:23 +01:00
Ludovic Marie 8953448987 Merge pull request #485 from LookImEcho-Rick/codex/issue-480-cloud-cpu
Reuse cloud geometry and GPU buffers between frames
2026-10-16 14:06:00 +02:00
LookImEcho-Rick 45397e4b30 Merge main and reconcile cloud caching with see-through text 2026-10-16 06:25:15 -05:00
Ludovic Marie b8f1011cb1 Merge pull request #474 from ludovic111/fix/lan-name-tags-through-walls
Name tags show through walls
2026-10-16 13:03:23 +02:00
Ludovic Marie 9c87672a0c Merge pull request #473 from ludovic111/fix/lan-trial-guests
LAN: trial spawners and vaults see the guests
2026-10-16 13:03:15 +02:00
Ludovic Marie b523567d1e Merge pull request #472 from ludovic111/fix/lan-guest-chat
LAN guest: command feedback names the guest and shows its numbers
2026-10-16 13:03:08 +02:00
Ludovic Marie 330c3ef07e Merge pull request #471 from ludovic111/fix/lan-guest-containers
LAN guest: their own ender chest, and double chests as 9x6
2026-10-16 13:03:01 +02:00
Ludovic Marie b1a3adff81 Merge pull request #470 from ludovic111/fix/lan-pvp
LAN: PvP between the host and guests
2026-10-16 13:02:54 +02:00
Ludovic Marie 018c5e43e3 Merge pull request #467 from ludovic111/fix/lan-guest-environment
LAN guest: hurt by lava, fire, drowning, suffocation and the void; hungry from moving
2026-10-16 13:02:47 +02:00
Ludovic Marie 7cfc19305a Merge pull request #466 from ludovic111/fix/lan-mobs-target-guests
LAN: mobs target and attack guests
2026-10-16 13:02:41 +02:00
Ludovic Marie 6ff898ec29 Merge pull request #481 from ludovic111/fix/block-entity-data-tooltips
Item tooltips for container contents, spawner data and op-only data
2026-10-16 12:45:14 +02:00
LookImEcho-Rick 56fec974ee Reuse cloud geometry and GPU buffers between frames 2026-10-16 05:45:09 -05:00
Ludovic Marie f5c91eded1 Merge pull request #479 from ludovic111/fix/pick-block-data
Ctrl + Pick Block copies the block entity's data in creative
2026-10-16 12:45:09 +02:00
Ludovic Marie 8da9c8c4c6 Merge pull request #483 from ludovic111/fix/world-options-game-rules
World Options: Edit Game Rules (InWorldGameRulesScreen)
2026-10-16 12:42:04 +02:00
Ludovic Marie 04bdf82ac9 Merge pull request #482 from ludovic111/fix/world-options-buttons
World Options: difficulty lock with its confirmation, and Restrictions
2026-10-16 12:42:00 +02:00
Ludovic Marie e8757cd75c Merge pull request #478 from ludovic111/fix/consent-buttons-reachable
Launcher consent: Send nothing / Save choices always on screen
2026-10-16 12:41:56 +02:00
Ludovic Marie 9bbe5c4fd3 Merge pull request #476 from ludovic111/fix/debug-crosshair-no-bob
F3 debug crosshair: no view bob, drawn after the hand like vanilla
2026-10-16 12:41:53 +02:00
Ludovic MarieandClaude Opus 5.5 a950a046ae World Options: Edit Game Rules (InWorldGameRulesScreen)
The rules by category under a search box, a switch or number box each,
with vanilla's tooltip, number validation (Brigadier readInt and the
rule's range, red text, Done held) and ConfirmScreen before discarding.
Done sets the changed rules and tells the player, as handleSetGameRule
does. The button follows the commands the player has, as updateButton.

The control socket's `type` now reaches World Options' boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:50:00 +02:00
Ludovic MarieandClaude Opus 5.5 e27e556ab2 World Options: the difficulty lock (with its question) and Restrictions
The Lock button toggles the wanted lock; Apply with a new lock asks first
(PopupScreen "Lock World Difficulty"), Yes locks the level's difficulty
(difficulty_settings.locked, now read and written), No returns to the
options. Restrictions opens RestrictionsScreen: the chat restriction the
Chat option makes, with its way to Chat Settings, and the four chat
permissions. A locked or hardcore level keeps both the difficulty and the
lock inactive, and LAN guests are told about the lock.

Part of #464

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:15:43 +02:00
Ludovic MarieandClaude Opus 5.5 ac4ed86925 Item tooltips for container contents, spawner data and op-only data
ItemContainerContents.addToTooltip: an item with a container (a shulker box,
or a chest Ctrl + Pick Block copied) lists its first five stacks as
"count x name" and how many more there are. Spawner.appendHoverText: a
spawner or trial spawner item names the entity its block_entity_data spawns,
else how to set one. ItemStack.OP_NBT_WARNING: for a player with game master
permission, an item carrying data of an op-only block entity type (command
blocks, lecterns, signs, spawners) warns that it might run commands.

The item argument now refuses block_entity_data without an id or with an
unknown block entity type, with vanilla's TypedEntityData messages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:09:03 +02:00
Ludovic MarieandClaude Opus 5.5 91360e2f29 tools/lan_e2e.py: walls runs next to nametags, before the world fills up
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:08:31 +02:00
Ludovic MarieandClaude Opus 5.5 ee088c0022 tools/lan_e2e.py: walls sees a dead guest by its death screen too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:05:39 +02:00
Ludovic Marie 4224687e54 Merge pull request #477 from ludovic111/fix/ctl-wait-keeps-held-keys
Keep held keys held through wait on the control socket
2026-10-16 11:02:24 +02:00
Ludovic MarieandClaude Opus 5.5 a8b6eea474 tools/lan_e2e.py: walls puts the wall halfway between the players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:02:21 +02:00
Ludovic MarieandClaude Opus 5.5 bd42496af0 Ctrl + Pick Block copies the block entity's data in creative
ServerGamePacketListenerImpl.addBlockDataToItem: with Ctrl held in creative,
Pick Block gives the block's item the block entity's data. What each type
turns into item components (removeComponentsFromTag and
collectImplicitComponents: container, custom_name, lock, container_loot,
sign text, waxed, bees, banner patterns, pot decorations, skull profile)
leaves its NBT; the rest becomes minecraft:block_entity_data, tagged with
the type's id. Placing the item does the reverse
(BlockItem.updateCustomBlockEntityTag, op-only types only for a game
master, then applyComponentsFromItemStack), for every block entity type,
which also fills placed chests, barrels, hoppers and the like from their
container component, as vanilla does, where only shulker boxes were.

The client sends includeData on Ctrl in multiplayer, a picked banner keeps
its patterns without Ctrl (AbstractBannerBlock.getCloneItemStack), a picked
beehive carries its honey level, a placed command block with data keeps its
own auto and output tracking, and a placed waxed sign opens no editor.

tools/test_pick_block_data_parity.py compares both directions with vanilla's
own block entities: 148/148 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:59:56 +02:00
Ludovic MarieandClaude Opus 5.5 4410064c58 tools/lan_e2e.py: walls brings a dead guest back first
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:27 +02:00
Ludovic MarieandClaude Opus 5.5 7e1e5eb7a0 tools/lan_e2e.py: trials puts the host back where it stood
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 3e6052ac92 control: keys reach a server's chat input
The control socket's key command went straight to key_pressed, so Escape
paused the game instead of closing the chat a server connection opens
(remote_chat_key handles the real keyboard).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 bc0cd9cc84 LAN guest: a use packet uses the item alone
ServerGamePacketListenerImpl.handleUseItem takes the client's rotation and
uses the held item, nothing else. The host ran the whole right click, with
the block the guest had looked at before: a guest that turned from an
ender chest to a double chest could get the ender chest opened again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 a8579b4df8 lan status shows whether each guest crouches; walls sets up its own scene
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 2a33f2c500 tools/lan_e2e.py: trials clears the air between its blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 984a06e582 tools/lan_e2e.py: feedback expects the rounded position and closes the chat
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 487d76c0ae gfx: the batch test covers see-through text
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 74fafdd787 LAN: trial spawners and vaults see the guests
PlayerDetector reads level.getPlayers(): the guests count for a trial
spawner's detection and a vault's connected players, and a key a guest
inserts rewards the guest's UUID.

Part of #462

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 11130e6ef7 LAN guest: command feedback names the guest and shows its numbers
Feedback about the player (target_name) names the guest a command runs
for. A client reading a server's chat now unwraps NbtOps' {"": value}
wrapper of any bare value, not only strings, so translation arguments that
are numbers show ("Replaced 1 slot(s)", the coordinates). horizon-ctl
messages reads a server's chat too.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 3f7fba836e Name tags show through walls
SubmitNodeCollection.submitNameTag: a name that is not discrete is drawn
in full over the world and, faded on its background, again with
RenderPipelines.TEXT_SEE_THROUGH (no depth test) after the rest of the
world; a discrete one (a crouching player) only once, faded, on its
background. The background is ARGB.color(0.25, black): alpha 0x3F.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:58:09 +02:00
Ludovic MarieandClaude Opus 5.5 ce49480b3e Launcher consent: the answers stay at the card's foot, its body scrolls
With "View what would be sent" open in a short window, "Send nothing" and
"Save choices" sat below the overlay's scroll range. The card now fits the
window (max height 100%): its body scrolls, and the answers row stays pinned
at its foot.

Fixes #460

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:57:53 +02:00
Ludovic MarieandClaude Sonnet 5.5 fc59063ada Keep held keys held through wait on the control socket
`wait` ended with release_agent_keys like every other task, so
`key w down` + `wait 40` + `wait 20` walked for the first wait only.
A wait holds nothing: it leaves keys and buttons alone. They stay held
until `key ... up`, `release` or `stop`. Documented in horizon-ctl's
key/wait help (and so the MCP tools) and docs/agent-control.md.

Fixes #469

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 10:56:48 +02:00
Ludovic MarieandClaude Opus 5.5 23a54d4a22 F3 debug crosshair: drawn at the hand's projection, without the view bob
GameRenderer.render3dHud draws DebugCrosshairRenderer after the hand and
the screen effects, with the hudFov projection, no bob and FogMode.NONE.
Horizon drew it among the world's on-top gizmos, so the axes bobbed with
View Bobbing.

Fixes #475

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:38:20 +02:00
Ludovic MarieandClaude Opus 5.5 257b5e9f2d tools/lan_e2e.py: containers waits for the server's menu before reading or taking
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:36:31 +02:00
Ludovic MarieandClaude Opus 5.5 64f7ecd367 tools/lan_e2e.py: containers takes its chests away
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:27:11 +02:00
Ludovic MarieandClaude Opus 5.5 36d7f02802 LAN: PvP between the host and guests
Player.attack on a player: the host hits the guest on its crosshair, a guest
hits the host or another guest. The hit is worked out as the attacker
(Game::melee_damage, shared with attacking mobs) and lands on the victim as a
player_attack (blocking, armour, knockback to the guest's client), when the
pvp game rule allows it (ServerPlayer.canHarmPlayer).

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:19:00 +02:00
Ludovic MarieandClaude Opus 5.5 2e1212346f tools/lan_e2e.py: hazards counts the food spent from full health
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:19:00 +02:00
Ludovic MarieandClaude Opus 5.5 e289980c1e LAN guest: their own ender chest, and double chests as 9x6
The hub's container menus now say where their slots live (lan::menu::Slots):
one block entity, both halves of a double chest (CompoundContainer, the right
half first) or the guest's own ender inventory (PlayerEnderChestContainer,
saved as EnderItems). The host's ender inventory stays the host's while a
guest acts. A Horizon client shows a server's chest-like menu with the rows
and title the server sent, and keeps its slots off a block that holds no such
container.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:19:00 +02:00
Ludovic Marie 90644915c5 Merge pull request #455 from ludovic111/fix/command-blocks
Command blocks: placing, the edit screen, and running commands
2026-10-16 10:12:07 +02:00
Ludovic Marie 7b53b06aca Merge pull request #448 from ludovic111/fix/drop-swing
Dropping from a menu swings the hand
2026-10-16 10:12:02 +02:00
Ludovic Marie 53fcf14850 Merge pull request #447 from ludovic111/fix/drop-key-repeat
Holding the drop key keeps dropping, in game and in menus
2026-10-16 10:11:58 +02:00
Ludovic Marie 84999e0762 Merge pull request #446 from ludovic111/fix/wayland-uncapped-windowed
Wayland: VSync off draws uncapped in a window
2026-10-16 10:11:54 +02:00
Ludovic Marie 3c81a89d1e Merge pull request #463 from ludovic111/fix/mod-commands-in-chat
Mod commands: coloured and Tab-completed in the chat like vanilla's (#456)
2026-10-16 09:50:12 +02:00
Ludovic Marie d993c36e02 Merge pull request #465 from LookImEcho-Rick/codex/fix-happy-ghast-riding
Fix Happy Ghast riding controls and standing after dismount
2026-10-16 09:19:43 +02:00
Ludovic Marie 9311cd5cd3 Merge pull request #468 from ludovic111/fix/parity-free-ports
[tools] parity harnesses: free ports instead of fixed ones
2026-10-16 09:16:45 +02:00
Ludovic MarieandClaude Opus 5.5 600bae5703 LAN guest: hurt by lava, fire, drowning, suffocation and the void; hungry from moving
The host now runs the rest of the server's body tick for each guest:
LivingEntity.baseTick's drowning and lava, fire blocks, suffocation
(isInWall) and the void (checkBelowWorld, 4 a tick, creative too), and per
move handleMovePlayer's jump (ServerPlayer.jumpFromGround's exhaustion) and
checkMovementStatistics (sprinting, swimming). The first move of a tick now
starts from the ground state before it, so take-offs are seen. Hurt sounds
are heard at the guest; lan status lists each guest's health and food.

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 09:13:43 +02:00
Ludovic MarieandClaude Opus 5.5 327aa5af6e tools/lan_e2e.py: a world with commands, so the guest's commands run again
Since #372 a guest's commands need the world's Allow Commands as well as
the LAN's; the test's survival world had none, so every guest command (tp,
gamemode, setblock) was refused and the checks built on them failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:59:53 +02:00
Ludovic MarieandClaude Opus 5.5 dac18e8350 LAN: mobs target and attack guests
Each mob sees the nearest player (host or guest), as Level.getNearestPlayer
gives it, and keeps a guest it targets or that hurt it in the last 100 ticks.
Its hits, pushes, fire and launches on that player run with the guest swapped
in; mobs' projectiles hit guests too, and knockback reaches the guest's client
(SetEntityMotion on its own entity).

Part of #435

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 08:44:07 +02:00
Ludovic MarieandClaude Sonnet 5.5 e27efc16ba [tools] parity harnesses: free ports instead of fixed ones
vanilla_world_check.py (used by the light harness) defaulted to port 25671, so two parity runs on one machine failed with 'FAILED TO BIND TO PORT'. It now picks a free port unless --port is given. The save round trip, datafix and upgrade_world tools derived their port from the pid or a random number; they ask the OS for a free one like test_dedicated_server_parity does.

Fixes #461

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-16 08:38:03 +02:00
LookImEcho-Rick 924698e506 Support standing and walking on happy ghasts 2026-10-16 01:10:46 -05:00
LookImEcho-Rick 705fab9bed Fix happy ghast rider collisions and turning 2026-10-16 01:10:19 -05:00
Ludovic Marie 7e8dd577ac Merge pull request #454 from ludovic111/fix/title-toast-slide
World backup toast slides in and out with its sounds (#384)
2026-10-16 07:37:05 +02:00
Ludovic Marie a75c3a315f Merge pull request #453 from ludovic111/fix/slider-smooth-drag
Classic sliders follow the pointer and snap on release (#385)
2026-10-16 07:37:01 +02:00
Ludovic Marie 932c7bfc17 Merge pull request #452 from ludovic111/fix/classic-text-fields
World screens' text fields: move the cursor, select, click and drag
2026-10-16 07:36:56 +02:00
Ludovic Marie d0fa05b3e7 Merge pull request #451 from ludovic111/fix/level-loading-screen
Level loading screen like vanilla: panorama, chunk map and progress bar
2026-10-16 07:36:52 +02:00
Ludovic Marie 592ac6966b Merge pull request #450 from ludovic111/fix/world-options-layout
World Options: vanilla layout, headings, port box, lock and footer
2026-10-16 07:36:47 +02:00
Ludovic Marie 3e19e0d4dd Merge pull request #449 from ludovic111/fix/pause-icon-buttons
Pause menu: icons centred and in full colour like vanilla
2026-10-16 07:36:43 +02:00
Ludovic Marie 6a0d4284d0 Merge pull request #445 from ludovic111/fix/world-list-join-icon
World list: hovered rows show the join arrow, clicking the icon joins
2026-10-16 07:36:39 +02:00
Ludovic MarieandClaude Opus 5.5 d9deabd54e Command blocks: placing, the edit screen, and running commands
A creative player with game master commands (Player.canUseGameMasterBlocks)
places command, chain and repeating command blocks and opens
CommandBlockEditScreen on them: the command, its previous output with the
O/X tracking toggle, Impulse/Chain/Repeat, Conditional and Needs
Redstone/Always Active, applied as handleSetCommandBlock does.

The block entity (CommandBlockEntity with BaseCommandBlock) saves and
loads its vanilla fields. CommandBlock.tick runs impulse blocks on a
rising signal, repeating blocks every tick while powered or always
active, and the chain in front (executeChain), honouring conditional
blocks, command_blocks_work and max_command_sequence_length. Commands
run through the command system as the block (position, facing, game
master level); their feedback becomes the block's last output and the
[@: ...] broadcast under command_block_output. Comparators read the
success count.

Fixes #444

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 07:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 3cfb7b8053 Mods' commands in the chat's command tree (#456)
A mod's commands ran but the chat box parsed against vanilla's tree only,
so `/coords` showed red as unknown and never completed. The game now keeps
the chat's tree as vanilla's plus a literal per running mod's command (every
permission level, optional greedy `args`), rebuilt when mods start, reload
or stop.

Fixes #456

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:48:56 +02:00
Ludovic Marie f188dfeb45 Merge pull request #439 from ludovic111/feat/ominous-trials
Trial spawners: become ominous, Trial Omen and ominous item spawners
2026-10-16 06:42:37 +02:00
Ludovic Marie 45404c4507 Merge pull request #433 from ludovic111/feat/trial-pots
Give trial chamber pots their loot, sherds and insertion
2026-10-16 06:42:32 +02:00
Ludovic Marie d7c7c56e05 Merge pull request #422 from ludovic111/feat/trial-visuals
Show trial spawner and vault visuals
2026-10-16 06:42:27 +02:00
Ludovic Marie d1c753e085 Merge pull request #417 from ludovic111/feat/vault-game
Vaults in the game: ticking, keys, rewards and the display item
2026-10-16 06:42:22 +02:00
Ludovic Marie 9126df5ac5 Merge pull request #410 from ludovic111/feat/vault-logic
Vaults: server logic and a parity harness
2026-10-16 06:42:17 +02:00
Ludovic Marie d3b9441833 Merge pull request #457 from LookImEcho-Rick/fix/launcher-data-preview-scroll
Fix launcher data preview scroll isolation
2026-10-16 06:40:49 +02:00
Ludovic Marie e6a2d72467 Merge pull request #459 from LookImEcho-Rick/codex/chat-arrow-history
Keep chat arrow keys from turning the camera
2026-10-16 06:40:45 +02:00
Ludovic MarieandClaude Opus 5.5 3c57aba6c9 Classic sliders follow the pointer and snap on release
Vanilla's AbstractSliderButton keeps its own position while held and
only snaps to the option's stepped value in resetValue (on release, or
600 ms after the last change for sliders that apply late).

Fixes #385

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 06:18:28 +02:00
Ludovic Marie 9b9b23bf3a Merge pull request #436 from ludovic111/feat/shaderpack-client
horizon-client: run a Java pack's full-screen passes over the world
2026-10-16 05:31:32 +02:00
Ludovic Marie 953cdc947f Merge pull request #418 from ludovic111/feat/shaderpack-passes
horizon-shaderpack: plan a Java pack's full-screen passes like Iris
2026-10-16 05:31:28 +02:00
Ludovic Marie 4de37ac0e4 Merge pull request #415 from ludovic111/feat/shaderpack-properties
horizon-shaderpack: read shaders.properties like Iris
2026-10-16 05:31:23 +02:00
Ludovic Marie 1bf51d786f Merge pull request #413 from ludovic111/feat/shaderpack-translate
Java shader packs (2): translate programs to WGSL (syntax-tree legaliser + naga)
2026-10-16 05:31:19 +02:00
Ludovic Marie 64a82acdab Merge pull request #405 from ludovic111/feat/shaderpack-preprocess
Java shader packs (1): horizon-shaderpack crate, Iris's preprocessor and options
2026-10-16 05:31:14 +02:00
LookImEcho-Rick f2bbd6e3dc Keep arrow-key camera controls out of chat and menus 2026-10-15 22:23:25 -05:00
Ludovic MarieandClaude Opus 5.5 63453086b6 Trial spawners: become ominous, Trial Omen and ominous item spawners
A player seen with Bad Omen or Trial Omen turns a trial spawner ominous
(tryDetectPlayers' ominous branch): Bad Omen becomes Trial Omen, level
events 3020, the spawner's mobs vanish (3012) and the ominous config
starts over. While active, an ominous spawner drops an ominous item
spawner over a player or one of its mobs every 160 ticks
(spawnOminousOminousItemSpawner, getDispensingItems seeded per 30x20x30
cell); it floats, grows and turns for 60 to 120 ticks, then the item
falls or is fired down (OminousItemSpawner).

Zombies, piglins and zoglins read IsBaby from their tag, so the baby
zombie trial spawners spawn babies, and zombies save it.

The trial spawner harness covers the ominous branch, Trial Omen, the
discarded mobs and the item spawners' items, positions and delays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:04:30 +02:00
Ludovic MarieandClaude Opus 5.5 c4f915c9d1 Give trial chamber pots their loot, sherds and insertion
Generated chunks now keep the block entities Horizon does not model
(decorated pots, brushable blocks) as placed, like a loaded chunk's, so a
trial chamber pot keeps its loot table and sherds. The loot table is
rolled into the pot's item the first time it is read
(RandomizableContainer.unpackLootTable: the seed, else the table's random
sequence, else the level random), so breaking the pot drops it. Pots
render their sherds from the saved block entity, and DecoratedPotBlock's
useItemOn / useWithoutItem insert items (wobble, sounds, dust plume,
game event) or wobble and refuse.

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:04:06 +02:00
Ludovic MarieandClaude Opus 5.5 ed5427197f Show trial spawner and vault visuals
LevelEventHandler 3011-3021 (spawn, detect, eject, ominous and item spawner
particles and sounds, vault activation and deactivation), the trial
spawner's client tick (state particles, the spinning display mob, the
ambient sound) and the vault's client tick (idle smoke and flames, the
connection particles towards the player, the ambient sound).

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:47 +02:00
Ludovic MarieandClaude Opus 5.5 3ac3efe749 Save round-trip: a vault's next state update moves with the clock
Vaults tick in the game now, and an idle one pushes
state_updating_resumes_at to gameTime + 20 on every update, so Horizon,
which saves later than vanilla, stores a later value. Stage 3 already
treats it as volatile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:38 +02:00
Ludovic MarieandClaude Opus 5.5 2c07b5e621 Wire vaults into the game
Vaults are block entities now (loaded from saves and structure templates,
so trial chambers keep their ominous configs) and tick every game tick:
their state changes, sounds, level events and ejected rewards reach the
world, keys go in through VaultBlock.useItemOn before the item's own use,
and VaultRenderer draws the display item spinning inside the cage.

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:38 +02:00
Ludovic MarieandClaude Opus 5.5 2567a4a41a Add the vault's server logic with a parity harness
vault::Vault is VaultBlockEntity with its config, server and shared data:
load and save, Server.tick (display item cycling, VaultState.tickAndGetNext,
ejection, transitions with their sounds and level events) and
VaultBlock.useItemOn / tryInsertKey (keys, rewarded players, fail sounds,
the reward roll with its random sequence).

tools/test_vault_parity.py runs 3,000 cases against vanilla through a new
mode=vault of the natural spawn oracle and fixture.

Part of #223.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 05:03:28 +02:00
LookImEcho-Rick d5fbe2b7d6 Fix launcher data preview scroll isolation 2026-10-15 22:02:18 -05:00
Cosmic 92236010fd Document the sulfur body component limitation 2026-10-15 22:22:07 -04:00
Cosmic 938f2ec884 Merge remote-tracking branch 'origin/main' into fix/sulfur-cube-inner-layer
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-15 22:19:52 -04:00
Cosmic e43e8ae82b Match sulfur cube block placement, lighting and fuse flashes 2026-10-15 22:19:39 -04:00
Cosmic 18e9c14642 Render swallowed sulfur cube blocks and award Uh Oh 2026-10-15 22:12:08 -04:00
Ludovic Marie 9044ae30c9 Merge pull request #423 from ludovic111/fix/server-list-dialogs
Server list: confirm before deleting, vanilla Direct Connection layout
2026-10-16 03:51:54 +02:00
Ludovic Marie c72a5643a4 Merge remote-tracking branch 'origin/main' into fix/server-list-dialogs
# Conflicts:
#	crates/horizon-client/src/multiplayer.rs
2026-10-16 03:46:14 +02:00
Ludovic Marie 3315138536 Merge pull request #421 from ludovic111/fix/server-list-entries
Server list rows: favicons, styled MOTDs, player count colours, icon arrows
2026-10-16 03:46:02 +02:00
Ludovic Marie f8c22f4e72 Merge pull request #416 from ludovic111/fix/f3-crosshair
F3 shows vanilla's debug crosshair (world axes)
2026-10-16 03:45:58 +02:00
Ludovic Marie 128111ff27 Merge pull request #414 from ludovic111/fix/f3-on-top
F3 overlay like vanilla: on top of menus, debug GUI scale, default entries
2026-10-16 03:45:54 +02:00
Ludovic Marie 09e01f066f Merge pull request #409 from ludovic111/fix/graphics-preset-distances
Graphics presets set render and simulation distance like vanilla
2026-10-16 03:45:51 +02:00
Ludovic Marie fb5a307903 Merge pull request #407 from ludovic111/fix/allow-commands
Commands follow the world's Allow Commands
2026-10-16 03:45:47 +02:00
Ludovic Marie 7cad2d7284 Merge pull request #406 from ludovic111/fix/world-reopen-entities
Reopening a world no longer duplicates its entities
2026-10-16 03:45:43 +02:00
Ludovic MarieandClaude Opus 5.5 c163fbff83 The world backup toast slides in and out with its sounds
EditWorldScreen's SystemToast goes through vanilla's ToastManager: it
slides in from the right over 600 ms (squared), shows for 5 s times the
notification display time, slides out, and plays ui.toast.in / out. The
in-game toasts' slide moves to a shared toasts module for it.

Fixes #384

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:32:21 +02:00
Ludovic MarieandClaude Opus 5.5 608507699c Classic world fields edit at a cursor: arrows, Home/End, Delete, clicks and drags
The world list's search, Edit World's name and Create New World's name and
seed kept only their String and appended to it. They now edit through an
EditBox (ui::Caret keeps the focused field's cursor, selection and scroll
between frames): EditBox.keyPressed's moves and deletions (Ctrl by word,
Shift to select), Ctrl+A/C/V/X on the selection, onClick placing the
cursor under the pointer (Shift extends, a double click within 250 ms
selects the word) and onDrag selecting.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:05:46 +02:00
Ludovic MarieandClaude Opus 5.5 03fe857ec2 Dropping from a menu swings the hand
LivingEntity.drop swings the main hand for every drop, so throwing a
slot's item with Q over the inventory, a container or a creative tab
plays the swing (the inventory's player model shows it). Horizon only
swung for the drop key in game; Player.drop's port now swings itself.

Fixes #442

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:05:26 +02:00
Ludovic MarieandClaude Opus 5.5 5373998ee0 Level loading screen like vanilla: panorama, chunk map and progress bar
LevelLoadingScreen while the player waits for its chunks: the title's
panorama, blur and menu background when a world opens (Reason.OTHER),
the nether portal sprite after a nether portal, Loading terrain... above
the 17x17 chunk status map (ChunkLoadStatusView, radius 8, COLORS) and
the 200x2 progress bar easing towards LevelLoadProgressTracker's value.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 03:05:21 +02:00
Ludovic MarieandClaude Opus 5.5 93ea338bfd Holding the drop key keeps dropping, in game and in menus
KeyboardHandler.keyPress sends a key repeat (InputConstants.REPEAT) to
Screen.keyPressed and KeyMapping.click like a press, so holding key.drop
keeps throwing the held item, the hovered slot's item in a menu, and
the hovered item of a creative tab. Horizon dropped every repeat; the
drop key's now reach key_pressed. horizon-ctl `key KEY repeat` sends
one auto-repeat, for scripted checks.

Fixes #443

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:57:59 +02:00
Ludovic MarieandClaude Opus 5.5 98321f5d8d World Options: tests of the layout and active states
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:52:47 +02:00
Ludovic MarieandClaude Opus 5.5 9f7c1c3ce5 World Options: vanilla layout, headings, port box, lock, game rules and restrictions
WorldOptionsScreen in a HeaderAndFooterLayout: the title in the 33 pixel
header, the content in a ScrollableLayout over the in-world list
background between the separators, the footer buttons at h - 27.
Bold underlined category headings (FocusableTextWidget), the difficulty
button 130 wide beside its lock, Edit Game Rules... and Restrictions...,
the port EditBox always shown with its hint and error colour, and the
buttons' active states from updateButton and the update*Button methods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:46:19 +02:00
Ludovic MarieandClaude Opus 5.5 a8e0dead35 Wayland: VSync off draws uncapped in a window
pre_present_notify ties winit's RedrawRequested to the compositor's
frame callbacks on Wayland, one per refresh, so a window drew at the
monitor's rate whatever the present mode. Only Fifo (VSync) asks for
that pacing now; Mailbox and Immediate draw as fast as the frame limit
allows, like vanilla's GLFW window.

Fixes #441

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:42:22 +02:00
Ludovic MarieandClaude Opus 5.5 8ca9cda5f6 Pause menu: icon sprites centred and untinted like SpriteIconButton
CenteredIcon draws its 15x15 sprite at x + 20/2 - 15/2 = x + 3 (integer
division), y + 3, with the widget's alpha only: Horizon drew it at +2
and tinted it grey.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:26:36 +02:00
Ludovic MarieandClaude Opus 5.5 9cec0dfb45 World list: hovered rows show vanilla's join arrow, a click on the icon joins
WorldListEntry.extractContent dims a hovered row's icon (0xA0909090) and
draws world_list/join over it, join_highlighted under the pointer;
mouseClicked joins the world when the click lands on the icon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 02:25:54 +02:00
Ludovic Marie d333780478 Merge pull request #420 from ludovic111/contrib/cull-leaves-387
Optional leaf and entity culling (Online5277's #387, rebased)
2026-10-16 02:13:02 +02:00
Ludovic MarieandClaude Opus 5.5 964280aa00 Guests on a dedicated server keep their ops-based commands
Allow Commands is the integrated server's setting; a dedicated server's
guests run what their permission level allows (dedicated_server parity).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 01:18:08 +02:00
Ludovic Marie 457a5d7ce2 Merge pull request #440 from ludovic111/feat/launcher-owner-download
Launcher: download Minecraft from Mojang for a signed-in owner
2026-10-16 00:53:49 +02:00
Ludovic Marie 310a3fb73b Merge pull request #438 from ludovic111/feat/launcher-other-launchers
Launcher: prepare from Prism, MultiMC, Modrinth App, ATLauncher or GDLauncher
2026-10-16 00:53:37 +02:00
Ludovic Marie d56c6dfd23 Merge pull request #437 from ludovic111/fix/launcher-missing-files-error
Launcher: when Minecraft isn't found, say where it looked and what to do
2026-10-16 00:53:23 +02:00
Ludovic MarieandClaude Opus 5.5 d30df8ce33 launcher: download Minecraft from Mojang for a signed-in owner
When no folder has the pinned version and the selected Microsoft account
owns Minecraft: Java Edition (the mcstore entitlement, asked again with the
saved token), Prepare downloads it the way other launchers do: the version
JSON from piston-meta by its pinned sha1, the client jar, this system's
libraries (the JSON's os.name rules) and the asset index with its objects
(the sounds), each checked against its sha1, written whole, 8 at a time,
into the data folder's minecraft/ in the official layout. The next
preparations find it there first. An asset that fails is a line in the log;
anything else is an error.

Not signed in, or signed in without the game, the not-found error says so as
its last line. Ownership stays required: there is no other path to the
download, and nothing of Mojang's is part of Horizon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:46:57 +02:00
Ludovic MarieandClaude Opus 5.5 e0b5e25c1a Commands: prune suggestions in the chat only, like the client's tree
Brigadier's getCompletionSuggestions lists every child whatever its
requirement (command_parse compares it at permission 4, 2 and 0); only the
tree the server sends the client is pruned (Commands.sendCommands). The
player's chat now asks client_suggestions, which skips what the player
may not use; guests' ask_server requests keep brigadier's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:42:38 +02:00
Ludovic MarieandClaude Opus 5.5 b2e9fafbe0 launcher: prepare from Prism, MultiMC, Modrinth App, ATLauncher or GDLauncher
The official launcher crashes on some Linux systems (rdbt's Arch), and
Horizon only read its layout. Preparing now finds the pinned version in the
chosen folder (or a folder above it, so an instance folder works), then in
every launcher's default folder on Linux (Flatpak included), macOS and
Windows:

- the official launcher: versions/<v>/<v>.jar
- Prism Launcher, MultiMC: the jar their meta/net.minecraft/<v>.json names
  (a Maven name in libraries/), libraries/com/mojang/minecraft/<v>/...
- the Modrinth App: meta/versions/<v>/<v>.jar, meta/libraries
- ATLauncher, GDLauncher (data/, Next's datastore/):
  libraries/net/minecraft/client/<v>/client-<v>.jar

The client jar is checked against the lock's sha1. horizon-prepare reads the
official layout and pins the version JSON's own sha1, so files found
elsewhere are linked into that layout in the data folder
(linked-minecraft/), with the pinned version JSON from piston-meta (by its
sha1, checked) when the launcher keeps only its own format or a rewrite.
Nothing else is copied or downloaded. Every preparer, old Python ones
included, reads the linked folder as an official one.

When nothing is found, the error lists the chosen folder and every installed
launcher's, with what each lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:40:31 +02:00
Ludovic Marie 71fc3482ba Merge pull request #404 from Online5277/fix/rail-mipmap-cutout
Preserve native sprite mipmaps in mixed-size texture arrays
2026-10-16 00:37:42 +02:00
Ludovic MarieandClaude Opus 5.5 561efb340e launcher: say which folders were checked when Minecraft is not found
Prepare used to stop with "Minecraft 26.3 is not in <dir>. Play 26.3 once from
the official launcher, or choose another folder." followed by an unrelated log
line ("Checking Java and your Minecraft install"). It now lists every folder it
looked in with what each one lacks (no such folder, no versions/26.3/26.3.json,
no 26.3.jar) and the two ways out, one per line. A missing client jar is caught
here too, instead of later inside horizon-prepare.

Only a preparer process's "Exited with ..." gets the last log line appended;
the launcher's own errors are full sentences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:24:43 +02:00
Ludovic MarieandClaude Opus 5.5 2862f17e86 horizon-client: run a Java pack's full-screen passes over the world
A folder holding shaders/, or a .zip, in shaderpacks/ now loads from the
Shader Packs screen: every planned begin, prepare, deferred, composite
and final pass is translated and drawn each frame over Horizon's world,
with Iris's formats, clears and buffer flips.

The targets keep OpenGL's layout (the translator flips gl_Position and
remaps its depth), the world enters as OpenGL depth, and the pack gets
OpenGL matrices. Textures share four samplers by kind (Metal allows 16
per stage), and isnan/isinf test bits (Metal's fast math folds x != x).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 00:22:39 +02:00
Cosmic 23cadd8b65 Keep entity culling description within the UI line limit 2026-10-16 00:12:58 +02:00
Cosmic 6aea14c8a0 Add conservative opt-in entity culling 2026-10-16 00:12:58 +02:00
Cosmic 504a70c062 Add an opt-in Cull Leaves graphics setting 2026-10-16 00:12:45 +02:00
Ludovic Marie 5350f3465b Merge pull request #424 from ludovic111/test/lan-e2e
tools/lan_e2e.py: a LAN host and guest, checked from both sides
2026-10-16 00:10:22 +02:00
Ludovic Marie 99cd0ec0ad Merge pull request #430 from ludovic111/fix/lan-guest-damage
LAN guest: fall damage, regeneration and hunger on the host
2026-10-16 00:10:16 +02:00
Ludovic Marie a42db752d7 Merge pull request #429 from ludovic111/fix/lan-guest-crafting
LAN guest: crafting in the 2x2 grid and at a crafting table
2026-10-16 00:10:10 +02:00
Ludovic Marie de53b7c14e Merge pull request #428 from ludovic111/fix/lan-player-nametags
LAN: name tags over other players, and the host under its own name
2026-10-16 00:09:58 +02:00
Ludovic Marie e2fa721c7a Merge pull request #427 from ludovic111/fix/lan-chest-lids
LAN: chest and shulker box lids open and close for every player
2026-10-16 00:09:53 +02:00
Ludovic Marie 1c91fad962 Merge pull request #426 from ludovic111/fix/lan-player-armour
LAN: every player wears their own armour
2026-10-16 00:09:45 +02:00
Ludovic Marie 824fad45cf Merge pull request #425 from ludovic111/fix/lan-guest-self-model
LAN guest: the guest sees himself (own player model on a server)
2026-10-16 00:09:40 +02:00
Ludovic Marie 5f45cf3830 Merge pull request #431 from ludovic111/fix/release-tag-order
Release train: two-digit same-day counter so GitHub lists releases in order
2026-10-15 23:22:49 +02:00
Ludovic MarieandClaude Opus 5.5 08c4805e10 Release train: pad the same-day counter to two digits
GitHub's releases page sorts a day's tags as text, so v2026.10.15.9 showed
above v2026.10.15.18 (reported by rdbt on Discord). Tags now count .01, .02...
Every reader (release_train.tag_key, the launcher's compare_versions) compares
the parts as integers, so older unpadded tags still order correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:17:09 +02:00
Ludovic MarieandClaude Opus 5.5 fc97b78931 Server list: confirm before deleting, vanilla Direct Connection layout
Delete opens JoinMultiplayerScreen's ConfirmScreen (title, wrapped
warning, Delete and Cancel; Escape answers no) and only removes the
server on yes. Direct Connection puts its label at y 100 and the box at
116 like DirectJoinServerScreen, and Add/Edit Server its boxes at 66 and
106 like ManageServerScreen, labels one pixel in.

Fixes #380, #381

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 23:05:32 +02:00
Ludovic MarieandClaude Opus 5.5 37f6481e14 Server list rows: favicons, styled MOTDs, player count colours, icon arrows
OnlineServerEntry draws the server's 64x64 favicon (from servers.dat, then
from each ping, saved back like updateServerList), the description as a
styled component with legacy codes, formatPlayerCount's dark grey with
grey numbers, and on hover the join and move arrows over the icon. The
icon's right half joins, its left quarters move the server up or down.

Fixes #365, #378, #370

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:53:15 +02:00
Ludovic MarieandClaude Opus 5.5 e1d9ae1dd7 horizon-shaderpack: plan a Java pack's full-screen passes like Iris
The begin, prepare, deferred, composite and final passes of a program
folder: what each writes (the later of DRAWBUFFERS and RENDERTARGETS),
which side of every ping-ponged colour target it reads (Iris's
BufferFlipper, with flip.* overrides), mipmapped targets, scale.*, and
the targets' formats, clearing and sizes from the consts of every pass
in Iris's order. The preprocessor now keeps the directive comments of
active code, as Iris's does, so a DRAWBUFFERS in an #if'd out branch
doesn't count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic MarieandClaude Opus 5.5 38a9f6543f horizon-shaderpack: read shaders.properties like Iris
Java .properties parsing, the two reads Iris makes (the raw file for
screens, sliders and profiles; the preprocessed one, with enabled options
and the environment's macros defined, for everything else), option
screens, profiles and the current one, program.*.enabled through a port
of Iris's BooleanParser, image formats for shadowcomp's writeonly
images, and blend overrides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic MarieandClaude Opus 5.5 98ebdcad7b horizon-shaderpack: translate Java shader pack programs to WGSL
The legaliser of docs/shader-packs.md step 1, on a syntax tree (glsl-lang)
instead of the spike's tokens: attribute/varying to located in/out with
matrix and array varyings split, loose uniforms in one std140 block that
extends across a pass, combined samplers split into texture + sampler
bindings (through function parameters, respecting locals that hide them),
compatibility built-ins and Iris's core-profile names declared, legacy
texture and shadow calls, hoisted global initialisers, sized unsized
arrays, isnan/isinf/modf helpers, and dead code after a jump dropped (the
spike's 'naga constructor bug' in BSL was code after discard). naga then
parses, validates and writes WGSL. The programs of one pass share a Link
so locations, bindings and the uniform layout agree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic MarieandClaude Opus 5.5 b22583d057 horizon-shaderpack: open Java shader packs and preprocess them like Iris
A new crate for the OptiFine/Iris translation (docs/shader-packs.md, order
step 1, first half): open a pack folder or zip (shaders/ found as Iris finds
it), list its programs per dimension folder, build the #include graph,
discover options per include component (OptionAnnotatedSource rules: boolean
defines referenced by #ifdef, value defines and configurable consts with a
[...] list, conflicting defaults dropped), write the player's values into
the lines, and expand Iris's standard macros with pp-rs, keeping line
numbers so errors name file:line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:36:18 +02:00
Ludovic Marie d28521f765 Merge pull request #362 from ludovic111/feat/dualsense-lights
DualSense lightbar and player LEDs
2026-10-15 22:31:10 +02:00
Ludovic Marie 7d6936e000 Merge pull request #361 from ludovic111/feat/dualsense-vibration
DualSense vibration: shaped pulses, HD haptics on USB (opt-in)
2026-10-15 22:30:54 +02:00
Ludovic Marie 5419ef0ed4 Merge pull request #360 from ludovic111/feat/dualsense-triggers
DualSense adaptive triggers
2026-10-15 22:30:46 +02:00
Ludovic Marie 457fb77ab0 Merge pull request #403 from Online5277/fix/server-ping-tooltip
Show ping tooltips over multiplayer network bars
2026-10-15 22:29:59 +02:00
Ludovic MarieandClaude Opus 5.5 850788d46c F3 shows the debug crosshair's world axes (#375)
The default debug profile enables 3d_crosshair while the overlay is open:
GameRenderer draws DebugCrosshairRenderer's axes one block ahead of the
camera (0.01 x GUI scale long; X red, Y green, Z light blue, 2 px over a
4 px black line) and Hud.extractCrosshair skips the 2D crosshair and its
attack indicator. Hidden with reduced debug info, in third person and
with F1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 77127ccfed F3 overlay like vanilla's DebugScreenOverlay (#363, #376, #377)
The overlay is drawn after screens and toasts, so it covers menus, and on
the title and options screens too (Gui.extractRenderState); F1 hides it
only in game. It lays out at the debug GUI scale (debugGuiScale, default
half the largest automatic scale) and shows the default profile's entries
sorted by id, split into vanilla's two columns: fps, version, position,
section position, performance impactors, system specs (CPU, display,
window, GPU, backend) and the integrated server's tick time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:12:56 +02:00
Ludovic Marie 13c4230431 Merge pull request #347 from ludovic111/feat/shader-pack-screen
Shader packs (3/3): pick a pack in Video Settings
2026-10-15 22:07:21 +02:00
Ludovic Marie 99dfeabfa5 Merge pull request #346 from ludovic111/feat/shader-pack-render
Shader packs (2/3): run a pack's composite stages over the world, with hot reload
2026-10-15 22:07:01 +02:00
Ludovic Marie 4a0c639f63 Merge pull request #345 from ludovic111/feat/game-mods
Mods in the game: Options > Mods, hooks and HUD panels, off by default
2026-10-15 22:06:46 +02:00
Ludovic Marie 18b5310b0f Merge pull request #343 from ludovic111/fix/sprite-interpolation
Interpolate animated textures like vanilla (prismarine, magma, stems, lit furnaces)
2026-10-15 22:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 5a0ad89207 Graphics presets set the distances like vanilla (#382)
GraphicsPreset.apply also sets render distance (8/16/32), simulation
distance (6/12/12), chunk update priority, smooth lighting and menu blur,
and its cloud range (32/64/128) and Fabulous entity distance (1.25)
differ from what Horizon set. Dragging the classic preset slider only
moved the label: the preset now applies whenever its value changes
(Options.applyGraphicsPreset), in both menu styles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:00:55 +02:00
Cosmic f5a795988b Draw sulfur cube cores before translucent shells 2026-10-15 15:56:59 -04:00
Ludovic MarieandClaude Opus 5.5 d1495076e6 Commands follow the world's Allow Commands (#372)
The single-player owner was always at permission level 4. Like
IntegratedServer.getProfilePermissions, the owner now gets OWNER (4)
only when the world allows commands, else ALL (0): /gamemode, /give and
friends answer "Unknown or incomplete command" and are not suggested
(the client's tree only holds usable nodes, Commands.sendCommands).
allowCommands is read from level.dat, set by World Options, and worlds
made without the Create New World screen take its default (Creative only).
The server console, the command harness and horizon-ctl `command` keep
the owner's level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:53:24 +02:00
Ludovic Marie b66eea6506 Merge pull request #348 from ludovic111/feat/dualsense-hid
DualSense output over HID: the report, a writer thread, and rumble through it
2026-10-15 21:52:08 +02:00
Ludovic MarieandClaude Opus 5.5 b5a7138489 Reopening a world no longer duplicates its entities (#374)
Save and Quit to Title kept boats, minecarts, armor stands, item frames,
paintings and other non-mob entities in memory. Opening the world again
loaded them from disk a second time and the next save wrote both copies,
so each reopen doubled them until the game crawled. Closing and opening a
world now let go of every entity of the level (MinecraftServer.stopServer
unloads them with the level). tools/test_world_reopen_parity.py reopens a
world three times and checks its minecarts stay the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:43:25 +02:00
Cosmic 725146c0ca Generate sprite mips before array-only enlargement 2026-10-15 15:41:53 -04:00
Cosmic d675918db9 Show server latency when hovering network bars 2026-10-15 15:30:10 -04:00
Ludovic Marie 0c103a4a97 Merge pull request #402 from Online5277/fix/menu-hotbar-keys
Prevent hotbar selection from changing behind menus
2026-10-15 21:30:02 +02:00
Cosmic 9b4d5d5613 Keep gameplay hotbar selection out of open menus 2026-10-15 15:20:09 -04:00
Ludovic Marie 292873e11a Merge pull request #397 from ludovic111/feat/inventory-sort
Sort buttons for the inventory and chests (extra, off by default)
2026-10-15 21:08:49 +02:00
Ludovic Marie c6aefdb82b Merge pull request #396 from ludovic111/feat/mouse-tweaks
Mouse Tweaks-style inventory controls (extra, off by default)
2026-10-15 21:08:44 +02:00
Ludovic Marie 66dc8ce3a4 Merge pull request #395 from ludovic111/fix/creative-search-select
Clicking a creative search result selects the query
2026-10-15 21:08:36 +02:00
Ludovic Marie 6bf89f912c Merge pull request #393 from ludovic111/fix/quick-craft-highlight
Drag highlight and counts as AbstractContainerScreen draws them
2026-10-15 21:08:28 +02:00
Ludovic Marie 17feed1620 Merge pull request #392 from ludovic111/fix/shift-double-click
Shift + double-click moves every matching stack
2026-10-15 21:08:19 +02:00
Ludovic Marie 32f7356eda Merge pull request #391 from ludovic111/fix/creative-item-tab-clicks
Creative item tabs click as CreativeModeInventoryScreen does
2026-10-15 21:08:12 +02:00
Ludovic Marie 2f23a776f8 Merge pull request #398 from ludovic111/fix/pick-block
Pick Block picks from the inventory in survival
2026-10-15 21:08:03 +02:00
Ludovic Marie bf4fcc3aa9 Merge pull request #394 from ludovic111/fix/text-highlight
Vanilla text selection colours and blinking cursors in the creative search and anvil
2026-10-15 21:07:57 +02:00
Ludovic MarieandClaude Opus 5.5 8e6a129f6b LAN: every player model wears its own armour
The host drew each guest with the host's own armour (player_armor_layers),
and a guest ignored ClientboundSetEquipmentPacket, so it drew the host and
other guests bare-handed and without armour. Player models now carry what
they wear in extra.head..feet: the host fills it from the guest's armour,
the guest from SetEquipment (main and off hand too, for mobs as well), and
both draw HumanoidArmorLayer from it. The host also draws what a guest holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 dd856ec5f6 LAN guest: tick the player's own model on a server, so the guest sees himself
The player model (third person, the inventory's preview) was only ticked by
the single-player tick; on a server it was never created.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 fd1acd5ed7 tools/lan_e2e.py: a host and a guest on LAN, checked from both sides
Two Horizon games under horizon-ctl, each with its own scratch home: the
host opens a survival world to LAN with commands, the guest joins by
address, and each check (tab, self, armour, nametags, chest, crafting,
damage) is reported on its own with screenshots of both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:56:45 +02:00
Ludovic MarieandClaude Opus 5.5 227edf7517 LAN guest: crafting in the inventory grid and at a crafting table
A guest could not craft: the host's inventory menu for guests had no
crafting slots (0-4 were always empty), and a crafting table the guest
used switched the host's own grid to 3x3 and opened nothing for the guest.

The guest's inventory menu (window 0) and a crafting table window
(minecraft:crafting) now run the host's vanilla-checked menu engine
(horizon_client::menu, Kind::Inventory / Kind::Crafting) over the guest's
inventory, kept between clicks so drags work, with the usual click
corrections. The 2x2 grid and its result are real slots of the guest's
inventory menu, so the slot sync carries them. Closing a menu puts its grid
back into the inventory (dropped if full); a guest leaving keeps it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:56 +02:00
Ludovic MarieandClaude Opus 5.5 9948ff42aa LAN: other players' name tags, and the host under its own name
No player had a name tag: only mobs with a custom name drew one. The other
players now get theirs on both sides (the guests on the host, the players a
server sends on a client), shown as LivingEntityRenderer.shouldShowName
allows: no custom name or crosshair needed, a crouching player's only within
32 blocks, team visibility and colours by player name, the below-name score.

The host also joined its own LAN world as "Player": the guests' player
list, name tags and chat, the MOTD and the offline UUID now use the host's
profile name, as IntegratedServer does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:56 +02:00
Ludovic MarieandClaude Opus 5.5 8d6b8b3f03 LAN: a trading screen a guest opens releases the chest it replaces
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:37:49 +02:00
Ludovic MarieandClaude Opus 5.5 f601630f05 LAN guest: fall damage, regeneration and hunger on the host
Nothing hurt a guest: the host only set a guest's position from its move
packets, so a fall did nothing, and their body never ticked on the server
(no damage clock, no regeneration, no food tick).

Each tick the host now runs a guest's server-side body like ServerPlayer:
the damage clock and Entity.baseTick's fluid phase, then
ServerPlayer.doCheckFallDamage for every move packet accepted that tick
(the supporting block, the fall distance and the landing, through the same
landing code as the host's own falls, so water resets a fall and lava
halves it), then peaceful regeneration and the food tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 20:28:36 +02:00
Ludovic MarieandClaude Opus 5.5 4718fef2ab LAN: chest lids open and close for every player
A chest a guest opened opened on the host only, and never closed: the
host ran the guest's open as its own player's (one opener, a local block
event), nothing was sent to the guests, and the guest's close was not
counted. Chests now count their openers like ContainerOpenersCounter (the
host and each guest; a guest's close, a menu it replaces and its leaving
take it off) and the block event goes to the guests too (shulker boxes as
well). A guest's client handles ClientboundBlockEventPacket and ticks its
block entities (ClientLevel.tickBlockEntities, without the server copy's
bell sound and gateway ticks), so lids move on its screen.

The control socket's block reply gives a chest's or shulker box's lid
openness (lid, 0..1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:53:23 +02:00
Ludovic MarieandClaude Opus 5.5 9e2d46d54e Pick Block picks from the inventory in survival, as vanilla does
In singleplayer the middle button did nothing outside creative, and in
creative it overwrote the selected hotbar slot. It now runs the server's
handlePickItemFromBlock (tryPickItem): a stack of the block's item
already in the hotbar is selected; one in the main inventory is swapped
into getSuitableHotbarSlot (the first empty hotbar slot from the selected
one, else the first unenchanted); only creative adds the item when none
is there, moving what the slot held to a free slot. Servers already got
the packet and do this themselves.

Fixes #367.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:19 +02:00
Ludovic MarieandClaude Opus 5.5 adef2bdc24 Draw text selections with vanilla's colours and blink the field cursors
GuiGraphicsExtractor.textHighlight paints an inverting white fill, then
adds pure blue (GUI_TEXT_HIGHLIGHT, additive). Horizon only inverted, so
selections lacked the blue: black text on a light box, not blue text.
The anvil's name box skips the inversion (setInvertHighlightedTextColor
false), as in AnvilScreen.

The creative search and anvil name box now draw like EditBox: the cursor
blinks from when the field took the focus (300 ms on, 300 ms off), turns
into a bar once the field is full, and Select All highlights the value.
Sign lines draw their Select All highlight too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:19 +02:00
Ludovic MarieandClaude Opus 5.5 3761f2a5fb Sort buttons for the inventory and chests, an extra off by default
The Extras page's Inventory section gains "Sort buttons" (sort, off) and
"Sort key" (sortKey, unbound). A small button right of a chest, barrel,
ender chest or shulker box title sorts the container; one right of the
"Inventory" line sorts the main inventory (the hotbar stays). The key
sorts the part under the cursor.

inv_sort::plan merges partial stacks, then orders the stacks by creative
tab order (the search tab's), then name, full stacks first, using only
left PICKUP clicks with an empty hand: the game sends them as ordinary
clicks, so vanilla servers accept them. A unit test replays the plans
through the menu engine on 40 random chests.

The Extras page binds any extra key the way it bound the zoom key
(Item::key_name, Extras::set_key).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 05a1e10bbe Mouse Tweaks-style inventory controls, an extra off by default
The Extras page gains an Inventory section with "Mouse Tweaks controls"
(mouseTweaks, off) and "Scroll wheel moves items" (mouseTweaksWheel). In
the player inventory and chest-like containers, locally and on servers:

- right-drag holding a stack puts one item in each slot entered, at once;
- left-drag holding a stack gathers the slots of the same item into the
  hand, while vanilla's drag covers one slot (a drag over empty slots
  still splits as vanilla does); with Shift they are quick-moved;
- Shift + left-drag with an empty hand quick-moves every slot entered;
- the wheel over a slot moves one item to the other side (down) or
  brings one back (up).

Every action is ordinary slot clicks: the menu engine's, or ContainerClick
packets for any slot (remote_slot_click, factored out of remote_click).
With the extra off each hook returns at once.

horizon-ctl gains drag-at and scroll-at, which send the window events of
a mouse drag and of wheel steps, to test these and vanilla's drags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 3e0793af21 Select the creative search query when a result is clicked
CreativeModeInventoryScreen.slotClicked moves the search box cursor to
the end and sets the highlight to 0 on any click of a display slot, so
the next typed character replaces the whole query. Horizon kept the
query and appended to it.

horizon-ctl's type now edits the creative search as typing does (the
selection and the 50 character limit apply) instead of appending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 5efb5ea335 Draw the quick craft drag as AbstractContainerScreen does
While a held stack is dragged over slots, each dragged slot shows the
stack it would get over a white highlight, its count yellow when the slot
caps it, and the cursor shows what stays in hand (extractSlot,
recalculateQuickCraftRemaining, extractCarriedItem); a drag over a single
slot hides that slot's item. Slots join the drag by
shouldAddSlotToQuickCraft (same item or empty, placeable, canDragTo, and
not more slots than carried items for a right drag), so the preview counts
the slots the menu will fill.

The menu engine computes both (Menu::quick_craft_accepts and
quick_craft_preview) for the inventory, crafting table, chests, hopper,
dispenser, shulker box, furnaces, crafter, loom, trading and creative
screens; the slot and carried-stack drawing of those screens goes through
draw_slot_item and draw_carried.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 a061c40fde Move every matching stack on a Shift double click
AbstractContainerScreen.mouseReleased: a Shift double click QUICK_MOVEs
every slot of the clicked slot's container that holds the item the last
Shift click moved (lastQuickMoved), when canTakeItemForPickAll allows the
clicked slot. Menu::quick_move_all runs it on the menu engine;
test_menu_click_parity generates it as a QUICK_MOVE_ALL op that the oracle
runs with the real Slot.mayPickup, Slot.container and
AbstractContainerMenu.canItemQuickReplace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic MarieandClaude Opus 5.5 b9e926588b Run the creative item tabs' clicks as CreativeModeInventoryScreen does
The item tabs handled clicks on press with their own rules: Shift sent the
item to the inventory, the hotbar row swapped with the carried stack, a
carried stack clicked on the tab was always deleted, and there were no
drags. They now go through the same mouseClicked/mouseReleased as the other
container screens, over ItemPickerMenu:

- display slots: slotClicked's CONTAINER branch (creative::click_display):
  an empty hand takes the shown stack (a full one with Shift), the same item
  grows by one, fills with Shift or shrinks with a right click, another item
  is deleted or loses one; number keys fill a hotbar slot, Q drops copies;
- the hotbar row: the menu engine's new ItemPicker kind, whose
  quickMoveStack deletes the stack (Shift-click on the hotbar from an item
  tab), and whose display slots take no drags or PICKUP_ALL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:44:18 +02:00
Ludovic Marie aca5f3a8ba Merge pull request #358 from ludovic111/fix/inventory-shift
Shift-click on every container screen, even when Shift is pressed after it opens
2026-10-15 19:38:36 +02:00
Ludovic MarieandClaude Opus 5.5 f8bcfcfec6 DualSense lights: pin the host defaults, document quit and the mute LED
Tests pin player 1 (0x04), the default blue (0, 0, 128) and the instant
bit (0x20) to hid-playstation.c and SDL; the docs say what Horizon resets
on quit (more than SDL or the kernel do) and that the mute LED is never
touched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 be57f82a07 DualSense HD haptics: Windows' device name, the arming rule, Bluetooth notes
- The pad's audio device is also found by Windows' "Wireless Controller"
  endpoint name (the four channels rule out a DualShock 4); a name test.
- docs/controllers.md: the haptics need reports without the rumble flags
  (SDL, nondebug/dualsense, dse-haptics), which Horizon now always sends while
  they are open; Windows needs the Quadraphonic speaker setup; on Linux ALSA
  can't open the card while PipeWire holds it; Bluetooth haptics (report
  0x32, SAxense) are documented and not done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 6663af199e DualSense triggers: catches at the press point, shields, official modes only
- A loaded crossbow and the boosts (rocket, pig, strider) get a weapon
  catch that breaks in the zone where the trigger threshold presses, so the
  click is the shot (the boost was a feedback wall that never gave way).
- Draws start at zone 2 and stop at 6 of 8 (held for as long as you like).
- A shield in the use hand weighs (feedback 2, 3); disabled by an axe, the
  trigger locks (feedback 1, 8), as Deathloop does for a jammed gun.
- Mining buzzes slower on harder blocks (40/30/20/10 Hz).
- A 0 Hz vibration frees the trigger (Nielk1's factory); tests pin the
  bytes to DSX++'s and dualsensectl's examples.
- docs/controllers.md: the table with the effect of each action and why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 6086758f94 DualSense output: rumble, LEDs and Bluetooth checked against SDL and the kernel
- Every report carries the motors; still motors send no rumble flags, as SDL
  stops rumble (a report without them ends the emulation and re-arms the
  audio haptics), so a trigger change no longer cuts a vibration.
- Legacy vibration at half strength (SDL); the Edge always takes V2; an
  unreadable firmware version falls back to legacy.
- The first report after opening clears the motor/trigger attenuation
  (flags 1 bit 6, byte 36; dualsensectl, pydualsense).
- The LED reset goes in a report of its own before any colour
  (hid-playstation's dualsense_reset_leds); over Bluetooth the colour waits
  for the connection animation (SDL's sensor clock check).
- Bluetooth output only where gilrs keeps reading the pad (Linux), else with
  HORIZON_DUALSENSE_BT=1: any output switches the pad to its full report.
- USB is preferred when the pad is on both.
- Tests pin the reset report and its CRC (zlib), the flags and the clock offset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 df3244d335 DualSense lightbar and player LEDs
The lightbar shows the player's health (green to amber to red) or the
dimension, at half brightness, chosen in the controller options (Lightbar:
Health / Dimension / Off; health by default). The player LEDs show player 1.
When Horizon lets go of the pad, the lights it changed go back to a host's
defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 0681064767 DualSense vibration: shaped pulses, HD haptics on USB (opt-in)
Each rumble event plays a short shape instead of one flat block: a hit
starts at full strength and dies away, an explosion rolls on, a totem
swells, a broken block is a tick. They drive the motors' emulation in the
HID report.

On USB the pad is also a four-channel audio device whose channels 3 and 4
drive its actuators: with HORIZON_DUALSENSE_HAPTICS=1 the shapes play there
as waveforms (80 Hz and 200 Hz tones for the heavy and light parts),
channels 1 and 2 silent, until that path is confirmed on a real pad.
docs/controllers.md says what each OS and transport allows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic MarieandClaude Opus 5.5 a316768bfc DualSense adaptive triggers
Each trigger feels the action bound to it: resistance growing while a bow
draws, a trident charges or a crossbow loads, a gun trigger's click on a
loaded crossbow, a wall where pulling boosts (a rocket while gliding, the
stick of a ridden pig or strider), and a light buzz while mining, scaled by
the block's hardness. Free in menus, paused, unfocused, on another pad,
with controllers off, and on quit. On by default, "Adaptive Triggers" in
the controller options turns them off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 19:23:05 +02:00
Ludovic Marie 36f61d68f1 Merge pull request #250 from ludovic111/feat/telemetry-launcher
Opt-in data collection (2/5): the launcher asks, Settings changes it, CLI and MCP
2026-10-15 19:06:43 +02:00
Ludovic MarieandClaude Opus 5.5 d53a366718 Opt-in data collection (2/5): the launcher asks, and Settings changes it
- First run (and when CONSENT_VERSION changes): a question over the launcher
  with four switches, all off, what each sends in plain words, what is never
  sent, "View what would be sent" (the exact JSON), and "Send nothing" /
  "Save choices".
- Settings → Data collection: the same switches any time, the install ID and
  what waits, "View what would be sent" and "Delete my data" (server deletion,
  outbox cleared, new install ID).
- The outbox is sent at start and on close (at most 3 s), only when there is a
  server (none yet) and only what the player allows.
- CLI `horizon-launcher data status|allow|deny|show|delete|send|bug|benchmark`
  and MCP tools data_collection, set_data_collection, data_preview,
  delete_my_data, report_bug. Bug reports and benchmark uploads are printed for
  the player to confirm; logs are scrubbed and lose chat lines.
- horizon-telemetry joins the launcher's paths (launcher.yml, release_train,
  pr_ready).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:52:45 +02:00
Ludovic Marie 713ff1d4a1 Merge pull request #355 from ludovic111/perf/spawn-heightmap
Natural spawning skips air sections when finding the surface
2026-10-15 18:42:48 +02:00
Ludovic Marie 4566d3175b Merge pull request #354 from ludovic111/fix/cpu-defaults
Cap a fresh install at vanilla's 120 FPS
2026-10-15 18:42:36 +02:00
Ludovic Marie 1264cb6556 Merge pull request #353 from ludovic111/fix/proto-save-off-main
Save proto chunks on a worker, not the main thread
2026-10-15 18:42:24 +02:00
Ludovic Marie 1ce266f66c Merge pull request #352 from ludovic111/fix/remesh-light-changes
Relit chunks remesh only where their light changed
2026-10-15 18:42:06 +02:00
Ludovic MarieandClaude Opus 5.5 ac955934b5 DualSense output over HID: the report, a writer thread, and rumble through it
The horizon-dualsense crate builds the DualSense's output report (USB 0x02,
Bluetooth 0x31 with its CRC-32 seeded with 0xA2) and writes it with hidapi
from a thread. The game sends its rumble pulses through it when the active
pad is a DualSense, so vibration now works on macOS too, and falls back to
gilrs force feedback when the pad can't be opened. Quitting frees the
triggers and stops the motors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:38:10 +02:00
Ludovic MarieandClaude Opus 5.5 466e260514 Mods in the game: load the ones turned on, hooks, HUD panels, Options > Mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 18:33:40 +02:00
Ludovic Marie 9cb9f8beeb Merge pull request #329 from ludovic111/feat/cargo-horizon-mod
cargo horizon-mod: new, build and run Horizon mods
2026-10-15 18:30:21 +02:00
Ludovic Marie 1a04810baf Merge pull request #328 from ludovic111/feat/mod-runtime
Horizon mods runtime: sandboxed WebAssembly components with wasmtime
2026-10-15 18:04:48 +02:00
Ludovic Marie c3b32fdab5 Merge pull request #327 from ludovic111/feat/mod-sdk
Horizon mod SDK: the WIT contract, a Rust SDK and an example mod
2026-10-15 18:02:34 +02:00
Ludovic Marie b8ed3a113a Merge pull request #326 from ludovic111/docs/mods-design
Mods design: Horizon's Rust mod system and what carries over from Java mods
2026-10-15 18:02:26 +02:00
Ludovic Marie 68cbad54a3 Merge pull request #351 from ludovic111/feat/agent-waits
Agent control: wait for a state, and the log of what happened
2026-10-15 17:43:54 +02:00
Ludovic Marie 24a702181a Merge pull request #350 from ludovic111/feat/agent-options
Agent control: read and set every option by key
2026-10-15 17:35:49 +02:00
Ludovic Marie f6ceb1da22 Merge pull request #342 from ludovic111/feat/launcher-mcp-game
Launcher MCP: play the running game through game_* tools
2026-10-15 17:34:47 +02:00
Ludovic Marie e6588a13f3 Merge pull request #349 from ludovic111/feat/agent-ui-tree
Agent control: the widget tree of every screen, click and set by id
2026-10-15 17:33:58 +02:00
Ludovic MarieandClaude Opus 5.5 c8d5d13813 Interpolate animated textures like vanilla (prismarine, magma, stems, lit furnaces)
SpriteContents.AnimationState.drawToAtlas mixes an animation with
"interpolate": true towards its next frame every tick
(animate_sprite_interpolate.fsh: mix(current, next, subFrame / time),
per mip level, into the 8-bit atlas). Horizon stepped the frames: the
sprite upload now mixes the two frames' mip chains the same way and
re-uploads every tick while the mix changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:32:39 +02:00
Ludovic Marie 49fc287254 Merge pull request #321 from ludovic111/docs/agent-control
Agent control: audit and design (docs/agent-control.md)
2026-10-15 17:25:24 +02:00
Ludovic Marie 92d1338e24 Merge pull request #341 from rdbtCVS/perf/20261015-animated-sprite-mips
Cache animated sprite mip chains within an 8 MiB budget
2026-10-15 17:25:19 +02:00
Ludovic Marie 43134d5ebf Merge pull request #330 from rdbtCVS/perf/20261015-shared-model-library
perf(client): share parsed block and item models
2026-10-15 17:24:47 +02:00
Ludovic MarieandClaude Opus 5.5 6b26f926df docs/agent-control.md: match what was built; before and after; status
The design sections now describe the implementation: ids for rows,
switches and segments, the redraw wait, wait-for --since and event KIND,
the events log (subscribe comes next), the launcher proxying through
horizon-ctl mcp. A before/after table and the PRs of each step replace
the plan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 17:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 293c589e35 Read the physical Shift and Ctrl keys on every container screen
The workstation screens (anvil, grindstone, stonecutter, smithing,
enchanting, cartography, loom, beacon, villager trading, brewing stand),
the horse/llama/camel inventory and the creative item grid read Shift from
the sneak mapping (`keys`). Behind a screen a Shift press never reaches the
mapping (KeyboardHandler.keyPress: only releases do) and Right Shift isn't
mapped at all, so shift-click there worked only when Shift was already held
before the screen opened. They now read `has_shift_down` like
AbstractContainerScreen (`InputWithModifiers.hasShiftDown`), and the recipe
book reads Ctrl the same way (`Minecraft.hasControlDown`).

`status` reports `physical_keys` so scripts can check what the OS delivered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:50:44 +02:00
Ludovic Marie 58af88f809 Merge pull request #344 from ludovic111/feat/shader-pack-loader
Shader packs (1/3): load and validate Horizon WGSL packs
2026-10-15 16:14:09 +02:00
Ludovic Marie 7e9bca982b Merge pull request #340 from ludovic111/docs/resource-pack-audit
Resource packs: what works, audited against 26.3
2026-10-15 16:14:03 +02:00
Ludovic Marie b4cb428ca9 Merge pull request #333 from ludovic111/docs/shader-packs
Shader packs design: Horizon WGSL packs and OptiFine/Iris translation
2026-10-15 16:13:56 +02:00
Ludovic MarieandClaude Opus 5.5 a6047e59c0 Shader packs: pick a pack in Video Settings (Advanced)
A Shader Pack row cycles Off and every folder in shaderpacks/; the game
loads the pick on the next frame. Not part of the presets, off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:11:21 +02:00
Ludovic MarieandClaude Opus 5.5 1b468100aa Save proto chunks on a worker, not the main thread (#224)
Converting a proto chunk for saving (VanillaTerrain::saved_proto copies it
block by block into a column, then encodes it) ran on the main thread: in
Streaming::save during the autosave, and in Streaming::update for every far
proto unloaded while exploring. The protos are now checked out (like a
decoration job does) and saved by a worker, ahead of every other job, then
handed back. The explicit save and quitting still save them in place, after
waiting for the saves in flight; dropping the streamer waits for them too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 16:00:00 +02:00
Ludovic MarieandClaude Opus 5.5 e0ecbf1db1 Test that relit columns keep every mesh current
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:59:58 +02:00
Ludovic MarieandClaude Opus 5.5 3137c7b977 Relit chunks remesh only where their light changed (#270)
A light job for a chunk that was already lit (a neighbour loaded, or a block
changed in an unlit neighbour) dirtied every section of the 3x3 columns
around it: 216 remeshes, whether or not any light changed. It now writes only
the sections whose light changed and dirties the sections around the changed
positions, as the incremental relight already does. A chunk lit for the
first time still dirties its 3x3 neighbourhood.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:59:58 +02:00
Ludovic MarieandClaude Opus 5.5 7f631bb36f Natural spawning skips sections of air when it finds the surface
GameLevel::heightmap found getHeight by testing every block from the top
of the world down (with a block tag lookup each), for every spawnable chunk
every tick: a quarter of the main thread in a village. Sections holding
only air are now passed over whole; air counts for no heightmap, so the
height is the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:43:40 +02:00
Ludovic MarieandClaude Opus 5.5 047bf45af1 Shader packs doc: match the first slice (sun angle, folders)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:25:31 +02:00
Ludovic MarieandClaude Opus 5.5 35f78ae27c Shader packs: run a pack's composite stages over the world, with hot reload
With shaderPack set in horizon-graphics.txt, the world renders into
world_post's target with its depth kept, each composite stage of the
pack (gfx/pack_stages.rs) draws a full-screen pass reading the scene
colour and depth, the previous stage and the Frame uniform (camera,
matrices, time, sun and moon, sky and fog colours and distances,
weather, camera state, dimension), and the last output goes to the
screen through the usual copy/FXAA, under the hand, screen effects and
HUD. The game loads the pack from shaderpacks/, checks its files twice
a second and on F3 + T, and keeps the last good pack on errors, which
go to chat. Without a pack nothing changes: no target, no extra pass,
depth discarded as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:25:23 +02:00
Ludovic MarieandClaude Opus 5.5 9739740379 Shader packs: load and validate Horizon WGSL packs (pack.toml, prelude, options)
shader_pack.rs reads shaderpacks/<pack>/pack.toml, the player's option
values (shaderpacks/<pack>.txt) and each composite stage's WGSL, puts
the api 1 prelude (bindings, the Frame uniform, depth helpers) before
it and has naga parse and validate it, with errors pointing into the
pack's own file. Options are WGSL override constants. A file stamp
tells when the pack was edited, for hot reload. Nothing draws with it
yet. examples/shaderpacks/horizon-sample is the MIT sample pack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:18:32 +02:00
Ludovic MarieandClaude Opus 5.5 eb466fe2d2 Shader packs: Iris translation spike results and implementation order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 15:15:06 +02:00
rdbtCVS 6d4aa954bb docs: record model sharing results after upstream integration 2026-10-15 14:00:10 +01:00
rdbtCVS b1c365fca1 Cache animated sprite mip chains within an 8 MiB budget 2026-10-15 13:59:45 +01:00
Ludovic MarieandClaude Opus 5.5 0892f944c0 Agent control: wait for a state, and the log of what happened
wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND replies once
the game gets there (--timeout, default 30 s, ends in an error of kind
timeout; --since SEQ counts chat and events from an earlier point), so
scripts and agents stop polling. events [--since SEQ] lists what
happened, oldest first, numbered: screens, chat lines, deaths, respawns,
items gained or lost, dimensions, worlds opened and closed. The state is
compared once per event loop turn and only under --control, so watching
never changes the game. respawn now says when the death screen's button
is not active yet instead of clicking it to no effect.

horizon-ctl and its MCP gain both; tools/agent_control_e2e.py gains an
events scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:51:30 +02:00
Ludovic MarieandClaude Opus 5.5 41f82e8db0 Resource packs: audit of every 26.3 pack feature with its proof
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:42:42 +02:00
Ludovic MarieandClaude Opus 5.5 1a067b3d9e Agent control: read and set every option by key
options-get [KEY...] gives every options.txt option by its vanilla key
with its value and what it accepts (type, range, step or choices; enums
by name, key bindings as key.keyboard.w). options-set KEY VALUE sets one,
applies it at once and saves options.txt as the options screen does
(language and resource packs reload too); a value the option refuses is
an error naming what it accepts, and the option keeps its value. After a
set, ui reads the redrawn screen.

horizon-ctl and its MCP gain both; tools/agent_control_e2e.py gains an
options scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:38:20 +02:00
Ludovic MarieandClaude Opus 5.5 304ea50a5f Shader packs design: the renderer's passes, Horizon WGSL packs and Iris translation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:35:11 +02:00
Ludovic MarieandClaude Opus 5.5 1573e4e553 Cap a fresh install at vanilla's 120 frames per second (#271)
Without an options.txt the game ran unlimited (260), while an options.txt
without maxFps, like vanilla, gives 120. With VSync off (or rendering
offscreen) that kept a core busy drawing frames nobody sees.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:34:36 +02:00
Ludovic Marie 337ee470b5 Merge pull request #318 from rdbtCVS/perf/20261015-random-tick-eligibility
Cache random-tick section eligibility across unchanged chunks
2026-10-15 14:29:44 +02:00
Ludovic Marie 1286523047 Merge pull request #309 from rdbtCVS/perf/20261015-typed-block-facts
Load gameplay facts with typed decoding and interned shapes
2026-10-15 14:29:37 +02:00
Ludovic MarieandClaude Opus 5.5 b0abb62883 Launcher MCP: play the running game through game_* tools
An agent now needs one MCP server. horizon-launcher mcp runs the selected
version's horizon-ctl mcp as a child and offers its tools as game_<name>
(all but launch, which launch_client covers), passing calls and image
results through. launch_client starts the game with a control socket in
the launcher's data folder, takes world, creative and offscreen, and
returns once the socket answers. A game tool called while no game listens
says to call launch_client.

tools/launcher_mcp_e2e.py checks it end to end against this checkout's
builds in a scratch launcher home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:23:57 +02:00
Ludovic Marie a2b62913ba Merge pull request #331 from ludovic111/chore/discord-assets-to-kira
Move the Discord brand assets to the kira repo
2026-10-15 14:23:38 +02:00
Ludovic MarieandClaude Opus 5.5 8dc2a0e90a Move the Discord brand assets to the kira repo; this repo holds the game, launcher and website
The Discord server banner (page + PNG), the server icon render and Kira's avatar (SVG master +
render) now live in the private kira repo (ludovic111/kira#1). xtask icons no longer renders them;
the brand README points there, and CONTRIBUTING states what belongs in this repository.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:23:14 +02:00
rdbtCVS 969ff7e4d9 Merge main and preserve shared model-state preparation 2026-10-15 13:22:02 +01:00
rdbtCVS 7b8189d19c perf(client): share parsed block and item models 2026-10-15 13:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 8c5d1ed86b Agent control: the widget tree of every screen, click and set by id
`ui` lists every widget the last frame drew (title menus, pause, options
in both menu styles, inventories and menus) with an id, its kind, label,
bounds and value; `click ID` clicks one through the same window events
as the mouse, `set ID VALUE` moves a slider along its track. Ids come
from labels (FOV: 70 is fov), world/server/language rows carry their
names, Horizon switches, sliders and segments their row's caption, and
inventory slots are slot-ID with their stacks. After a click, the next
widget command waits for a redrawn frame.

horizon-ctl and its MCP gain ui, click and set, and launch --offscreen.
tools/agent_control_e2e.py gains a ui scenario and runs offscreen on
macOS; both scenarios now handle Singleplayer opening Create New World
when there are no worlds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:08:15 +02:00
Ludovic MarieandClaude Opus 5.5 90edb028bf Mods design: Horizon's Rust mod system and what carries over from Java mods
docs/mods.md: the sandboxed WebAssembly runtime, capabilities, hooks, budgets,
authoring, versions, multiplayer and distribution, with measured hook costs.
docs/research/java-mod-compat.md: why Fabric/NeoForge jars cannot run, data
import, which mods to port natively first, a porting guide outline.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:04:56 +02:00
Ludovic MarieandClaude Opus 5.5 0248df4d90 cargo horizon-mod: new, build and run Horizon mods
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:01:57 +02:00
Ludovic MarieandClaude Opus 5.5 b3906d803d Horizon mods runtime: load sandboxed WebAssembly components with wasmtime
Manifest and .hmod reading, capability checks against the component's imports,
fuel and memory limits per call, a trapping mod stopped alone, hot reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:01:57 +02:00
Ludovic MarieandClaude Opus 5.5 670e055f4a Horizon mod SDK: the horizon:mods WIT contract, a Rust SDK and an example mod
horizon-mod-sdk wraps wit-bindgen: a Mod trait with default hooks and export_mod!.
coords-hud shows coordinates, facing and the time of day, with /where and /coords.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:01:57 +02:00
Ludovic Marie c6eb50f10b Merge pull request #325 from ludovic111/feat/version-port-report
Port report: ranked work items from a Minecraft version diff
2026-10-15 14:00:06 +02:00
Ludovic Marie 62b704ceaf Merge pull request #324 from ludovic111/feat/version-port-classes
Diff vanilla classes method by method and map them to Horizon
2026-10-15 13:59:57 +02:00
Ludovic Marie 59d0148057 Merge pull request #323 from ludovic111/feat/version-port-diff
version_port.py: detect, prepare and diff Minecraft releases
2026-10-15 13:59:50 +02:00
Ludovic Marie 64f480ce65 Merge pull request #322 from ludovic111/docs/version-ports
Design: port each new Minecraft release with as little human work as possible
2026-10-15 13:59:44 +02:00
Ludovic MarieandClaude Opus 5.5 7c0d3ceea6 Port report: ranked work items from a version diff
tools/port_report.py turns a diff into work items: rank 1 blocks the switch (facts,
packets, DataFixers, data component types, report formats, tables to regenerate),
rank 2 and 3 one item per Horizon file that ports changed vanilla code (with or
without a harness), rank 4 new content, rank 5 summaries. version_port.py report
writes report.md and report.json; port prepares, diffs and reports in one go.
The class map now resolves names of both versions. The doc records the 26.2 -> 26.3
replay against Horizon's own code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:51:39 +02:00
Ludovic MarieandClaude Opus 5.5 93bb113e0d Diff vanilla classes method by method and map them to Horizon
tools/class_index.py hashes every method's bytecode with constant-pool references
resolved and branch targets as instruction indices, lambdas folded into their
declaring method; class diffs list moved classes. tools/vanilla_map.py maps vanilla
classes to the Rust files whose comments name them and the harnesses whose oracles
or probes reach them. version_port.py prepare indexes, diff reports both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:44:26 +02:00
Ludovic MarieandClaude Opus 5.5 58ad81674b version_port.py: detect, prepare and diff Minecraft releases
detect reads Mojang's manifest; prepare downloads and verifies a version into the
version store (outside the repository), runs its data generator and reads the
jar's own version facts; diff compares facts, packets, registries, blocks, item
components, commands, data packs, assets and lang.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:38:37 +02:00
Ludovic MarieandClaude Opus 5.5 720e91fb8c Design the version-port pipeline: detect, prepare, diff, regenerate, report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:33:51 +02:00
Ludovic MarieandClaude Opus 5.5 81c3e58a1c docs/agent-control.md: inventory of agent control and the design ahead
What the launcher, the game socket, horizon-ctl and the two MCP servers
can do today, and one model for what comes next: shared names and JSON
shapes, a widget tree with actions by id, options get/set, waits, an
event stream, the launcher's MCP proxying the game, errors with a kind,
and the safety rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 13:32:32 +02:00
Ludovic Marie a3cf73d9be Merge pull request #316 from ludovic111/feat/fishing-rod-visuals
Fishing: the rod casts, bites splash and bubble, and the line is vanilla's
2026-10-15 12:40:48 +02:00
Ludovic MarieandClaude Opus 5.5 44e21e6601 Merge main into feat/fishing-rod-visuals: keep the off-hand renderer, add the cast-rod flag
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:35:49 +02:00
Ludovic Marie ce6bc9c5c7 Merge pull request #320 from ludovic111/feat/trial-spawners-game
Trial spawners run in game
2026-10-15 12:32:58 +02:00
Ludovic Marie 96268647d3 Merge pull request #319 from ludovic111/feat/trial-spawners
Trial spawner state machine, checked against vanilla
2026-10-15 12:32:52 +02:00
Ludovic Marie d4c16084d7 Merge pull request #317 from ludovic111/fix/axe-copper-animation
Axes on copper: a shield in the off hand is raised first, and sculk sensors hear it
2026-10-15 12:32:40 +02:00
Ludovic Marie fd3d456de8 Merge pull request #315 from ludovic111/feat/minecart-sounds
Minecarts roll and rattle when ridden, and hits on vehicles sound
2026-10-15 12:32:32 +02:00
Ludovic Marie 23ac335b53 Merge pull request #314 from ludovic111/fix/offhand-render
Show the off-hand item in first person
2026-10-15 12:32:26 +02:00
Ludovic Marie 204e4fdccb Merge pull request #313 from ludovic111/fix/offhand-use
Use the off hand when the main hand passes: place blocks, eat, charge anchors
2026-10-15 12:32:20 +02:00
Ludovic Marie e00ce7c89e Merge pull request #312 from ludovic111/fix/offhand-crossbow-fireworks
Crossbows load firework rockets from either hand; bows and crossbows take held arrows first
2026-10-15 12:32:14 +02:00
Ludovic Marie 40d5b43e43 Merge pull request #311 from ludovic111/fix/shield-deflects-arrows
Shields turn arrows back, as vanilla does
2026-10-15 12:32:06 +02:00
Ludovic Marie d5e7dc76e6 Merge pull request #310 from ludovic111/fix/shield-block-no-hurt-sound
Shield blocks: the player hears the block sound, not the hurt sound
2026-10-15 12:32:00 +02:00
rdbtCVS ed0febf163 Merge main and preserve tick chunk membership invalidation 2026-10-15 11:29:05 +01:00
Ludovic MarieandClaude Opus 5.5 ed3d1e1168 Trial spawners run in game
Trial spawner block entities are kept (generated chunks and saves), tick on
the server with the level random and random sequences, spawn persistent
mobs, eject their rewards as items and set their block state, with their
level events and shutter sounds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:20:30 +02:00
Ludovic MarieandClaude Opus 5.5 ca76075cc9 Trial spawner state machine, checked against vanilla
mobs::trial_spawner: TrialSpawner and its state data, the registry
configs (data/*/trial_spawner), the six states, player detection with the
visual line of sight, mob spawns and reward ejection, read and saved as
vanilla does (a field that fails to decode takes its default alone).

tools/test_trial_spawner_parity.py ticks 2,500 random trial spawners in
VanillaNaturalSpawnOracle and natural_spawn_fixture and compares states,
spawns, events and ejected items line by line.

Also: copper grates have no visual shape; a slime's or magma cube's
obstruction box scales with the Size its spawn data sets; LootData::roll_split
for an empty-context getRandomItems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 12:20:29 +02:00
rdbtCVS ef92004ede test(client): cache legacy shape identity keys 2026-10-15 10:57:28 +01:00
rdbtCVS bea9d4b765 perf(client): decode gameplay facts without a JSON document tree 2026-10-15 10:57:28 +01:00
Ludovic MarieandClaude Opus 5.5 ce4d520f55 Fishing line: the hand side is a float, as in vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:55:30 +02:00
Ludovic MarieandClaude Opus 5.5 e162f9cdb7 Axes on copper: a shield in the off hand comes first, and sculk hears it
Scraping and waxing off copper already swing the arm, show the scrape and
wax-off particles and play the axe sound on main (#59, #104). Two gaps were
left in BlockTransformer.transformBlock:

- playerHasBlockingItemUseIntent: with a shield in the off hand, right-clicking
  with an axe raises the shield instead of scraping, unless sneaking.
- The block_change game event, which sculk sensors pick up.

Fixes #222

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:47:02 +02:00
Ludovic MarieandClaude Opus 5.5 64739d2b8b Show the off-hand item in first person
FirstPersonHandsAndItemsRenderer draws both hands: the off-hand item sits
on the other arm with its own equip height and swing, uses its own pose
when it is the hand in use (eating, blocking, drawing a bow), and
evaluateWhichHandsToRender hides a hand while a bow or crossbow is drawn
or a charged crossbow is held in the main hand. Only the main hand's empty
arm shows, as in vanilla.

Part of #276

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 5e2935747b Use the off hand when the main hand passes: place blocks, eat, charge anchors
Minecraft.startUseItem tries each InteractionHand in turn: useItemOn, then
useItem, stopping at the first that succeeds or fails. Item uses now act on
the hand that started them (Game::hand), so blocks, food, potions, buckets,
spawn eggs and the rest work from the off hand, the swing and the
re-equip animation follow that hand, and the empty-hand block use
(useWithoutItem) stays main-hand only. The respawn anchor passes on the
main hand when the off hand holds glowstone, as vanilla does, replacing the
special case for it.

The use_item_on harness now puts items in the off hand too.

Part of #276

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 ffd0297dc4 Crossbows load firework rockets from either hand; bows and crossbows take held arrows first
Player.getProjectile: what the weapon supports in the off hand, then in
the main hand (getHeldProjectile; crossbows also take firework rockets
there), else the first arrows in the inventory. Crossbows only looked
for arrows in the inventory, so a rocket in the off hand was never
loaded. A charged rocket is shot at an angle from 0.15 under the eyes
(CrossbowItem.createProjectile), every shot of a load holding one at
power 1.6 (getShootingPower), for three durability (getDurabilityUse).

Part of #276

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 e2473aafd8 Shields turn arrows back, as vanilla does
AbstractArrow.onHitEntity: an entity that takes no damage from the arrow
(a raised shield, invulnerability) turns it back with
ProjectileDeflection.REVERSE at power 0.2: a tenth of the speed, a half
turn give or take ten degrees from the arrow's own random; one left
almost still drops its pickup and is discarded. Arrows used to vanish.

The arrow tick now stops at the entity it hits: the game hurts the
entity, then arrows::finish_entity_hit spends the arrow (with its hit
sound, from the arrow's random) or turns it back, and ends the tick
(drag, gravity, fluids). The damage is taken from the speed at the hit,
the crit bonus from the arrow's random, the entity search follows
ProjectileUtil.getManyEntityHitResult (the box first, then
computeMargin's margin, which needs tickCount), Punch pushes only what
was hurt, and a refused burning arrow leaves the player's fire as it
was. Mobs' refusals keep the old ending until their hurt results follow
hurtServer.

The arrow harness gains an entity in the way that takes or refuses the
damage: squarely, grazing, slow enough to drop.

Part of #277

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
Ludovic MarieandClaude Opus 5.5 72ff742273 Shield blocks: the player hears the block sound, not the hurt sound
LivingEntity.hurtServer's playHurtSound goes through Player.playSound,
which leaves the player itself out; the player hears its hurt sound from
the client's handleDamageEvent, and a blocked hit sends BlocksAttacks'
block sound (onBlocked) instead of the damage event. Queue the local hurt
sound on the damage broadcast rather than on the server's hurt sound.

Part of #277

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:41:06 +02:00
rdbtCVS fac4944cfa Document transient chunk wrapper retention 2026-10-15 10:40:54 +01:00
Ludovic Marie 050b203846 Merge pull request #306 from ludovic111/feat/debug-lightmap
F3+4 shows the lightmap texture in the bottom right corner
2026-10-15 11:39:03 +02:00
Ludovic Marie b6ced87869 Merge pull request #305 from ludovic111/feat/debug-charts
Debug charts: F3+2 frame and tick times, F3+3 ping and bandwidth
2026-10-15 11:38:57 +02:00
Ludovic Marie 89465a8c6d Merge pull request #304 from ludovic111/feat/create-world-tabs
Create New World: vanilla's Game, World and More tabs
2026-10-15 11:37:55 +02:00
Ludovic Marie fd8223061c Merge pull request #303 from ludovic111/feat/list-scrollbars
World list and pack lists: vanilla scroller, dragged with the pointer
2026-10-15 11:37:49 +02:00
rdbtCVS 0c161a0336 Cache random-tick section eligibility for unchanged chunks 2026-10-15 10:37:46 +01:00
Ludovic Marie 889fff562d Merge pull request #301 from ludovic111/feat/multiplayer-lan-scanning
Multiplayer: vanilla's server list, with the LAN scanning text and its animated dots
2026-10-15 11:37:43 +02:00
Ludovic Marie fbb458252f Merge pull request #299 from ludovic111/feat/menu-blur
Blur the panorama behind world selection, multiplayer and world creation, as vanilla does
2026-10-15 11:37:36 +02:00
Ludovic Marie b92cc7c9e8 Merge pull request #295 from ludovic111/feat/recipe-toast-icons
Recipe toasts show the workstation icon and cycle through the unlocked recipes
2026-10-15 11:37:23 +02:00
Ludovic MarieandClaude Opus 5.5 61a127a2a2 Fishing: the rod casts, bites splash and bubble, and the line is vanilla's
- The fishing rod switches to its cast model while its hook is out
  (FishingRodCast), in first person, third person and the hotbar.
- FishingHook.catchingFish's particles: splashes where fish tease the bait,
  the fishing-particle trail and bubbles of a fish swimming up, and the
  bubble and fishing burst when it bites.
- FishingHookRenderer: the line is 16 black `lines` segments of the window's
  line width (2.5 px at 1080p) from a quarter block above the hook to the
  rod hand (on the near plane in first person, beside the body in third),
  and the bobber is centred on the hook.

Fixes #265

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:35:31 +02:00
Ludovic MarieandClaude Opus 5.5 adb19abd4c Minecarts roll, rattle when ridden, and take hits with the attack sounds
MinecartSoundInstance follows every minecart in the level (entity.minecart.riding,
louder with speed, silent when the tick rate is frozen), and getting in one starts
RidingMinecartSoundInstance's two inside sounds (normal and underwater). Hitting a
minecart or a boat plays Player.attack's strong/weak (and knockback) sounds.

The sound_engine harness now replays both instances against the vanilla client.

Fixes #227

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 11:29:15 +02:00
Ludovic Marie 4abf648a85 Merge pull request #280 from ludovic111/fix/streaming-reload-loop
Stop reloading saved columns every frame after the player moves
2026-10-15 11:27:01 +02:00
Ludovic Marie 5d17c56d77 Merge pull request #269 from ludovic111/perf/worker-face-visibility
Load worlds ~4.7x faster: stop copying the face visibility table per mesh job
2026-10-15 11:26:54 +02:00
Ludovic Marie 9044c69429 Merge pull request #307 from ludovic111/feat/bee-hives
Bees live in hives and nests: they go home, come out in the morning and make honey (#208)
2026-10-15 11:18:21 +02:00
Ludovic Marie 68ec2236e2 Merge pull request #293 from ludovic111/fix/drowned-parity
mob_ai oracle: wait for the light engine before ticking (drowned flake, #290)
2026-10-15 11:13:48 +02:00
Ludovic MarieandClaude Opus 5.5 5876014b57 Droppers and hoppers don't take a beehive for a container
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:36:37 +02:00
Ludovic MarieandClaude Opus 5.5 1f4154c0df F3+4 shows the lightmap texture in the bottom right corner
DebugScreenOverlay.toggleLightmapTexture and showLightmapTexture: the 16x16
lightmap at 64x64 on a black border, 2 pixels from the corner, sky light up
and block light right, as vanilla's flipped blit. It shares the bottom of the
screen with the F3+2 and F3+3 charts, so each switch turns the others off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:31:14 +02:00
Ludovic MarieandClaude Opus 5.5 dc863a66a4 Create New World: vanilla's Game, World and More tabs
CreateWorldScreen as in 26.3: a MenuTabBar (tab sprites, header
separators, underline) over the transparent tab header, each tab's
GridLayout aligned at (0.5, 1/6) between it and a 33-pixel footer with
Create New World and Cancel.

Game: the world name box with its save-folder tooltip, Game Mode
(Survival, Hardcore, Creative), Difficulty and Allow Commands, locked
in Hardcore. World: World Type (Default, Superflat), Customize, the seed
box with its hint, and the Generate Structures / Bonus Chest switches.
More: Game Rules, Experiments and Data Packs. Tooltips follow vanilla's.

The choices reach the new world: hardcore, difficulty and allowCommands
in level.dat, and world_gen_settings with the flat preset, structures and
bonus chest written before streaming starts. Re-Create reads them back.

Fixes #281

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:24:52 +02:00
Ludovic MarieandClaude Opus 5.5 6ae018055d Debug charts: F3+2 frame and tick times, F3+3 ping and bandwidth
DebugScreenOverlay's FpsDebugChart, TpsDebugChart, PingDebugChart and
BandwidthDebugChart with vanilla's scales, colours and places: 240 samples
(LocalSampleLogger), F3+2 and F3+3 toggles that show the overlay and exclude
each other, the overlay's "Debug charts" lines, and the play-state ping
request and pong response packets the ping chart uses on servers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:16:02 +02:00
Ludovic Marie df0acb2810 Merge pull request #302 from ludovic111/ci/parity-timeout
CI parity: allow 120 minutes so core changes finish instead of timing out
2026-10-15 10:05:43 +02:00
Ludovic MarieandClaude Opus 5.5 a8bd497132 World list and pack lists: vanilla scroller, dragged with the pointer
SelectWorldScreen's list now sits at vanilla's 49..h-60 with the list
background, header and footer separators, white selection outline and
the widget/scroller sprites at the row's right + 8. The scroller follows
the pointer while held (AbstractScrollArea.mouseDragged), the wheel moves
half a row, and the pack screen's lists can be dragged the same way.

Part of #282

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 10:04:37 +02:00
Ludovic MarieandClaude Opus 5.5 f2e87721f4 CI parity: allow 120 minutes, so changes to core crates finish instead of timing out
Changes to horizon-core or horizon-world make ~280-300 harnesses rerun, which takes
60-90 min on the Mac mini runners; at 45 min those runs were cancelled and proved nothing
(#269, #280, #292 today).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:59:10 +02:00
Ludovic Marie 3ed8abd25e Merge pull request #300 from ludovic111/fix/autosave-spike
Autosave stops waiting for entity writes: no more 2 s freeze (#224)
2026-10-15 09:58:50 +02:00
Ludovic Marie c0e1095ea9 Merge pull request #294 from ludovic111/fix/arrow-interpolation
Arrows stop jumping along their path after they land (#278)
2026-10-15 09:58:46 +02:00
Ludovic Marie 9593d9e329 Merge pull request #292 from ludovic111/fix/golem-persistence
Copper golems keep the persistence vanilla saved (#289)
2026-10-15 09:58:42 +02:00
Ludovic MarieandClaude Opus 5.5 587695e108 Multiplayer: vanilla's server list, with the LAN scanning header and its dots (#282)
JoinMultiplayerScreen's layout: the title centred in the 33-pixel header, the
ServerSelectionList between the separators (list background, 305-wide rows,
white selection outline, scissored rows, the scroller sprites), the footer
buttons centred in 60 pixels. The list always ends with the LAN header,
"Scanning for games on your local network" over LoadingDotsText's dots,
then the LAN games. Saved servers show two MOTD lines, the player count and
the ping, pinging or unreachable icon; Edit and Delete need a saved server.
The list scrolls with the wheel and a draggable scrollbar (ui::Scroll, after
AbstractScrollArea).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:48:14 +02:00
Ludovic MarieandClaude Opus 5.5 b18261cc79 Autosave doesn't wait for the entity writes
The autosave queued its chunks without waiting (#284), then saved the
entities with the waiting call. The region writer is one FIFO thread,
so the main thread still waited for every chunk queued before the
entities: a 1.9 s frame at the 5-minute autosave on a world with ~900
columns. Like PersistentEntitySectionManager.autoSave (saveAll's flush
only on save-all and quit), the autosave now queues the entities too.

Part of #224

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:39:04 +02:00
Ludovic MarieandClaude Opus 5.5 6953710c74 Blur the panorama behind the title menus' screens, as vanilla does (#282)
Screen.extractBlurredBackground: every classic screen reached from the title
(world selection and creation, multiplayer, options) blurs the panorama with
vanilla's minecraft:blur post chain, three horizontal and vertical box blur
passes of menuBackgroundBlurriness pixels, then draws menu_background over it
instead of a flat dark fill. The title screen itself stays sharp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:32:16 +02:00
Ludovic Marie 02cfbf6f28 Merge pull request #298 from ludovic111/fix/firework-entity-light
Draw flying firework rockets as their lit item model like vanilla
2026-10-15 09:31:33 +02:00
Ludovic Marie eb1467f45f Merge pull request #297 from ludovic111/fix/leaves-option-remesh
Rebuild every section when See-Through Leaves, Smooth Lighting or Biome Blend changes
2026-10-15 09:30:39 +02:00
Ludovic Marie bab7cf92a7 Merge pull request #296 from ludovic111/fix/reversed-z-depth
Render the world with reversed depth like vanilla (far fire no longer flickers)
2026-10-15 09:30:02 +02:00
Ludovic MarieandClaude Opus 5.5 cf67db1873 Render firework rockets as their lit item model like vanilla
FireworkEntityRenderer submits the rocket's item in the GROUND context,
turned to the camera (and over on its side when shot at an angle), at the
packed light of its eye. Horizon drew a flat 0.5-block sprite at full
brightness, so rockets glowed at night and had no item shading.

Fixes #279

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:24:19 +02:00
Ludovic MarieandClaude Opus 5.5 81b07d1dd9 Rebuild every section when a meshing option changes like vanilla
Options.cutoutLeaves, ambientOcclusion and biomeBlendRadius call
LevelExtractor.allChanged when they change. Horizon only handed the new
value to the mesher, so after turning See-Through Leaves off the loaded
sections kept their see-through leaves, pale against the sky, beside the
opaque ones of sections rebuilt later.

Fixes #273

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:24:19 +02:00
Ludovic MarieandClaude Opus 5.5 1bfef88477 Render the world with reversed depth like vanilla
Vanilla 26.3's Projection passes its planes reversed and every pipeline
tests GREATER_THAN_OR_EQUAL. Horizon kept forward depth with a 0.05 near
plane, so far geometry lost precision and fire_side's quad, 0.01 pixel in
front of the block it burns on, z-fought with it.

Fixes #272

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:24:19 +02:00
Ludovic MarieandClaude Opus 5.5 111b237b6d Bees live in hives and nests: they go home, come out in the morning and make honey (#208)
BeehiveBlockEntity (crate::beehive): up to three bees stored as entity
tags, out when their time is up (not at night, in the rain or through a
blocked front unless in an emergency), honey for nectar, the flower they
knew; vanilla's bees / flower_pos save format. Bees find a hive with room
(BeeLocateHiveGoal), fly home and go in (BeeEnterHiveGoal through
MobEvent::EnterHive). In the game hives tick, breaking one lets the bees
out angry unless smoked, fire empties it, dispensers release them.

mob_ai: bee_hive_night, _rain, _morning and _honey match vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:14:21 +02:00
Ludovic MarieandClaude Opus 5.5 5530d682ab Arrows interpolate from their last tick, not from where they were shot
arrows::tick never set the projectile's previous position (Entity.setOldPosAndRot
at the start of every tick); Projectile::tick does, but arrows don't go
through it. The renderer lerped from the spawn point to the current
position every frame, so a flying or stuck arrow jumped along its whole
path. The arrow harness now prints the old position too.

Fixes #278

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 09:03:39 +02:00
Ludovic MarieandClaude Opus 5.5 f88bfc66f8 Recipe toasts show the crafting station and cycle through every unlocked recipe (#283)
RecipeToast keeps the station and result of each display unlocked while it
is up, draws the station at 0.6 scale in the plaque's corner over the result,
takes turns through them over the display time and stays up five more seconds
after each unlock. Toasts slide in and out on ToastInstance's squared curve,
honour notificationDisplayTime and play ui.toast.in / ui.toast.out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:55:36 +02:00
Ludovic MarieandClaude Opus 5.5 fa188fca42 mob_ai oracle: wait for the scene's light updates before ticking (#290)
The oracle started a scenario once hasLightWork() had been false for six
ticks, but light updates reach the light thread through the chunk task
dispatcher, where hasLightWork() can't see them. A drowned's first
sun-burn roll (Mob.isSunBurnTick draws only when the brightness at its
eyes is above 0.5) then read the dirt's light under a freshly filled
pool or the water's, depending on thread timing: one random draw more or
less, and drowned_pool / drowned_pool_swimmer failed at random.

Queue ThreadedLevelLightEngine.waitForPendingTasks on every forced chunk
after building the scene and tick only once they have all completed.

Fixes #290

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:34:05 +02:00
Ludovic Marie b5a2afb71e Merge pull request #291 from ludovic111/feat/bee-flight
Bees fly, pollinate flowers, get angry and sting
2026-10-15 08:19:07 +02:00
Ludovic Marie 77cad63374 Merge pull request #287 from ludovic111/fix/bat-flight
Bats hang upside down and fly, with their wing animations
2026-10-15 08:12:08 +02:00
Ludovic Marie 271a26515a Merge pull request #285 from ludovic111/fix/strider-lava
Striders stand on lava instead of bobbing in it
2026-10-15 08:08:58 +02:00
Ludovic MarieandClaude Opus 5.5 7c2a31206c Bees fly, pollinate flowers, get angry and sting (#208)
A port of vanilla 26.3 Bee: FlyingMoveControl and flying navigation,
hover wandering, flower search and pollination (nectar), growing crops,
tempting and breeding, persistent anger with the hurt-by alert, the sting
(poison, stinger lost, later death), drowning, staying home at night and
in rain, roll, textures and NBT. Hives as homes are not simulated yet.

FlyingMoveControl clamps the pitch like Entity.setXRot, so a rotlerp
that wraps to 359 gives 90 as in vanilla.

Mob AI parity: 9 bee scenarios, 3609/3609 ticks; full harness 120/120.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:57:51 +02:00
Ludovic MarieandClaude Opus 5.5 2bcb30b375 Bats hang upside down and fly, with their wing animations (#215)
Bats fell into the generic fallback AI and walked like land animals; their
animation states were never started. Port vanilla Bat: hanging from a solid
block until a player comes within 4 blocks or the block goes, the take-off
level event, customServerAiStep's flight towards random nearby air, Bat.tick
(held to the ceiling while resting, vertical speed scaled by 0.6 in flight),
setupAnimationStates (flying and resting keyframes), waking when hurt,
BatFlags in the save, no fall damage, movement events without sounds, the
lower voice pitch, and no omnidirectional air drag (vanilla gives that only
to bees, parrots and sulfur cubes).

Mob AI parity harness: six bat scenarios, 6/6 match (1906/1906 mob-ticks).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:57:51 +02:00
Ludovic MarieandClaude Opus 5.5 8cd97334bf Striders stand on lava instead of bobbing in it (#256)
Striders fell into the generic fallback AI, whose FloatGoal jumped them
up and down in lava. Port vanilla Strider: its goals (no FloatGoal,
StriderGoToLavaGoal, RandomStrollGoal every 60 ticks, LookAtPlayerGoal for
striders), floatStrider, standing on lava sources through the living
collision context and the half-block liquid shape, travelling in air where
lava holds it up, StriderPathNavigation's lava destinations, the walk target
value, going cold (suffocating speed modifier, cold texture) away from warm
blocks, the happy/retreat sounds and a lava-proof checkFallDamage.

Mobs now keep Entity.mainSupportingBlockPos from their last move, as
getBlockStateOnLegacy reads it.

Mob AI parity harness: five strider scenarios, 5/5 match (1105/1105
mob-ticks; 0/5 and 5/1105 before).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:57:51 +02:00
Ludovic MarieandClaude Opus 5.5 fb15822b3b Load PersistenceRequired as vanilla does: copper golems save 0 again
Mob.readAdditionalSaveData assigns persistenceRequired from the tag,
overriding the constructor's setPersistenceRequired (copper golems,
elder guardians). Horizon ORed it in, so a copper golem vanilla saved
with 0 came back with 1. The monument's elder guardian, which Horizon
builds from NBT where vanilla calls the constructor, now carries the
flag in its spawn NBT.

Fixes #289

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:41:31 +02:00
Ludovic Marie e626a727a1 Merge pull request #267 from ludovic111/feat/perf-mesh-once
Mesh each column once its neighbours' light has settled
2026-10-15 07:35:44 +02:00
Ludovic Marie 6dde5ec2d0 Merge pull request #288 from ludovic111/fix/save-roundtrip-macos
Run the save round-trip harness on macOS without vscreen
2026-10-15 07:26:54 +02:00
Ludovic Marie 282acfe086 Merge pull request #254 from ludovic111/feat/perf-battery-mode
Battery mode: an opt-in 60 FPS cap (30 in menus or unfocused) and half the worker threads
2026-10-15 07:23:31 +02:00
Ludovic Marie 0f569a4918 Merge pull request #261 from ludovic111/feat/launcher-perf
Launcher measurements: a session performance log and horizon-launcher benchmark
2026-10-15 07:20:59 +02:00
Ludovic Marie a6860bbec6 Merge pull request #263 from ludovic111/feat/benchmark-mode
horizon-client --benchmark SCENARIO: benchmark your own machine, report in the shared format
2026-10-15 07:20:54 +02:00
Ludovic Marie 18234d3a01 Merge pull request #264 from ludovic111/feat/perf-bench-report-v1
perf_bench.py: repeats with 95% CIs, the shared report format, loads end when meshing is done
2026-10-15 07:20:42 +02:00
Ludovic Marie 7b6ef36d90 Merge pull request #247 from ludovic111/feat/bench-report
Benchmark report format v1 (docs/benchmark-report.md, crates/horizon-bench)
2026-10-15 07:20:04 +02:00
Ludovic MarieandClaude Opus 5.5 0c685d9db4 Stop reloading saved columns every frame after the player moves
Generation asked for columns within the view distance + 3 or two of a
finished chunk, but a column that came back finished (loaded from the
save) was kept only within + 2 or one of a finished chunk. After a
teleport, saved columns in between were loaded, dropped, asked for and
loaded again on every frame, forever: 8 of them at render distance 16
held 8 of the 39 in-flight generation slots and kept the disk and a
worker busy. The requested, kept and unloaded ranges now live in Ranges
and nest; requested_columns_stay_loaded checks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:14:31 +02:00
Ludovic Marie 375a685b84 Merge pull request #230 from rdbtCVS/perf/generation-scan-invalidation-20261014
perf(streaming): skip unchanged generation scans
2026-10-15 07:11:36 +02:00
Ludovic MarieandClaude Opus 5.5 e64c80243d Run the save round-trip harness on macOS without vscreen
On macOS the Horizon client renders offscreen (--offscreen) with audio off,
from the logged-in session; Linux keeps vscreen and the null ALSA device.
The vanilla side was already a headless dedicated server. --bin-dir is
resolved, since the client runs from the run folder. parity.py no longer
skips save_roundtrip off Linux; it stays local-only (no rendering over SSH).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:04:56 +02:00
Ludovic MarieandClaude Opus 5.5 78f206bc22 Mesh only when no neighbour is being lit; skip columns that can't be lit yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:04:25 +02:00
Ludovic MarieandClaude Opus 5.5 4f3326e983 Mesh a column once its neighbours' light has settled
A new chunk relights its eight neighbours, and every finished light job
dirties all sections of the 3x3 columns around it. Meshing waited only for
the column itself to be lit, so sections near the loading frontier were
meshed with half-settled light and again after each neighbour's relight:
about 2.25 meshes per section while a world loads. Wait until no column of the
3x3 is being lit or queued for light. The final meshes are the same; they are
built once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:04:25 +02:00
rdbtCVS 69925a57c5 perf(streaming): skip unchanged exhausted generation scans 2026-10-15 07:04:23 +02:00
Ludovic Marie cfa6033535 Merge pull request #220 from rdbtCVS/perf/reuse-pack-overlays-20261014
perf: reuse unchanged resource-pack overlays after validation
2026-10-15 07:03:05 +02:00
Ludovic Marie ff29d50f73 Merge pull request #219 from rdbtCVS/fix/classic-title-menu-parity
Fix classic title menu layout and accessibility shortcut
2026-10-15 07:02:08 +02:00
Ludovic MarieandClaude Opus 5.5 146c0fd3ce benchmark: a load ends when every streaming stage is idle
Same rule as perf_bench: with status.streaming, the last sections are drawn
once no stage has held work for half a second, so the remeshing that
follows a load (fluids settling, random ticks) isn't counted as loading.
Without it, meshing still has to go quiet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 014b1f5267 Benchmark reports go to the telemetry outbox, with the benchmark-upload consent only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 0f61ed6b63 benchmark: Apple GPUs by name (Metal has no vendor ID)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 32695d4680 benchmark: a load ends when meshing goes quiet too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 309dea385b horizon-client --benchmark SCENARIO: the perf_bench run from inside the game, written as a report
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:18 +02:00
Ludovic MarieandClaude Opus 5.5 49b6367d2e perf_bench: a load ends when every streaming stage is idle
Builds that report status.streaming are loaded once chunk counts are
stable and no stage (generation, decoration, light, meshing) has held work
for half a second. Meshing going quiet also waited through the remeshing
that follows a load (fluids settling, random ticks: 1500 falling to 60
meshes/s for ~150 s at RD16), which is simulation, not loading. Older
builds keep the meshing-rate rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 7d36d09edd perf_bench.py: a load ends when meshing goes quiet too
Sections wait for their neighbours' light before they are meshed, so the
chunk counts settled while edge sections were still undrawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 2a91612928 perf_bench.py: repeats, and the benchmark report format v1
Each game runs --repeats times (3) per render distance and writes one v1
report per distance (benchmarks/perf/<label>-rd<N>.json): figures as means
with standard deviation and 95% CI, per-flight frame entries with the fixed
histogram, the hardware without the host name. frames() and summary() match
crates/horizon-bench value for value (tested on the same inputs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:03 +02:00
Ludovic MarieandClaude Opus 5.5 bb1c292a40 Launcher benchmark reports go to the telemetry outbox with the benchmark-upload consent; tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:02 +02:00
Ludovic MarieandClaude Opus 5.5 c78f2d3e27 Launcher measurements: a session performance log and horizon-launcher benchmark
Each session rewrites benchmarks/launcher-<time>-session.json in the data
folder (report format v1): start to first frame, release list fetch,
install/update and prepare durations, Play to the game's first frame (the
game now logs its first frame), RSS, UI frame times, binary size.
horizon-launcher benchmark starts the launcher N times (cold, then warm),
switches pages, measures idle CPU and memory and the release list fetch,
and writes one report with means and 95% CIs. Nothing is uploaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:01:02 +02:00
Ludovic MarieandClaude Opus 5.5 650ad39d85 Benchmark report uploads are dated to the day, like every telemetry item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:00:54 +02:00
Ludovic MarieandClaude Opus 5.5 a500e25705 horizon-bench: load average, power source, RSS and CPU time probes; launcher metric names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:00:54 +02:00
Ludovic MarieandClaude Opus 5.5 e0ab3f8fcc Benchmark report format v1: docs/benchmark-report.md and crates/horizon-bench
One versioned JSON report for every benchmark Horizon writes (the game's
--benchmark, the launcher's measurements, tools/perf_bench.py): the local log
a player can attach and, with consent, what the telemetry upload sends.
Summaries over repeats (mean, stdev, 95% CI) and fixed-edge frame-time
histograms, never raw frame streams; no host, user or path in uploads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 07:00:54 +02:00
Ludovic Marie c44d311d46 Merge pull request #239 from rdbtCVS/perf/chunk-membership-sync-20261014
perf(client): skip tick chunk synchronization when membership is unchanged
2026-10-15 06:58:40 +02:00
Ludovic Marie c8ce1e081d Merge pull request #184 from rdbtCVS/t3code/fix-effect-icon-rendering
Fix status effect icons and display effects in inventories
2026-10-15 06:58:33 +02:00
Ludovic Marie 9b0d8fd7d7 Merge pull request #204 from rdbtCVS/perf/entity-model-document-20261014
perf(client): share parsed entity models across startup batches
2026-10-15 06:58:11 +02:00
Ludovic Marie 639d315859 Merge pull request #210 from rdbtCVS/perf/single-state-model-parse-20261014
perf(client): reuse parsed model states during preparation
2026-10-15 06:56:45 +02:00
Ludovic Marie 407e863d9f Merge pull request #233 from rdbtCVS/perf/lighting-worker-results-20261014
perf: return only lighting from chunk light workers
2026-10-15 06:56:10 +02:00
Ludovic Marie 5071cd785a Merge pull request #237 from rdbtCVS/perf/entity-material-buckets-20261014
perf: reuse renderer entity material batching buffers
2026-10-15 06:55:45 +02:00
Ludovic Marie 3df34df87b Merge pull request #284 from ludovic111/fix/bastion-chunks
Save every generated chunk and decorated proto chunks, as vanilla does (#249)
2026-10-15 06:48:44 +02:00
Ludovic Marie 8965ca5e2b Merge pull request #274 from ludovic111/fix/mesh-out-of-range
Mesh columns loaded past the render distance once they come in range (#228)
2026-10-15 06:48:24 +02:00
Ludovic Marie 30144e478d Merge pull request #206 from ludovic111/feat/perf-bench
Benchmark Horizon against vanilla 26.3 and vanilla + performance mods (tools/perf_bench.py, docs/benchmark.md)
2026-10-15 06:38:45 +02:00
Ludovic MarieandClaude Opus 5.5 1c3fd6e23c Save every generated chunk and the proto chunks decoration wrote to (#249)
Horizon saved a chunk only once a player changed it and never saved proto
chunks, so leaving an area dropped decorated chunks while the final chunks
next to them stayed loaded (or were regenerated later). Coming back, those
chunks were generated again and their decoration was skipped beside a final
neighbour: whole chunks of a bastion lost their pieces, other chunks their
features. Like vanilla, generated chunks are now saved, proto chunks that
decoration wrote to are saved with their status (terrain or features) and
restored onto the chunk generated again, columns that finish after the player
left are saved, and saving waits for the decorations in flight.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:37:05 +02:00
Ludovic MarieandClaude Opus 5.5 4901bdf231 Mesh sections of columns that load past the render distance once in range (#228)
The mesh loop dropped every dirty section of a loaded column outside the
render distance. Columns load up to three chunks past it, and a column
loaded from a save with its light runs no light job, so nothing asked for
its sections again: once the player came closer it stayed invisible until
a block in it changed. Those sections now wait and are put back when the
centre, radius or forced chunks change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 06:12:18 +02:00
Ludovic Marie 087cbacdc4 Merge pull request #268 from Online5277/fix/entity-use-occlusion
Prevent block interactions through targeted entities
2026-10-15 05:38:42 +02:00
Ludovic Marie 416012ad4c Merge pull request #241 from ludovic111/feat/controllers2-focus
Controllers: focus navigation in menus, on-screen keyboard, menu navigation option
2026-10-15 05:25:30 +02:00
Ludovic Marie 5c2201a781 Merge pull request #200 from ludovic111/feat/friends-join
Play with friends (3/3): Join a friend with the code alone, in game and in the launcher; end-to-end harness
2026-10-15 05:07:05 +02:00
Ludovic Marie 44a78fe65e Merge pull request #199 from ludovic111/feat/friends-host
Play with friends (2/3): Invite friends in the pause menu (no server needed)
2026-10-15 05:06:36 +02:00
Ludovic MarieandClaude Opus 5.5 213af61b92 status: report each streaming stage's work
`status` gains `streaming` (queued jobs, chunks generating, protos waiting for
decoration, decorating, finishing, lighting, meshing) and `dirty_light`, so a
slow load shows which stage it waits on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:55:14 +02:00
Ludovic Marie 12b5e39ac8 Merge pull request #192 from ludovic111/feat/friends-relay
Play with friends (1/3): invite codes that need no server, port mapping, STUN, a relay anyone can run
2026-10-15 04:49:42 +02:00
Ludovic Marie 7a184a41f0 Merge pull request #259 from Online5277/fix/options-list-clipping
Clip options rows smoothly at list boundaries
2026-10-15 04:39:25 +02:00
Ludovic Marie ef569d3201 Merge pull request #262 from Online5277/fix/spectator-movement
Prevent climbable blocks from slowing spectator flight
2026-10-15 04:39:22 +02:00
Ludovic Marie 20ecaf0254 Merge pull request #266 from Online5277/fix/gamemode-switcher
Add the F3+F4 game mode switcher
2026-10-15 04:39:17 +02:00
Ludovic Marie 503b91df33 Merge pull request #260 from Online5277/fix/cloud-settings
Honor Off, Fast and Fancy cloud settings
2026-10-15 04:38:40 +02:00
Ludovic MarieandClaude Opus 5.5 75a30185a5 Keep blank splash lines like SplashManager.prepare
Vanilla trims each line and drops only the hidden splash (hash 125780783);
a blank line stays a (blank) splash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:37:23 +02:00
rdbtCVS bd4101e9d1 Update merged world-menu test for title resource loading 2026-10-15 04:37:23 +02:00
rdbtCVS a0f3315a79 Address title menu review: language, icon opacity, and splash behavior 2026-10-15 04:37:23 +02:00
rdbtCVS cccb5bff96 Match Minecraft integer centering for title menu icons 2026-10-15 04:37:08 +02:00
rdbtCVS 6cdcf6c85a Fix classic title menu layout and accessibility shortcut 2026-10-15 04:37:08 +02:00
Cosmic 22e548471c Keep entity item use from falling through to blocks 2026-10-14 22:22:01 -04:00
Cosmic 25858f63ae Add the F3+F4 game mode switcher 2026-10-14 22:09:12 -04:00
Ludovic MarieandClaude Opus 5.5 50a1649dea Pack small palettes without hashing
PalettedContainer::pack hashed all 4096 values with SipHash to build the
palette. It now shares from_values' scan, which only switches to a map
past 16 distinct values. Same palette order, same output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:06:42 +02:00
Ludovic MarieandClaude Opus 5.5 ed8f3d70b8 Keep each worker's face visibility table across mesh jobs
Every mesh job cloned the whole FaceVisibility (every state, every face
VoxelShape and the occlusion cache) and dropped it afterwards: about half
of the workers' busy time while a world loads. Each worker now keeps its
copy, like vanilla's per-thread occlusion cache, and clones a new one only
when the mesh tables change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 04:06:42 +02:00
Cosmic 4c7d77352e Skip climbable movement limits for spectators 2026-10-14 21:55:34 -04:00
Cosmic 21411176a7 Honor Off and Fast cloud settings 2026-10-14 21:47:18 -04:00
Cosmic ede136bdd2 Clip partially visible options list rows 2026-10-14 21:42:07 -04:00
Ludovic MarieandClaude Opus 5.5 e6be303517 docs/benchmark.md: no unmeasured startup figure
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:40:12 +02:00
Ludovic MarieandClaude Opus 5.5 b691b5ebaa Benchmark Horizon against vanilla 26.3 and vanilla with performance mods
tools/perf_bench.py runs Horizon, the vanilla 26.3 client and vanilla with
Sodium, Lithium, FerriteCore and ImmediatelyFast through the same steps:
fresh world (seed 1234, creative) per render distance, start to title, start
to in-world, then per scene teleport, chunk load, settle and a fixed 192-block
flight. Every frame time is kept and one function computes average FPS, 1% and
0.1% lows, p50/p95/p99/p99.9 and a frame-time histogram for every game; RSS is
sampled from outside the process for every game. One options.txt (vanilla's
keys, Fancy preset values with a Custom preset so the render distance holds)
is written for all of them.

Vanilla is driven by a dev-only Fabric mod (mods/horizon/src/bench) compiled
against the user's own client jar: it creates the world with
WorldOpenFlows.createFreshLevel, teleports with /tp and places the player on
the path every frame as horizon-ctl fly does. Nothing of the game is
redistributed and the performance mods are fetched from Modrinth with the
hashes pinned in packs/mods.lock.json.

Horizon side: `fly ... PITCH raw` also replies with every frame time, and
--offscreen drives frames from the event loop, so a window nobody sees (a
locked macOS session) still renders.

docs/benchmark.md describes what is measured, how fairness is kept, the
caveats and how to reproduce it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:40:12 +02:00
Ludovic Marie ff5df6aa5e Merge pull request #257 from Online5277/fix/keybind-scrollbar
Make options list scrollbars draggable
2026-10-15 03:38:29 +02:00
Cosmic 7cfede86f4 Handle dragging in options list scrollbars 2026-10-14 21:34:04 -04:00
Ludovic MarieandClaude Opus 5.5 bf74ac6f30 Controllers: the Steam keyboard choice falls back to Horizon's without xdg-open
Picked by hand on macOS or Windows, A on a text field opened nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:27 +02:00
Ludovic MarieandClaude Opus 5.5 635622652e Controllers: options sub-pages keep the classic focus highlight under the Horizon style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 210a6f5ddd Controllers: focus follows scrolled lists, clipped targets, pad status shows focus
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 9927d06049 Controllers: focus navigation in menus, on-screen keyboard, menu navigation option
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:10:15 +02:00
Ludovic MarieandClaude Opus 5.5 c9b3a06cf7 Launcher lockfile: horizon-friends' if-addrs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 7a475a948c Invite friends works with no relay: the code, and what friends can expect
Sharing no longer needs a relay. The panel shows the full invite code in
groups (monospace, wrapped) with Copy and Stop sharing, then who can join:
friends anywhere when the router opened the port or this computer has a
public address; otherwise what to do (UPnP, the port to forward and the
LAN address, a relay, a VPN; CGNAT called out) with a link to the
troubleshooting docs. A relay set in horizon.txt (friendsRelay) or
HORIZON_RELAY_URL adds its short code. horizon.txt keeps other lines when
the menu style is saved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 fe7df463fa Play with friends: bound handshakes and the relay's first line in time, cap relay connections
A read timeout restarts with every byte, so a peer trickling one byte at
a time could hold the host's 8 handshake slots (or relay threads) for
hours. The host's openings and the relay's first line now end after 10 s
in all; the relay closes connections past RELAY_MAX_CONNECTIONS (4096).
The docs say what happens to a router mapping when the game crashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 9e6d248fbe test_friends: --xvfb and --offscreen for headless Linux machines
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 872e77e19b Invite friends: who is here and notices read in the muted colour, the hint stays faint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 a3913be641 Join a friend with the full code (no server) or a relay's short code
The game joins `friend:CODE` with either code: a full invite is dialled
directly, then through the relay it names; a short code goes through the
relay the player set. Join a friend gains a Relay (optional) field, saved
to horizon.txt (friendsRelay) and used for hosting too; the code field
shows the end of long codes and says what is wrong with one (typo, other
version, short code without a relay). The launcher validates both kinds.

tools/test_friends.py now runs (a) the code alone with no relay anywhere
and (b) a relay run locally with the short code; routers and STUN stay
out of tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 241dee2649 Invite friends: count direct and relayed sessions in friends status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 341aa98a96 test_friends: --hold for the guest role, report chunks once they arrive
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 21a70c8e97 LAN: a guest's entity id no longer takes the host's
Hub::admit read the entity counter before incrementing it, while the game
increments first (the current value is the last id in use). The first
guest after Open to LAN got the host's own entity id, so its client took
the host's AddEntity for itself and never showed the host.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 c7f30d2105 Relay: RELAY_BIND listens on one address (tailnet-only test relays)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 7784350bad Play with friends: Invite friends in the pause menu
The Horizon pause menu gains "Invite friends" beside Open to LAN. It opens
the world to LAN if it isn't (Open to LAN's defaults) and shares that port
behind an invite code through the relay; the menu shows the code with Copy
and Stop sharing, who is here and why the code changed. Closing the LAN
server ends the code; stopping sharing closes LAN again if sharing opened
it. `horizon-ctl friends host|stop|status` drives it in harnesses.

Without HORIZON_RELAY_URL (the default until a relay is deployed) the button
is disabled and says the feature isn't available yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic MarieandClaude Opus 5.5 5d29f31663 Play with friends: Join a friend in game and in the launcher
The game joins `friend:CODE` addresses: the network thread resolves the code
through the relay, agrees on the key, settles on a direct or relayed stream
and connects the client to the tunnel's loopback port; everything after is
an ordinary join. Errors (unknown or ended code, wrong secret, host not
answering, version mismatch, rate limits) end on the Disconnected screen in
plain words. Shown and logged addresses hide the code's secret half.

Horizon's multiplayer page gains "Join a friend" (a code field); the classic
screens keep vanilla's buttons. The launcher's Play page gains a "Join a
friend" row (paste works) that starts the game straight into the join;
`horizon-launcher play --join CODE` does the same.

tools/test_friends.py runs a relay, a host Horizon sharing a world and a
guest Horizon, over plain LAN (--baseline), the direct path and the forced
relay path: chat, movement and blocks both ways, then the ended invite.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:08:07 +02:00
Ludovic Marie 6ddabae4dd Merge pull request #238 from ludovic111/fix/player-water-physics
Player water on #59: bubble columns, swim sounds, drown bubbles, vanilla fixes
2026-10-15 03:07:20 +02:00
Ludovic MarieandClaude Opus 5.5 dbc85822c1 Battery mode: a one-line description
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:07:02 +02:00
Ludovic Marie 6da701a669 Merge pull request #253 from ludovic111/feat/telemetry-privacy
website: privacy page for opt-in data collection
2026-10-15 03:06:13 +02:00
Ludovic MarieandClaude Opus 5.5 61b0aafbf8 Relay: no hosting platform in its comments
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 5189a735c7 Invite codes: one address per LAN, no unique-local IPv6, the macOS firewall prompt in the docs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 de464a8fc0 Play with friends without a server: endpoints and key in the code
Ludovic can't pay for a hosted relay, so nothing depends on one now. The
host's game finds how it can be reached on its own: its interface
addresses (LAN, VPNs such as Tailscale or ZeroTier, IPv6), a port opened
on the router for an hour at a time (PCP, then NAT-PMP, then UPnP IGD;
renewed while sharing, deleted on stop) and its public address from STUN
(Cloudflare's and Google's public servers by default, configurable). The
invite code carries those endpoints and an 80-bit secret in base32 groups
with a checksum; guests dial every endpoint at once and run the same
SPAKE2 + ChaCha20-Poly1305 handshake, no rendezvous involved. When nothing
can reach the host it says why and what to do (UPnP, the port to forward,
a relay, a VPN; CGNAT and double NAT detected).

The relay stays as something anyone can run (binary, Dockerfile, systemd
unit; Railway config removed): with one configured the host also gets a
short code, and the full code names the relay so guests fall back to it.
No relay by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 74c1a90980 Relay: hand out IPv4 clients' addresses as IPv4
A dual-stack listener reports them as ::ffff:a.b.c.d, which the other end
can't dial from an IPv6-only socket.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic MarieandClaude Opus 5.5 60ddf3daf8 Play with friends: invite codes, the relay and the encrypted tunnel
horizon-friends: codes (Crockford base32, nameplate + secret), the relay's
JSON-line protocol, SPAKE2 then ChaCha20-Poly1305 frames keyed off the
code's secret, direct connections (candidates, TCP simultaneous open from
a reuse-port socket) with the relay as fallback, and the host and guest
ends that carry the game's TCP stream between loopback ports.

horizon-relay: the service (codes, rendezvous, byte relay, rate limits,
bandwidth caps, idle timeouts, counts only), its Dockerfile and Railway
config. Nothing is deployed and nothing in the game uses it yet.

Design: docs/play-with-friends.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 03:05:54 +02:00
Ludovic Marie 186cf8c320 Merge pull request #251 from ludovic111/feat/telemetry-server
Opt-in data collection (4/5): the ingest server (SQLite, rollups, retention, aggregate endpoint)
2026-10-15 03:04:31 +02:00
Ludovic Marie 56861be08c Merge pull request #252 from ludovic111/feat/telemetry-game
Opt-in data collection (3/5): the game keeps crash reports and session summaries only with consent
2026-10-15 03:01:37 +02:00
Ludovic Marie f12578238e Merge pull request #248 from ludovic111/feat/telemetry-consent
Opt-in data collection (1/5): consent, payloads, outbox and a gated uploader
2026-10-15 03:01:26 +02:00
Ludovic MarieandClaude Opus 5.5 b3755bd6b1 website: privacy page for opt-in data collection
/privacy says what the launcher may send when a player allows it (performance
summaries, crash and bug reports, benchmark uploads), why, on what basis,
where it is kept and for how long, and how to delete it. The footer links it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:59:29 +02:00
Ludovic MarieandClaude Opus 5.5 12c1af0261 Opt-in data collection (4/5): the ingest server
crates/horizon-telemetry-server, its own workspace like the launcher's (the
game's workspace never builds a web server):
- POST /v1/batch (zstd, decoded and checked by horizon-telemetry), DELETE
  /v1/installs/<id>, GET /v1/aggregate (cached 10 min, CORS for the website),
  GET /healthz.
- SQLite: machine descriptions stored once, one row of columns per session
  with 80-byte summary blobs, zstd bodies for crash/bug/benchmark, daily totals
  rebuilt when a day changes and kept after raw rows expire (180 days). About
  190 bytes per session on disk.
- Per-address token bucket (addresses in memory only), per-install daily cap,
  groups under 5 install-days hidden from the totals.
- Tests: refusals, rate limit, daily cap, deletion (rows and totals), retention,
  storage size, and end to end with the launcher's uploader.
- Dockerfile and a path-filtered CI workflow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:53:59 +02:00
Ludovic MarieandClaude Opus 5.5 d7b975185c Opt-in data collection (3/5): the game obeys the player's consent
The game never sends anything itself; it keeps items in the outbox for the
launcher, and only for kinds the player allows:
- Crash reports: crash::deliver keeps a scrubbed copy (error, stack, thread,
  Horizon version, graphics adapter and render settings; never the log, world,
  server or resource packs; the world's name and folder and the server address
  are scrubbed wherever they appear). Without consent crash-reports/ stays
  local, as before.
- Performance: a Session records frame times while playing and the integrated
  server's tick times into fixed-size Timings, with the time to the title
  screen and to the world; on leaving a world or quitting, a session of a
  minute or more becomes a summary (under 1 KiB) when the player allows it.
- The GPU, its driver and backend come from the wgpu adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:51:02 +02:00
Ludovic MarieandClaude Opus 5.5 a2b2b3f776 Player water on #59: bubble columns, swim sounds, drown bubbles, vanilla fixes
Builds on Cosmic's #59 (player fluid travel, swimming, air supply) and
changes only what differs from vanilla 26.3 or what it lacks:

- BubbleColumnBlock.entityInside for the player (isPrecise, onAbove /
  onInsideBubbleColumn, not while flying) with its splash/bubble particles.
- LocalPlayer.goDownInWater before LivingEntity.aiStep's velocity cleanup.
- The aiStep jump phase as vanilla: nothing while flying, the fluid jump
  threshold from the eye height (also in travelInLava).
- Player.travel: swim steering samples y + 1.0 - 0.1, and LivingEntity.travel
  checks fluids before gliding; water and lava no longer end a glide.
- LocalPlayer.isUnderWater is wasUnderwater; sprint conditions get shallow
  water, flight and gliding; no flight toggle while swimming; the base tick
  runs before local crouching; the desired pose puts swimming before gliding.
- decreaseAirSupply creates the oxygen_bonus instance (getAttribute).
- Swim sounds and muffled steps in water; entity event 67 drown bubbles.
- Scene motion harness: swim_corpus (player travel in water and lava, bubble
  columns, air supply) and sprint-swim cases for --sprint-input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:45:13 +02:00
Ludovic Marie 89b58991bb Merge pull request #246 from rdbtCVS/perf/save-batch-entity-grouping-20261014
perf: group block entities once per save batch
2026-10-15 02:38:17 +02:00
Ludovic MarieandClaude Opus 5.5 cc8744a06b Trim synthetic overlay benchmarks; record Mac real-pack numbers
The real-pack benchmark on the production resource_root path stays as the
reproducible measurement. On the Mac mini it saves 1.18 s (Faithful 32x) and
5.29 s (FPBR) per startup with the pack enabled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:26:46 +02:00
Ludovic MarieandClaude Opus 5.5 0229615560 Battery mode: an opt-in frame cap and fewer worker threads
A Horizon graphics setting (Performance, off by default, batteryMode in
horizon-graphics.txt): at most 60 frames per second in a world, 30 in menus
without a world or behind other windows, and half the automatic worker
threads. It only changes when frames are drawn; ticks and what frames show
are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:24:57 +02:00
Ludovic MarieandClaude Opus 5.5 43138159b2 Opt-in data collection (1/5): consent, payloads, outbox and a gated uploader
horizon-telemetry, shared by the launcher, the game and the ingest server:
- consent.json in the game folder: four switches, all off until the player
  answers, a consent version that re-asks when what is collected changes, the
  answer's time, and a random install ID made at the first yes.
- Payloads: perf summaries (percentiles + 14 frame-time buckets from a
  fixed-size Timings recorder), crash and bug reports, and benchmark reports
  carried as docs/benchmark-report.md defines them; size caps per kind.
- A scrubber for user folders, user names, e-mail and IP addresses, UUIDs and
  caller-known private terms.
- The outbox: only allowed kinds get in, daily caps, 200 items / 2 MiB.
- zstd batches with bounded decoding; the uploader re-reads the consent and
  drops withdrawn items before connecting, backs off and caps batches a day.
- tests/consent_gating.rs: a local mock server sees zero connections with the
  consent missing, outdated or withdrawn.

No server is set (DEFAULT_ENDPOINT is None): nothing is sent yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 02:21:13 +02:00
rdbtCVS 1295fccd17 perf(client): validate overlay reuse with source stamps and benchmark real packs 2026-10-15 02:19:11 +02:00
rdbtCVS 8177726f70 perf: reuse validated resource-pack overlays 2026-10-15 02:19:11 +02:00
rdbtCVS dd5a361a58 bench(client): measure state reuse after slice reader changes 2026-10-15 02:18:30 +02:00
rdbtCVS cb28f691ff perf: return only lighting from chunk light workers 2026-10-15 02:18:30 +02:00
rdbtCVS ded40d60c6 perf(client): reuse parsed model states during preparation 2026-10-15 02:18:30 +02:00
rdbtCVS 2746802152 perf(client): synchronize tick chunk membership only on changes 2026-10-15 02:14:16 +02:00
rdbtCVS 4cac0a13cd perf(client): track structural chunk membership revisions 2026-10-15 02:14:16 +02:00
rdbtCVS 5c076ca38f perf(client): parse entity model document once for startup batches 2026-10-15 02:14:04 +02:00
Ludovic Marie 44144373ea Merge pull request #240 from rdbtCVS/perf/reuse-entity-geometry-20261014
perf: reuse entity geometry vertex storage between frames
2026-10-15 02:13:54 +02:00
Ludovic Marie da0c985d1d Merge pull request #244 from rdbtCVS/fix/launcher-desktop-wrapper
Preserve package wrappers in launcher desktop entries
2026-10-15 02:09:35 +02:00
rdbtCVS a794c05417 bench: measure dense entity geometry buffer reuse 2026-10-15 02:08:23 +02:00
rdbtCVS 90bdf5bb1a perf: retain entity geometry vertex storage between frames 2026-10-15 02:08:23 +02:00
rdbtCVS 34628c1471 perf: group block entities once per save batch 2026-10-15 01:07:56 +01:00
Ludovic Marie 8df59b96a9 Merge pull request #243 from rdbtCVS/perf/visibility-shortcuts-20261014
perf(mesher): short-circuit section visibility flood fills
2026-10-15 02:06:31 +02:00
Ludovic Marie de75330b21 Merge pull request #245 from rdbtCVS/fix/launcher-warning-wrap
fix(launcher): wrap long warnings within the window
2026-10-15 02:06:19 +02:00
rdbtCVS 2491d49bf5 perf: reuse entity material batching scratch buffers 2026-10-15 02:05:41 +02:00
Ludovic Marie 142b8da33e Merge pull request #236 from ludovic111/feat/pr-ready
pr_ready: one self-check for agents and contributors, CONTRIBUTING.md and a PR template
2026-10-15 02:04:01 +02:00
rdbtCVS 954b67ffa2 Merge main and preserve both HUD regression tests 2026-10-15 01:00:00 +01:00
rdbtCVS f9fdcd2912 fix(launcher): let warning text wrap within the page 2026-10-15 00:58:00 +01:00
rdbtCVS 930f6fa19e Honor package wrappers in launcher desktop entries 2026-10-15 00:49:41 +01:00
rdbtCVS 90f84ac10d perf(mesher): short-circuit section visibility flood fills 2026-10-15 00:45:44 +01:00
Ludovic Marie e96e1571e9 Merge pull request #59 from Online5277/fix/vanilla-item-use-animations
Fix animations, axolotls, fluids, fire and world progress isolation
2026-10-15 01:36:40 +02:00
Ludovic Marie af0e0db4ec Merge pull request #205 from rdbtCVS/perf/buffer-json-readers-20261014
perf(client): buffer model and render JSON reads
2026-10-15 01:21:21 +02:00
Ludovic Marie adbbff5904 Merge pull request #234 from ludovic111/fix/horizon-menus-delete-world
Horizon menus: delete singleplayer worlds
2026-10-15 01:18:39 +02:00
Ludovic Marie 1ac9e4860a Merge pull request #207 from rdbtCVS/perf/slice-large-json-20261014
perf(client): parse large fact JSON from in-memory bytes
2026-10-15 01:17:38 +02:00
Ludovic Marie 51a939bd00 Merge pull request #225 from rdbtCVS/perf/single-release-fetch-20261014
perf(launcher): fetch startup releases once for list and updates
2026-10-15 01:17:31 +02:00
Cosmic ccbf6f09c6 Merge latest main without duplicate hurt sounds 2026-10-14 19:17:30 -04:00
Ludovic Marie 61cb0c92bb Merge pull request #190 from Online5277/fix/crosshair-attack-indicator
Match crosshair alpha cutoff and ready-target checks
2026-10-15 01:16:41 +02:00
Ludovic Marie fa8056e172 Merge pull request #226 from ludovic111/feat/rust-edition-title
Title screen: "RUST EDITION" under the logo
2026-10-15 01:16:08 +02:00
Cosmic a18410f92f Merge main while preserving fluid phases and baked arrow rendering 2026-10-14 19:14:59 -04:00
Ludovic MarieandClaude Opus 5.5 48c85fa19f Add tools/pr_ready.py, CONTRIBUTING.md and a pull request template
pr_ready is the one self-check to run before asking for review: from the
files a branch changes it runs rustfmt, clippy with warnings denied, the
tests of the touched crates (mapped through cargo metadata), the launcher's
own workspace when it is affected, the tokens.css check, the Cargo.lock
rule, the Python tests and the public-tree check, and the vanilla harnesses
the change can affect, picked by tools/parity.py exactly as CI's parity job
picks them. It runs locally or on a fleet machine through ~/fleet/rbuild;
on the Mac mini the harnesses use the parity runner's own workspace and
scripts. It prints one Markdown block for the pull request body and exits
non-zero on any failure.

tools/parity.py gains --changed-files for copies without Git history, and
tools/ci/vanilla-data.sh accepts the preparation key from outside.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:13:39 +02:00
Ludovic Marie f02db7aede Merge pull request #232 from ludovic111/fix/explorer-maps-147
Explorer, treasure and abandoned camp maps work (#147)
2026-10-15 01:13:24 +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
Ludovic MarieandClaude Opus 5.5 4d7ca0fc93 Horizon menus: delete singleplayer worlds
The Horizon-style Singleplayer page had no way to delete a save. A Delete
(danger) button now shows beside Play when a world is selected and opens a
confirmation page in the design language: the world's name and last-played
date in a notice led by the danger dot, "It will be lost forever. This
can't be undone.", then Delete world / Cancel. After deleting, the list
refreshes and the selection moves to the world that took the deleted row
(or the new last one). A failure shows a Horizon error page whose Back
returns to the question.

Both menu styles share world_management::delete, now a port of
LevelStorageAccess.deleteLevel: it takes the world's session lock first, so
a world open in this or another instance is refused, deletes everything but
the lock (retrying five times, 500 ms apart), then releases and removes the
lock and the folder. Only a single plain folder name directly in saves is
accepted, and links inside the world are removed, never followed.

Leaving a world now flushes the region writer and releases the session
lock (vanilla closes the storage access), so the world just played can be
deleted from the title.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:12:18 +02:00
Ludovic Marie b980ae9150 Merge pull request #188 from rdbtCVS/fix/title-panorama-quality
Fix pixelated Classic menu panorama
2026-10-15 01:10:47 +02:00
Ludovic Marie b6f1ca63ba Merge pull request #231 from ludovic111/ci/runner-disk
CI disk: short artifact retention, runner cleanup, sccache on the Mac runners
2026-10-15 01:10:25 +02:00
Ludovic MarieandClaude Opus 5.5 2ce2ddcd4d Explorer, treasure and abandoned camp maps work (#147)
Vanilla 26.3 has a MapItem per explorer map (buried_treasure_map,
abandoned_camp_map, woodland_mansion_map...) and treats any stack with a
map_id as a map. Its ExplorationMapFunction finds the nearest structure of
the tag, makes an unlimited tracking map centred on it, draws the biome
preview (MapItem.renderBiomePreviewMap) and adds the target decoration
(MAP_DECORATIONS, drawn by tickCarriedBy). Horizon only recognised
filled_map, turned trade maps into filled_map, cleared every decoration but
the player's each tick, and chest loot only flagged the map as found: the
treasure maps of shipwrecks and underwater ruins and the maps of 26.3's
abandoned camps had no map id, no target and no picture.

- map_id: any stack with the component.
- tick_maps: the item's map_decorations (red X, mansion, camp...) are drawn
  as addDecoration places them (off the map only when unlimited).
- render_biome_preview: vanilla's water outline picture from the noise
  biomes at sea level (new generator query noise_biomes, fuzzed through
  BiomeManager's zoom) and #water_on_map_outlines.
- Chest loot (rolled with the chunk here) keeps the function's search in
  minecraft:custom_data; opening the chest runs it from the chest (vanilla
  runs it when the chest is unpacked on first open), else reaching the
  inventory runs it from the player. Nothing found drops the item, as the
  tables' filtered map_id check does.
- Trades keep their map item.

Fixes #147

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:07:48 +02:00
Ludovic MarieandClaude Opus 5.5 7a07251acf CI disk: short artifact retention, runner cleanup, sccache on the Mac runners
Releases now ship as often as changes land, so nothing they leave behind may
grow without bound. Workflow uploads keep 3 days (nightly parity logs 7); the
releases carry their own assets.

tools/ci/runner-gc.sh, run every 6 hours by launchd on the Mac mini from a copy
of main, skips a runner in a job, removes a checkout's cargo build folders past
12 GB and harness work folders under .local/ untouched for 3 days, and keeps the
2 newest shared preparations while no runner works. The runners build through
the machine's sccache, so what it removes comes back from the cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:05:59 +02:00
Ludovic Marie cc0d6736a1 Merge pull request #229 from ludovic111/ci/release-debounce
Release train: ship every settled player-facing change, no 3-hour gap
2026-10-15 01:01:37 +02:00
Ludovic MarieandClaude Opus 5.5 86e0e122f4 Release train: ship every settled player-facing change, no 3-hour gap
Ludovic wants releases as often as changes land. A kind now releases when
player-facing pull requests merged since its last release and the newest of
them merged 15 minutes ago (a burst of merges becomes one release), or the
oldest an hour ago when merges never pause; release-now still ships at once.
There is no minimum time between releases. The scheduled checks run every 10
minutes (game at :02, launcher at :07) so a settled release goes out without
another merge.

Release titles keep proper nouns (Horizon's mark, Microsoft, LAN, ...) when
they lowercase the clauses after the first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 01:01:26 +02:00
Ludovic Marie 4552494550 Merge pull request #216 from ludovic111/fix/berry-prick-sound-144
Play the player's hurt sound for every damage, by its damage effects (#144)
2026-10-15 00:56:39 +02:00
Ludovic Marie 339bed0b7d Merge pull request #218 from ludovic111/fix/pickup-anim-142
Items, orbs and arrows fly to whoever picks them up (#142)
2026-10-15 00:56:30 +02:00
Ludovic Marie 440d9257ed Merge pull request #214 from ludovic111/fix/lan-name-195
LAN: offline players get a kept generated name instead of Player (#195)
2026-10-15 00:55:56 +02:00
Ludovic MarieandClaude Opus 5.5 8f3f72c7ef Title screen: "RUST EDITION" under the logo
The classic title screen's banner now reads "RUST EDITION". It is rebuilt
at load time from the player's own textures/gui/title/edition.png:
"EDITION" is kept column for column, the "T" is lifted out of it, and R, U
and S are Horizon's own glyphs (rectangles in title_edition.rs) drawn the
way the texture's letters are built, in shades sampled from it. "RUST"
spans "JAVA"'s columns, so the banner stays centred. Only vanilla 26.3's
exact texture (pixel fingerprint) is rebuilt: a resource pack's banner is
left as drawn. No Mojang pixels are added to the repository.

The logo and banner are now blitted as LogoRenderer does: the logo's top
44 of 64 rows and the banner's top 14 of 16 rows at y = 30 + 44 - 7
(the banner was 20 px too low and squeezed 16 rows into 14).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:54:50 +02:00
rdbtCVS aec1519f91 perf(launcher): share startup release response with update check 2026-10-14 23:52:59 +01:00
Ludovic Marie d85aa27aa8 Merge pull request #212 from ludovic111/fix/website-favicon-cache
Website: the new favicon reaches browsers at once, and /favicon.ico exists
2026-10-15 00:51:51 +02:00
rdbtCVS 8b6a2861b5 Merge main and retain both HUD regression tests 2026-10-14 23:33:46 +01:00
Ludovic MarieandClaude Opus 5.5 a3fcacb212 Website: the new favicon reaches browsers at once, and /favicon.ico exists
The icon files kept their names while the server lets browsers cache /favicon*
for a week, so visitors still saw the old mark. The pages now link each icon
with its content hash (?v=…), and the build writes /favicon.ico (the 32-pixel
PNG in an ICO container) for browsers that ask for it on their own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:32:42 +02:00
Ludovic MarieandClaude Opus 5.5 aec497c94b Items, orbs and arrows fly to whoever picks them up (#142)
Vanilla's ClientPacketListener.handleTakeItemEntity adds an
ItemPickupParticle for every take: the taken entity's render state
(extracted at partial tick 1) moves to the collector's x, (y + eyeY) / 2, z
along ((life + partial) / 3)^2 over three ticks, the target lerped between
ticks (ItemPickupParticleGroup). Horizon removed taken entities at once.

game/pickups.rs keeps those particles (joining the group at the end of the
particle tick, as ParticleEngine.tick does) and the item, orb and arrow
renderers draw them frozen at the moving position. Takes by the player
(items, orbs, arrows), by mobs (allays, villagers, piglins, sulfur cubes,
raid banners), by LAN guests on the host, and ClientboundTakeItemEntity on
a LAN guest or server all add one.

Fixes #142

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:31:19 +02:00
Cosmic e08687f362 Merge main and retain remaining crosshair parity fixes 2026-10-14 18:30:06 -04:00
rdbtCVS 576e88bd8c Zero-pad inventory effect duration fields 2026-10-14 23:29:29 +01:00
Ludovic MarieandClaude Opus 5.5 bf7b3d140f Play the player's hurt sound for every damage, by its damage effects (#144)
Vanilla plays the hurt sound on the client from broadcastDamageEvent
(LivingEntity.handleDamageEvent -> Player.getHurtSound, the damage type's
DamageEffects sound, pitch (r - r) * 0.2 + 1). Horizon only played
entity.player.hurt by hand for falls and mob hits, so sweet berry bushes
(poking -> hurt_sweet_berry_bush), fire and lava (burning -> hurt_on_fire),
drowning, freezing, cactus and the rest were silent.

The combat port's broadcast_damage now carries its damage source
(HealthEvent::DamageBroadcast, same trace line as before) and the game plays
hurt_sound(effects) for it; the two hand-written plays are gone.

Fixes #144

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:24:16 +02:00
Ludovic MarieandClaude Opus 5.5 dacdf69293 LAN: offline players get a kept generated name instead of Player (#195)
Every offline Horizon player joined as Player, the LAN host's own offline
profile, so the host refused every guest with "You logged in from another
location". Vanilla's client defaults --username to Player + millis % 1000
and the launcher passes the account's name. Horizon now generates
Player + five digits once and keeps it in horizon-player.txt next to
options.txt (same name, same offline UUID, same inventory on a friend's
world). --name, HORIZON_PLAYER_NAME and a signed-in account still win.

tools/test_lan_lifecycle.py: two default guests join one host together,
keep their names across rejoins, and a guest named Player is refused.

Fixes #195

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:24:16 +02:00
Ludovic Marie 830e8adbe5 Merge pull request #203 from ludovic111/fix/dedicated-server-spawners
Loaded spawners keep their mob; chunk block entities in vanilla's order
2026-10-15 00:23:26 +02:00
rdbtCVS 2217ebcb33 perf(client): parse large fact JSON from in-memory bytes 2026-10-14 23:21:03 +01:00
Ludovic Marie 02df01adbb Merge pull request #174 from ludovic111/feat/design-website
Website in the new design language
2026-10-15 00:18:33 +02:00
Ludovic Marie fe1b08f83a Merge pull request #173 from ludovic111/feat/design-menus
Game menus (Horizon style) in the new design language
2026-10-15 00:18:17 +02:00
Ludovic Marie b009c7652f Merge pull request #168 from ludovic111/feat/design-launcher
Launcher in the new design language
2026-10-15 00:18:02 +02:00
Ludovic Marie 0a0504dd63 Merge pull request #167 from ludovic111/feat/design-icon
Horizon's mark and its icon everywhere
2026-10-15 00:17:45 +02:00
Ludovic Marie 792aa79b0a Merge pull request #166 from ludovic111/feat/design-tokens
Design system: one set of tokens for the launcher, menus and website
2026-10-15 00:17:29 +02:00
rdbtCVS 99514c4b79 perf(client): buffer model and render JSON reads 2026-10-14 23:16:21 +01:00
Ludovic MarieandClaude Opus 5.5 8a95aa79a7 Website and README: the round-2 menus and launcher screenshots
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 5763ec7e58 Game menus: AA veils over bright backdrops, a whole wordmark on the home page
Every page and the in-world screens veil the backdrop at 0.90; the home page at 0.80, with its
secondary text in ink_soft (Ui::muted). Muted text over the backdrop is BACKDROP_MUTED. The home
page's faint wordmark is now whole: as wide as the content column, above the footnote, instead
of cut off at the bottom right.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 3e39dfae33 Launcher: a development-build override of the Microsoft endpoint, for the sign-in panel
HORIZON_MSA_MICROSOFT_ENDPOINT (debug builds only) points the device-code and token requests at
a local mock, so the restyled code panel can be seen and screenshotted without Microsoft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 a248e9242a Ship the launcher as Horizon.app in a .dmg, and in Linux's menus
macOS: packaging/macos/package.sh (run by launcher.yml) builds Horizon.app (Info.plist, bundle
id dev.horizon.launcher, macOS 11+, the .icns), signs it ad hoc, and makes the two release
assets: horizon-launcher-macos-arm64.dmg, the installer (the app and an Applications link over
a background, laid out by dmgbuild without a GUI), and the .zip the launcher updates from. A
launcher inside the app replaces the whole bundle: copied beside it with ditto, swapped in by
renames, restarted; a bare binary, as older launchers are, still finds its binary in the zip.
A macOS test zips, unpacks, swaps and re-verifies a signed bundle. Signing for real is a
MACOS_SIGN_IDENTITY away (packaging/macos/README.md, with what Gatekeeper asks meanwhile).

Linux: release builds keep horizon-launcher.desktop (with the binary's current path),
horizon.desktop (hidden, for the game window's icon) and the hicolor icons under
$XDG_DATA_HOME, without root; `horizon-launcher desktop-entry --remove` takes them out and
stops it, `desktop-entry` puts them back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 cd39680534 Show the new launcher and menus on the website and in the README
The README banner is redrawn in the new design (docs/media/banner.html), and the launcher and
menu screenshots (README and website) are the restyled ones. The website's launcher section
gains a Launcher tab: its screenshots no longer show the old background, whose licence was
never confirmed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 2094fb58ad Describe the game menus' sidebar and plates as built
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 275f60efec Launcher settings: a switch for the entry in Linux's applications menu
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 fcbbff26a7 Give Horizon a mark, and its icon everywhere
The mark is an H whose crossbar is the horizon: a violet line edge to edge, the stems above it
and their reflection below at a third, on whole units of a 16-unit grid so it stays sharp at
16 px. docs/media/brand holds the SVG masters (square icon, macOS tile, avatars, the mark for
light and dark backgrounds), the two alternatives that were shown with it, the GitHub social
preview and the Discord banner, and a README of what goes where and how to upload what only
Ludovic's accounts can.

`cargo run -p xtask -- icons` renders the masters (rsvg-convert) and packs, in plain Rust:
packaging/icons/horizon.ico and horizon.icns, Linux's hicolor icons 16–512 and the scalable
SVG, the game's run-time window icon, the avatars and the website's favicons.
packaging/linux has .desktop entries for the launcher and the game.

On Windows, build.rs embeds the .ico as icon resource 1 in horizon-client (and its tools) and
horizon-launcher (winresource, a Windows-only build dependency); gpui loads that resource as
the launcher's window icon. The game window sets the icon itself (X11, Windows) and names
itself `horizon` on Wayland so the desktop entry matches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 c49a91a22c Restyle the website in the new design language
The page now reads the design tokens generated from crates/horizon-theme (src/tokens.css, put
before style.css by build.mjs), and follows the system's light or dark appearance, with a
toggle that remembers the choice. The full-screen sunset hero is replaced by type: a headline,
one paragraph, the download, and an index list of what the page holds. Every section is a mono
label, a display heading and its content as numbered rows with one-pixel rules: the facts, the
fourteen subsystems, the launcher's features, the community links. Rows fade a soft plate in
under the pointer; content arrives with a short fade, rise and unblur. The footer ends on the
giant faint wordmark. The 404 page and the social preview (og.html) follow.

Instrument Serif, the hero images, the parallax, the statement fill and the magnetic buttons
are gone; the icons come from Horizon's new mark (xtask icons). Touch targets are 44 px high.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 7df75b9d3b Restyle the game's Horizon menus in the new design language
Only Horizon's own style changes (what HORIZON_MENU_STYLE=horizon and "Menu style: Horizon"
show); vanilla's classic screens and the HUD are untouched.

The shared components in ui_theme follow the launcher: pill buttons, the sidebar as text items
with a soft sliding pill under the mark and a name instead of a tinted panel, the segmented
control's ink pill, a 36x20 switch, a thin ink slider, fields without borders, display-size page
titles, mono capitals for status lines and section labels. Settings rows, worlds and servers are
numbered index lists (01, 02…) with one-pixel rules; list rows fade a soft plate in under the
pointer and keep it under the selection. Plates are translucent ink so they read the same over
any backdrop. The veil over the live backdrop is flat (no gradients), lighter on the home page,
which ends on the faint wordmark. The opening's wordmark is "horizon" in Geist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 86149f7da6 Restyle the launcher in the new design language
A top bar (the mark, Horizon, the pages as text) replaces the sidebar; a mono footer carries
the launcher's version, its update and the community links. Every page is a display title, a
short muted line, then an index list: Play's resources and folders, the versions with their
names large and their size and state in mono, the patch notes, the settings. Buttons are pills,
the main action is the one violet thing on the screen, selection is an ink pill, the console,
notices and the crash report sit on soft plates without borders. The Play page ends with the
faint wordmark, half set below the window's edge.

The sunset photograph and its veils are gone (and the `image` dependency with them): the
window is the page colour, light or dark with the system. The opening draws the wordmark
letter by letter, then the launcher rises into place. HORIZON_LAUNCHER_APPEARANCE=light|dark
overrides the system's appearance, for screenshots and tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:40 +02:00
Ludovic MarieandClaude Opus 5.5 7d26cb0a5b Hold the game menus' text to AA over the brightest backdrops
The veil goes to 0.90 (0.80 on the title's home page, which keeps to ink and ink_soft), and
muted text over the backdrop is lifted to #9fa3ae; a test checks every pair over a backdrop of
luminance 0.77 (the snowiest title panorama measured reaches 0.49), bare and on a row's plate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:22 +02:00
Ludovic MarieandClaude Opus 5.5 1a730659f0 Add a design system: one set of tokens for the launcher, menus and website
docs/design-system.md describes the new design language (after mkdir.directory: type as the
interface, numbered index lists with hairline rules, one violet accent, space instead of
borders, quiet motion, a faint wordmark) and its tokens, components, surfaces and mark.

crates/horizon-theme now holds the whole system: the two palettes (cool neutrals, light and
dark, each text colour checked against WCAG AA on every background by a test), the type scale
with web sizes, spacing, radii, rules, control heights and motion with CSS easing curves.
Instrument Serif is gone; display type is Geist at medium weight.

`cargo run -p xtask -- tokens` writes website/src/tokens.css from the crate, and a test fails
when the file is out of date. The launcher and the game's menus read the new names through
small adapters; the restyles follow in their own changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:08:22 +02:00
Ludovic Marie 4b4b36b716 Merge pull request #191 from rdbtCVS/t3code/classic-video-gui-button
Fix GUI Scale button in classic Video Settings
2026-10-15 00:08:20 +02:00
Ludovic Marie a5bd5ebf7a Merge pull request #201 from ludovic111/fix/cactus-mips-110
Sprite mips from vanilla's MipmapGenerator: no cactus corner gaps
2026-10-15 00:04:25 +02:00
Ludovic Marie c8dbb00031 Merge pull request #177 from rdbtCVS/t3code/classic-singleplayer-ui-parity
Fix Classic singleplayer world management screens
2026-10-15 00:04:20 +02:00
Ludovic Marie 288ea9dfa1 Merge pull request #198 from ludovic111/fix/end-fog-103
The dragon fight's world fog and the End's void darkness floor
2026-10-15 00:02:15 +02:00
Ludovic Marie e17a40fc6b Merge pull request #197 from ludovic111/fix/end-crystal-102
End crystals from EndCrystalModel: lit, with their base and beams
2026-10-15 00:02:09 +02:00
Ludovic Marie b91288a937 Merge pull request #196 from ludovic111/fix/crosshair-97
Inverting crosshair and a working attack indicator
2026-10-15 00:02:02 +02:00
Ludovic Marie 1643b78d7b Merge pull request #194 from ludovic111/fix/creeper-swell-157
Creepers swell and flash for LAN guests; load ignited from NBT
2026-10-15 00:01:56 +02:00
Ludovic MarieandClaude Opus 5.5 d93b347b41 Loaded spawners keep their mob; chunk block entities in vanilla's order
A saved spawner's block entity id is minecraft:mob_spawner, not its block's
name, so decode_block_entity found no block entity for it and dropped it:
every spawner loaded from a save (a vanilla world's dungeons and mineshafts,
or Horizon's own after a reload) lost its spawn data, and the server sent
its chunk with no update tag. The id now maps to the spawner block.

The chunk packet lists block entities in LevelChunk.blockEntities' order,
which Horizon reproduces from the position hashes; positions sharing a
bucket keep their insertion order, which for a loaded chunk is the save's
block_entities list (SerializableChunkData -> pendingBlockEntities). That
list order is now kept with the chunk and used as the insertion order.

dedicated_server: 542 transcript lines identical (was 10 differing).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 00:00:37 +02:00
Ludovic MarieandClaude Opus 5.5 95db0fce9d Sprite mips from MipmapGenerator: cutout edges keep their coverage
Horizon averaged each mip level's alpha plainly, so a cutout texture's
transparent edges grew past the alpha test with every level: the cactus
side's transparent border columns opened see-through slits along its
corners from about 40 blocks, more than vanilla shows. The sprite array's
mips now come from a port of MipmapGenerator.generateMipLevels: the
texture's .mcmeta mipmap_strategy (auto: cutout when a pixel is fully
transparent) and alpha_cutoff_bias, solidify or the dark fill on the base,
meanLinear or darkenedAlphaBlend, and scaleAlphaToCoverage keeping every
cutout level's alpha-test coverage at the base level's.

tools/test_mipmap_parity.py runs vanilla's MipmapGenerator and the port on
every static 16x16 block texture and a sample of item textures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:59:47 +02:00
rdbtCVS b48539197a Use the current framebuffer for GUI Scale cycle limits 2026-10-14 22:59:27 +01:00
Ludovic Marie 73a019967e Merge pull request #193 from ludovic111/fix/arrow-pickup-119
Arrows can be picked up (#119)
2026-10-14 23:46:15 +02:00
Ludovic Marie 3bf8387ca8 Merge pull request #189 from ludovic111/fix/arrow-fire-124
Burning arrows: lava, fire, Flame and what they light (#125, #124)
2026-10-14 23:45:54 +02:00
Ludovic Marie d6f729321b Merge pull request #187 from ludovic111/fix/arrow-water-127
Arrows get vanilla's water physics (#127)
2026-10-14 23:45:32 +02:00
Ludovic Marie de5fb47174 Merge pull request #186 from ludovic111/fix/water-fall-damage-180
Water cancels the player's fall damage (#180)
2026-10-14 23:45:20 +02:00
Ludovic MarieandClaude Opus 5.5 3319e6e204 The dragon fight's world fog, and void darkness from each dimension's floor
EnderDragonFight's boss event sets createWorldFog, and while such a bar
shows AtmosphericFogEnvironment.setupFog pulls the fog in (start at most
10, end at most 96, sky and cloud fog ending there): Horizon never did, so
the End stayed clear during the fight. FogInputs gains boss_world_fog,
set while the dragon's bar shows.

FogRenderer's void darkness starts voidDarknessOnsetRange (32) above
level.getMinY(), which is 0 in the Nether and the End; Horizon used the
Overworld's -64 everywhere, so the End's fog never darkened below y 32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:41:22 +02:00
Ludovic MarieandClaude Opus 5.5 ee697cadc6 End crystals from EndCrystalModel, lit, with their base and beams
The crystals were a hand-built mesh without normals (so unshaded), without
the bedrock base and without beams. They now draw the baked
minecraft:end_crystal#main layer under EndCrystalRenderer's transform
(scale 2, down 0.5, not flipped) with EndCrystalModel.setupAnim's poses
(end_crystal::poses: the base shown per showsBottom, the glass raised by
half the bob, outer glass, inner glass and cube turned with rotateBy), the
entity light at their block, and the beam to their target
(submitCrystalBeams from the target's centre up to the crystal) while the
spikes are being summoned back. Crystals placed outside the End draw too.

The entity model harness no longer skips end_crystal: the oracle runs
EndCrystalModel.setupAnim on EndCrystalRenderState and the fixture poses
the layer with end_crystal::poses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:40:18 +02:00
Ludovic MarieandClaude Opus 5.5 6ab958a423 Crosshair through the inverting CROSSHAIR pipeline, with the attack indicator
Hud.extractCrosshair draws every crosshair sprite with
RenderPipelines.CROSSHAIR, whose BlendFunction.INVERT (ONE_MINUS_DST_COLOR,
ONE_MINUS_SRC_COLOR, ONE, ZERO) inverts the colour under it; Horizon drew it
alpha-blended white. GuiDraw sprites now carry their pipeline, the HUD batch
records the vertex ranges that invert and the HUD renderer draws them with a
second pipeline.

The attack indicator (crosshair or hotbar, per the Attack Indicator option)
now shows Player.getAttackStrengthScale(0) from the attack strength ticker
instead of a constant full strength, and the full indicator shows at full
strength on a living entity under the crosshair with a weapon slower than
MIN_CROSSHAIR_ATTACK_SPEED.

The HUD harness records every sprite's pipeline on both sides and gives the
crosshair a stand-in crosshairPickEntity (alive or not) and attack delay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:32:36 +02:00
Ludovic MarieandClaude Opus 5.5 087b2526c6 Creepers swell for LAN guests and load ignited from NBT
The host sends Creeper.DATA_SWELL_DIR and DATA_IS_IGNITED with the
creeper's entity data, and a guest runs the client half of Creeper.tick
on them, so the swell and white flash CreeperRenderer draws now show on
the guest too. Creeper.readAdditionalSaveData's ignited lights the fuse
(summon creeper ~ ~ ~ {ignited:1b}).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:28:35 +02:00
rdbtCVS e8efd36e5b Integrate world management with Classic button click sounds 2026-10-14 22:20:20 +01:00
rdbtCVS e57a43745a Fix GUI Scale button in classic Video Settings 2026-10-14 22:20:13 +01:00
Ludovic MarieandClaude Opus 5.5 89eaaab2ee Arrows can be picked up (#119)
Players could never pick up arrows: arrows had no pickup rule and no
item, nothing checked a player touching them, and arrows from saves were
loaded into a separate list that never ticked.

Arrows now carry AbstractArrow's pickup rule and pickupItemStack. The
player's bow and crossbow give them the drawn ammo (ProjectileWeaponItem
.useAmmo: creative, Infinity and multishot's extra arrows draw intangible
ammo, so those arrows are CREATIVE_ONLY), setOwner(player) turns
DISALLOWED into ALLOWED, dispensers make them ALLOWED, mobs' stay
DISALLOWED. Each tick the player touches the arrows in its box grown by
(1, 0.5, 1) (Player.aiStep): a stuck arrow that has stopped shaking goes
into the inventory (ALLOWED), or just goes for a creative player
(CREATIVE_ONLY, or ALLOWED with a full inventory), with the item pickup
sound at the arrow.

Saved arrows load as live arrows (life, shake, inGround, inBlockState,
pickup, item, fire) and live arrows are saved, so stuck arrows survive a
reload and despawn on time; the legacy never-ticked arrow list is gone,
and /kill, /tp and /data reach the live arrows.

The arrow harness adds who shot the arrow, intangible ammo and
dispensers' ALLOWED, then touches in flight, while shaking and when stuck
by survival and creative players with empty, partly and fully filled
inventories, against the real setOwner and playerTouch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:17:07 +02:00
rdbtCVS 24d8c57932 Make filtered world selection regression deterministic 2026-10-14 22:06:48 +01:00
rdbtCVS f8341dc415 Ignore generated world backups 2026-10-14 22:06:48 +01:00
rdbtCVS aa5305772d Fix backup completion flow and localize world management 2026-10-14 22:06:48 +01:00
rdbtCVS b37db61713 Fix world selector clipping lint on Rust 1.99 2026-10-14 22:06:48 +01:00
rdbtCVS 329334723d Fix classic singleplayer world management UI parity 2026-10-14 22:06:48 +01:00
Ludovic Marie 94aeab9a42 Merge pull request #185 from Online5277/fix/prepare-relative-root
Resolve relative preparation roots before running Java exporters
2026-10-14 23:04:32 +02:00
Ludovic Marie cab02f7e5e Merge pull request #183 from Online5277/fix/rabbit-animation
Drive rabbit hop and idle animations from runtime controls
2026-10-14 23:04:26 +02:00
Ludovic Marie 237e9b9ff3 Merge pull request #181 from Online5277/fix/falling-block-model
Render falling blocks with their actual block models
2026-10-14 23:04:20 +02:00
Ludovic Marie c176b4f8bc Merge pull request #179 from Online5277/fix/dragon-egg-particles
Render dragon egg teleport particles
2026-10-14 23:04:15 +02:00
Ludovic Marie 7d1b2a5777 Merge pull request #176 from rdbtCVS/t3code/fix-screenshot-seed-actions
Fix screenshot, seed, and locate chat click actions
2026-10-14 23:04:09 +02:00
Ludovic Marie b857fc2b72 Merge pull request #182 from rdbtCVS/t3code/fix-classic-ui-menu-sounds
Fix classic menu click sounds to match vanilla
2026-10-14 23:04:03 +02:00
Ludovic Marie 4704e98bda Merge pull request #137 from ludovic111/feat/extras-reconnect
Horizon extras: auto-reconnect (off by default)
2026-10-14 23:00:07 +02:00
Cosmic 2f34bf7b46 Invert crosshair rendering and show attack cooldown indicators 2026-10-14 16:57:51 -04:00
rdbtCVS 42eda649a8 Render Classic menu panorama with vanilla cube-map filtering 2026-10-14 21:54:22 +01:00
Cosmic e7e35fa6e1 Correct resident fluid phase and breathing decisions 2026-10-14 16:53:10 -04:00
Cosmic 792a681497 Seed falling block models from their spawn cell 2026-10-14 16:51:30 -04:00
Cosmic f465e1b6ae Avoid verbatim preparation paths on Windows 2026-10-14 16:49:07 -04:00
Cosmic 07f1c34f96 Merge remote-tracking branch 'origin/main' into fix/dragon-egg-particles
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-14 16:49:02 -04:00
Ludovic MarieandClaude Opus 5.5 37043b3dff net harness: the Disconnected screen gained auto-reconnect's retry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:56 +02:00
Ludovic MarieandClaude Opus 5.5 228bdda201 Burning arrows: lava, fire, Flame and what they light (#125, #124)
Arrows never caught fire: nothing ignited them (lava, fire blocks, a
Flame bow's projectile_spawned ignite), so they never set mobs, TNT,
campfires or candles alight. Flame only worked for mobs' bows, as a
5-second ignite on hit.

Arrows now keep Entity.remainingFireTicks: the blocks along their path go
through InsideBlockEffectApplier.StepBasedCollector (fire and soul fire
fireIgnite to 8 s, lava LAVA_IGNITE to 15 s, water EXTINGUISH with the
extinguish sound), the player's bow and mobs' bonus ignite them for 100 s
at spawn, and the fire burns down in flight but not while stuck. A
burning arrow sets its target alight for 5 s, primes TNT, lights
campfires, candles and candle cakes (onProjectileHit, mob_griefing for
mobs' arrows), and blows up a TNT minecart; arrows now hit minecarts like
other entities (VehicleEntity damage). Burning arrows show
FlameFeatureRenderer's fire quads.

The arrow harness adds lava pools, lava/water walls, fire and soul fire,
Flame arrows, and burning/non-burning hits on TNT, campfires, candles and
candle cakes against the real blocks' onProjectileHit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:37 +02:00
Ludovic MarieandClaude Opus 5.5 1d93642094 Arrows get vanilla's water physics (#127)
Arrows flew through water with a point check for the 0.6 drag and nothing
else: no bubbles, no splash, no currents, no bubble columns, and the
stuck arrow logic (nudging back by the normalized motion, no falling
out, a 1200-tick age) was not AbstractArrow's.

Port AbstractArrow.tick for arrows (arrows.rs, state in
Projectile::arrow): the in-block check, the water inertia from the last
base tick's wasTouchingWater with four bubbles behind it, crit trails,
lerped rotation, the COLLIDER clip with entity hits first, the effects
of the blocks along the path (bubble columns push, water extinguishes),
air drag and gravity, then the base tick's updateFluidInteraction
(currents while not stuck, splash sound/particles), sticking 0.05 back
along each moving axis with the arrow's own random for the hit sound,
falling out when the block goes, and despawning 1200 ticks after
sticking.

New harness tools/test_arrow_parity.py drives real vanilla Arrow entities
(VanillaArrowOracle) and Horizon's arrows::tick (examples/arrow_probe.rs)
over the resident scene.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 645109d427 Keep the scene motion scope text a valid string
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 f442374e6d Format the player fluid binding
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 60d9bffc86 Water cancels the player's fall damage (#180)
The player's movement treated every fluid as empty: is_in_water was
always false, LivingEntity.checkFallDamage's updateFluidInteraction was
a no-op and Entity.move's fall-reset ray ignored water. A player landing
in water (or a waterlogged block, a bubble column, kelp) still took the
whole fall.

Bind vanilla's Entity.updateFluidInteraction to the player over the
existing horizon-world kernels (EntityFluidInteraction heights, eyes and
currents, entity_fluid_tick, water_splash, fluid_flow): run it in the
base tick and from checkFallDamage, read wasTouchingWater for
isInWater, query the water's (first-query cached) shape in the
FALLDAMAGE_RESETTING ray, halve the fall in lava after the first tick,
and play the splash sound with bubble and splash particles.

The scene motion harness gains water/lava fixtures (fluid_tick plus
scripted falls through the real Entity.move) against the real vanilla
updateFluidInteraction with the #water/#lava tags bound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:44:31 +02:00
Ludovic MarieandClaude Opus 5.5 ce9c1d3a5c Auto-reconnect: never after account session refusals, retry when session servers are down
Classifies the online-mode login outcomes from #161: invalid session,
banned from playing online, multiplayer disabled, no account signed in
and the server failing to verify the username are never retried;
unreachable session servers (the client's or the server's) are. A retry
of an online-mode server joins with the account held in memory for the
whole run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:41:14 +02:00
Ludovic MarieandClaude Opus 5.5 5797cf2702 Auto-reconnect retries with the session's player name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:39:51 +02:00
Ludovic MarieandClaude Opus 5.5 43b45fabc7 Horizon extras: auto-reconnect, off by default
On the Disconnected screen after a lost multiplayer connection, a
countdown "Reconnecting in N s" with a Cancel button, then the same
address again. Attempts back off (5, 10, 20, 40, 60 s) and stop after
five; the count resets only after a minute connected. Only failed
connections and the server's shutdown, restart, time-out or full
reasons are retried, never kicks, bans, whitelists, version refusals or
duplicate logins: the network thread now says whether the server gave
the reason (login refusals included).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:39:36 +02:00
Ludovic Marie 21aab8b164 Merge pull request #136 from ludovic111/feat/extras-minimap
Horizon extras: minimap (off by default)
2026-10-14 22:38:32 +02:00
Ludovic Marie 35120b0e91 Merge pull request #135 from ludovic111/feat/extras-core
Horizon extras: zoom, FPS counter, keystrokes and the Extras page (off by default)
2026-10-14 22:38:01 +02:00
Ludovic Marie 294010913b Merge pull request #143 from ludovic111/ci/release-train-nightly
Release train 4/4: nightly parity audit with parity-regression issues
2026-10-14 22:28:57 +02:00
Ludovic Marie ae460db2e8 Merge pull request #141 from ludovic111/ci/release-train-launcher
Release train 3/4: automatic launcher releases, version built in from the tag
2026-10-14 22:28:36 +02:00
Ludovic Marie af6bb6bc8c Merge pull request #138 from ludovic111/ci/release-train-games
Release train 2/4: automatic game releases
2026-10-14 22:28:13 +02:00
Cosmic 7566a4753c Merge PR 186 fluid foundation and preserve swimming and fire behavior 2026-10-14 16:28:07 -04:00
Ludovic Marie 12e8d5b71e Merge pull request #133 from ludovic111/ci/release-train
Release train 1/4: cached verify, and a parity job on the Mac mini for what a PR can affect
2026-10-14 22:27:33 +02:00
rdbtCVS c166ea57d1 fix: match vanilla screenshot and seed link behavior 2026-10-14 21:22:19 +01:00
Ludovic MarieandClaude Opus 5.5 beac11eaa5 Nightly parity audit on the Mac mini, with an issue per regressed harness
nightly.yml runs every harness from scratch on main at 03:00 Paris time on the Mac mini's runner, after
preparing the resources again if the preparer changed. tools/ci/parity_issues.py compares the results with
the previous audit: a harness that passed and now fails gets one issue labelled parity-regression (an open
one is commented on), and an issue closes when its harness passes again. The bugs session picks them up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:18:31 +02:00
Ludovic MarieandClaude Opus 5.5 98ca844f07 launcher: the Microsoft sign-in's User-Agent says the built-in version too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:18:31 +02:00
Cosmic e57c32efb8 Merge current main and preserve axolotl damage behavior 2026-10-14 16:18:08 -04:00
Ludovic MarieandClaude Opus 5.5 76f3e7c2a8 launcher.yml: GPUI's runtime shaders on a Mac runner without the Metal toolchain (the Mac mini)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:46 +02:00
Ludovic MarieandClaude Opus 5.5 fa61f262c3 launcher: automatic releases, the version built in from the tag
launcher.yml releases the launcher by itself: after a push to main that changes launcher/,
crates/horizon-prepare or crates/horizon-theme, hourly, and by hand, with the game's rules
(tools/release_train.py decide --kind launcher: player-facing pull requests and a 3-hour cooldown,
release-now, skip-release, RELEASES_PAUSED). launcher-vX.Y.Z is the next patch, or the next minor after a
pull request labelled feature. Linux on ubuntu-22.04, Windows on GitHub, macOS on the Mac mini's runner;
published with --latest=false.

No version-bump commits: VERSION reads HORIZON_LAUNCHER_VERSION with option_env!, falling back to
Cargo.toml's, and --version, the User-Agent and the self-update comparison use it; each build checks
--version says the release's version.

With several game releases a day, the newest launcher release soon falls out of the newest 100 (launcher)
or 30 (website) releases: both now look the newest launcher-v tags up one by one when the list has none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:46 +02:00
Ludovic MarieandClaude Opus 5.5 6d759dc7bb release_train: name a feature split over several pull requests once in the title; no leading Fix
Today's main would have been titled "Controllers, controllers, controllers and 17 more changes".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 9e016b6235 vanilla-data.sh: clone the sounds into each preparation instead of linking them
Vanilla's resource packs refuse symbolic links inside a pack: with linked sounds the vanilla sound oracles found
no sound files while Rust did (sound_event, sound_engine on #133's parity run).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 fb256809a7 release: the macOS smoke run draws 1500 frames; 300 end before the world has loaded
Checked on the Mac mini: 1500 offscreen frames in 12 s, 81 chunks generated, 4153 sections meshed, a player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 cfad57d0b1 Mac runner: the game's sounds in each preparation; save_roundtrip Linux-only
The first audit on the Mac mini failed 7 audio harnesses (no sounds in the prepared resources: the Mac mini has
no official launcher for horizon-prepare sounds to copy from) and save_roundtrip (it needs vscreen). The
sounds depend only on the Minecraft version: kept once in shared-local/sounds/<version>/ and linked into each
preparation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 e994c8ab95 release: the macOS smoke run opens a world (from a scratch folder) and checks it drew one
Without --world the client stays on the title screen: 300 frames and no chunks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 7b82ee8c16 Two Horizon runners on the Mac mini: one preparation at a time; docs
A second runner (ludovics-mac-mini-2, same labels) shares the first one's vanilla data through a link, so
vanilla-data.sh prepares under a lock; a 30-minute audit no longer holds a release's macOS build back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 7adfe44f6c launcher: rustfmt the rollback test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 f6191fe8ee parity_hosts.json: linux_only, empty: the four harnesses that once differed on macOS pass there
options, chunk_generation, world_border_render and mob_ai all pass on the Mac mini (7.3 min).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 8aacb48798 release: automatic game releases after main's green checks
release.yml now releases the game by itself: after every green checks run of a push to main, every hour
(a release the cooldown held goes out without another merge) and by hand. tools/release_train.py decides
from the pull requests merged since the last published game release: a release when one changed what
players run and the last release is 3 hours old, at once for a pull request labelled release-now;
skip-release pull requests don't count. RELEASES_PAUSED=1 stops it. Tags vYYYY.MM.DD, then .1, .2 the
same UTC day.

Linux (ubuntu-22.04) and Windows build on GitHub with Cargo caches, macOS on the Mac mini's runner
(MAC_RUNNER=macos-latest sends it to GitHub). Each builds horizon-client, horizon-ctl and horizon-prepare
(all three named: --bin selects only the named binaries), checks all three exist, checks their libraries,
starts each one, packages with xtask and archives; the Mac also runs the game offscreen for 300 frames on
a new world with resources prepared from its own Minecraft. The notes: New (label feature), Fixed (the
rest), Known issues (docs/known-issues.md). A hand-started run off main, or with dry_run, builds
everything and writes the notes but publishes nothing.

docs/release-flow.md describes the flow, the labels, pausing and rolling back; the launcher gets a test
that a release turned back into a draft is never offered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 989f843b1c vanilla-data.sh: keep everything horizon-prepare writes per preparer version, beside the shared data
The first parity run kept only .local/horizon-client per preparer version; the exporters' outputs beside it
stayed in the checkout, and the dedicated server, which finds registries.json at
<resources>/../../datagen, didn't find it next to the prepared folder (world_spawn failed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 874d111d60 checks: cached verify, and a parity job on the Mac mini for the harnesses a PR can affect
verify keeps its dependencies and workspace build in a cache saved by main's runs (Swatinem/rust-cache).
The new parity job runs on the Mac mini's self-hosted runner (label horizon-game), which holds the
maintainer's own Minecraft files: tools/ci/vanilla-data.sh links .local/ to the runner's copy and prepares
the resources with the pull request's own horizon-prepare, once per preparer version; then
tools/parity.py --changed <base> runs only the harnesses whose traced dependencies changed since the merge
base and that have no pass for their current inputs. A fork's pull request runs it only when a maintainer
adds run-parity, once per label, on the commit it had then.

parity.py: --changed REF, and linux_only in tools/parity_hosts.json for harnesses that differ on other
platforms for a reason that is not a Horizon bug (skipped there with the reason, unless named).

docs/release-flow.md starts with the pull request checks and merging.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:17:20 +02:00
Ludovic MarieandClaude Opus 5.5 03309dee95 Format the player fluid binding
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:13:34 +02:00
Ludovic Marie 7338f55b9a Merge pull request #164 from ludovic111/feat/msa-launcher
Microsoft account (c): launcher sign-in, keychain, session handoff to the game
2026-10-14 22:12:11 +02:00
Ludovic Marie 4559a92942 Merge pull request #161 from ludovic111/feat/msa-online-mode
Microsoft account (a): join online-mode servers (session join, RSA, AES/CFB8), skins over HTTPS
2026-10-14 22:10:58 +02:00
Ludovic MarieandClaude Opus 5.5 e9da898f97 Water cancels the player's fall damage (#180)
The player's movement treated every fluid as empty: is_in_water was
always false, LivingEntity.checkFallDamage's updateFluidInteraction was
a no-op and Entity.move's fall-reset ray ignored water. A player landing
in water (or a waterlogged block, a bubble column, kelp) still took the
whole fall.

Bind vanilla's Entity.updateFluidInteraction to the player over the
existing horizon-world kernels (EntityFluidInteraction heights, eyes and
currents, entity_fluid_tick, water_splash, fluid_flow): run it in the
base tick and from checkFallDamage, read wasTouchingWater for
isInWater, query the water's (first-query cached) shape in the
FALLDAMAGE_RESETTING ray, halve the fall in lava after the first tick,
and play the splash sound with bubble and splash particles.

The scene motion harness gains water/lava fixtures (fluid_tick plus
scripted falls through the real Entity.move) against the real vanilla
updateFluidInteraction with the #water/#lava tags bound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:07:48 +02:00
Cosmic 6c4ad9d825 Resolve preparation root before changing exporter directories 2026-10-14 16:06:18 -04:00
Ludovic Marie 70fd5f5c77 Merge pull request #178 from Online5277/fix/reuse-imported-minecraft
Reuse verified Minecraft imports when preparing new versions
2026-10-14 22:04:25 +02:00
Cosmic 88b9a82688 Select baby rabbit model for summoned negative ages 2026-10-14 16:01:30 -04:00
Ludovic Marie 9ae39fac28 Merge pull request #170 from rdbtCVS/fix/lan-host-tab-list
Fix tab list for singleplayer LAN hosts
2026-10-14 21:59:55 +02:00
Ludovic Marie f04d47e14d Merge pull request #165 from rdbtCVS/t3code/fix-inventory-banner-rendering
Fix inventory banner face depth ordering
2026-10-14 21:59:49 +02:00
Cosmic af8700bd20 Drive rabbit hop and idle animations from runtime controls 2026-10-14 15:46:01 -04:00
rdbtCVS d456dadafa Match vanilla inventory effect duration text color 2026-10-14 20:45:33 +01:00
Ludovic Marie d299d1f07a Merge pull request #162 from rdbtCVS/t3code/fix-classic-ui-background
Fix missing background in Classic menus
2026-10-14 21:43:28 +02:00
Ludovic Marie 7156602a7f Merge pull request #171 from rdbtCVS/t3code/disable-lan-on-leave
Fix LAN shutdown when leaving a singleplayer world
2026-10-14 21:43:23 +02:00
Ludovic Marie 1f8a2b28d6 Merge pull request #172 from rdbtCVS/t3code/fix-lan-world-menu-pause
Fix LAN worlds pausing when the host opens menus
2026-10-14 21:43:16 +02:00
Cosmic 8caee51897 Regenerate preparation metadata when reusing imports 2026-10-14 15:37:32 -04:00
rdbtCVS d69a380851 Fix classic menu click sounds to match vanilla 2026-10-14 20:35:30 +01:00
rdbtCVS 2fbbe1c5e2 Fix status effect icons and render inventory effect panels 2026-10-14 20:35:18 +01:00
rdbtCVS 79ab688bda Fix banner rendering test lint on Rust 1.99 2026-10-14 20:34:26 +01:00
rdbtCVS 322a87cf93 Fix Clippy warnings in LAN tab list regression tests 2026-10-14 20:34:25 +01:00
Cosmic 517e275c79 Render falling blocks with their baked models 2026-10-14 15:33:44 -04:00
Ludovic Marie cb3b5abf6b Merge pull request #175 from ludovic111/fix/maces-105
Maces: smash attacks, Density, Breach, Wind Burst
2026-10-14 21:33:02 +02:00
Cosmic 1df16591b4 fix(client): render dragon egg teleport particles 2026-10-14 15:24:41 -04:00
Ludovic MarieandClaude Opus 5.5 56aae0e902 Maces smash: fall damage bonus, Density, Breach, Wind Burst, knockback
A mace hit after falling more than 1.5 blocks is now a smash attack
(MaceItem): the fall-based damage bonus plus Density, mace_smash damage,
the stopped fall with its damage waived, the ground/air smash sounds, the
dust pillars (level event 2013) and the knockback of the creatures around
the target, then the fall reset and landing particles. Breach thins the
target's armor (CombatRules.getDamageAfterAbsorb), Wind Burst bursts at
the player's feet (ExplodeEffect, trigger), and weapons wear by their
weapon component and award the used statistic.

Vanilla details matched on the way: the hurt knockback is 0.4F and reads
the live knockback-resistance attribute, explosion exposure uses the
exact COLLIDER clip, a burst posts the explode game event and ends the
impulse grace unless it is a wind charge's, visible armor stands feel
bursts that affect block-like entities, attack sounds play at the player.

tools/test_mace_parity.py compares the hit with VanillaDispenserOracle's
real MaceItem / hurtServer / post-attack effects: 3000/3000.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 21:23:57 +02:00
Ludovic Marie e15c879fe7 Merge pull request #169 from ludovic111/fix/bed-explosions-101
Beds and straw beds blow up when used in the Nether and the End
2026-10-14 21:23:09 +02:00
Ludovic Marie 11b7038b39 Merge pull request #163 from ludovic111/fix/respawn-anchors-99
Respawn anchors charge, set the respawn point, explode outside the Nether
2026-10-14 21:17:29 +02:00
Cosmic 20988e810e fix(prepare): reuse verified imports from installed versions 2026-10-14 15:14:10 -04:00
rdbtCVS 9f07f64bff fix: unify locate teleport suggestion links 2026-10-14 20:13:59 +01:00
rdbtCVS d25d6184c2 fix: enable screenshot and seed chat click actions 2026-10-14 20:04:08 +01:00
Cosmic ba3f4003cd Merge current main and preserve PR 59 review fixes 2026-10-14 14:56:18 -04:00
rdbtCVS 0ed50ae6ae Fix LAN worlds pausing when the host opens menus 2026-10-14 19:53:45 +01:00
rdbtCVS 1b1e129112 Fix LAN shutdown when leaving a singleplayer world 2026-10-14 19:51:23 +01:00
rdbtCVS 72074eb080 Fix LAN host tab list to include connected guests 2026-10-14 19:50:21 +01:00
Cosmic 027ac3cdf1 Address final axolotl bucket and fire review corrections 2026-10-14 14:39:49 -04:00
Ludovic MarieandClaude Opus 5.5 190f89b214 Beds and straw beds blow up when used in the Nether and the End
A right click on a bed in the Nether or the End tried to sleep in it (and
set the respawn point) as in the Overworld.

AbstractBedBlock.useWithoutItem reads the dimension's bed rule, which in
the Nether and the End is destroy_on_use: BedBlock.destroyOnUse removes the
head, then the foot behind it if it is still the bed, and explodes at the
head (power 5, fire, BLOCK interaction, the bad_respawn_point damage source:
"Intentional Game Design"); StrawBedBlock.destroyBed plays
block.straw_bed.break_leave and removes it.

tools/test_use_item_on_parity.py clicks beds and straw beds (head or foot,
whole or missing their other half, among blocks) in the Nether and the End,
through vanilla's real explosion; the oracle gains the End.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:24:55 +02:00
Ludovic MarieandClaude Opus 5.5 1596210bba Respawn anchors charge, set the respawn point, explode outside the Nether
A right click with glowstone on a respawn anchor did nothing and a charged
anchor never became the respawn point: the block reported the use and the
game dropped it.

RespawnAnchorBlock is now played out (game/respawn_anchor.rs): useItemOn
charges with glowstone (the block change, block.respawn_anchor.charge, the
used item and the charge_respawn_anchor criterion), passing to the off
hand's glowstone; useWithoutItem sets the respawn point in the Nether
(block.minecraft.set_spawn once, respawn_anchor.set_spawn) and elsewhere
explodes: the anchor goes, then a power 5 fiery BLOCK explosion with the
bad_respawn_point damage source whose centre resists like water when water
would flow into it. Dying with an anchor as the respawn point travels to
its dimension and respawns at findStandUpPosition (DismountHelper's safe and
unsafe passes, facing the anchor with calculateLookAtYaw), using a charge
and playing respawn_anchor.deplete; a missing or empty anchor falls back to
the world spawn with block.minecraft.spawn.not_valid. The point is saved in
the player's respawn compound with its dimension; a bed or /spawnpoint
replaces it. Dying in the Nether without one now respawns in the Overworld.

Explosions gained what these need: a damage type, a block resistance
override, BaseFireBlock.getState for their fire, the explosion radius in the
block loot context (survives_explosion and explosion_decay drawing from the
table's random sequence) and TntBlock.wasExploded without a game event.

tools/test_use_item_on_parity.py now charges, uses and blows up anchors in
both dimensions through vanilla's real ServerExplosion on the stand-in
level, and respawns at anchors among blocks through
ServerPlayer.findRespawnAndUseSpawnBlock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:18:49 +02:00
rdbtCVS aab88753b6 Keep title panorama optional during full resource preparation 2026-10-14 19:15:16 +01:00
rdbtCVS 8987369b07 Fix inventory banner face depth ordering 2026-10-14 19:09:42 +01:00
Ludovic MarieandClaude Opus 5.5 d4e0dc4917 Settings subtitle mentions accounts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 6bed9b92ca launcher.yml: build in HORIZON_MSA_CLIENT_ID from the repository variable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 324eb2836c Launcher README: Microsoft accounts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 82bee83dc5 Launcher: Microsoft account sign-in, keychain and the game's session
Device-code OAuth, Xbox Live, XSTS, Minecraft login, ownership and profile;
secrets in the OS keychain (oo7 on Linux, keyring on macOS/Windows), accounts.json
without tokens; Settings account rows, 'Playing as' on the Play page; CLI
'account' and MCP account tools; the game gets a 0600 session file it deletes.
Client ID is configuration (HORIZON_MSA_CLIENT_ID), empty = not configured.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:07:47 +02:00
Ludovic MarieandClaude Opus 5.5 f38e8cc393 Skins over HTTPS: fetch textures with ureq (rustls)
Players' skins on online servers come from https://textures.minecraft.net;
the old plain-HTTP fetch left everyone with their default skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:57:34 +02:00
Ludovic MarieandClaude Opus 5.5 0acd686720 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 6957d74742 docs: Minecraft login endpoint
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 ac97e9c0b9 Harness: online-mode login against vanilla's server and a mock session service
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 89b2ca64a9 Join online-mode servers with the launcher's account
Read the signed-in account from a session file (--session-file, deleted
on read), answer ClientboundHelloPacket like vanilla: shared secret, server
hash, session join through the services discovery document (authlib's
error mapping and messages), RSA-wrapped key packet, AES/CFB8 both ways.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 c8629f3378 Protocol: RSA public keys and PKCS#1 encryption, server hash, secrets
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic MarieandClaude Opus 5.5 c6634830d3 WIP: Microsoft account design note and RSA (not wired yet)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:55:57 +02:00
Ludovic Marie f83bc0ab70 Merge pull request #146 from ludovic111/fix/spawners-95
Monster spawners spawn, spin their mob and take spawn eggs
2026-10-14 19:54:54 +02:00
rdbtCVS eebf5621bf Fix classic menu panorama resource preparation 2026-10-14 18:54:51 +01:00
Ludovic Marie d1517fa79b Merge pull request #158 from Online5277/fix/arrow-hit-sound
Play normal arrow impact audio for player hits
2026-10-14 19:54:48 +02:00
Ludovic Marie b7f14763a7 Merge pull request #149 from ludovic111/feat/controllers-rumble
Controllers: rumble
2026-10-14 19:43:33 +02:00
Ludovic MarieandClaude Opus 5.5 663f61c8c3 Horizon extras: a minimap, off by default
A corner map of the loaded chunks in vanilla's map colours with the map
item's height and water shading: square or round, three sizes, four
zooms, north up or turning with the player, with the player arrow. In
the Nether, underground and under a roof it maps the floor at the
player's height and cuts walls. Each loaded chunk keeps a 16x16 tile,
filled nearest first within 0.3 ms a frame and rescanned after block
changes; the image is recomposed only when the view or a tile changed
and uploaded into its own atlas slot. Nothing runs while it is off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:41:01 +02:00
Ludovic MarieandClaude Opus 5.5 365d35f53a Extras: the zoom key zooms only while playing and never takes keys
The zoom key went to the zoom on every screen, so with zoom on, Z could
not be typed in screens with text fields that share the game's key
handler. Now it zooms only while playing with no sign or book open, lets
go when a screen opens, and always passes the key on, like a vanilla key
mapping. A vanilla mapping bound to the same key still acts; the Extras
page marks the zoom key "Also bound" and names it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:41:01 +02:00
Ludovic MarieandClaude Opus 5.5 a08577e301 Extras: horizon-ctl key and mouse reach the zoom key and keystrokes
Agents press keys and buttons through the control socket, which skipped
the keyboard and mouse hooks; under control the window may also lack
focus without pausing, so the zoom no longer lets go then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:39:38 +02:00
Ludovic MarieandClaude Opus 5.5 c4b0e8efe0 Horizon extras: zoom, FPS counter and keystroke overlay, off by default
An Extras section in Horizon's Options holds optional additions saved in
horizon-extras.txt beside options.txt. Zoom divides the field-of-view
target vanilla's Camera.tickFov eases toward (the spyglass path), with a
wheel-adjustable level and lower mouse sensitivity; scoping keeps the
spyglass view. The FPS counter (with minimum and 1% low) and the keystroke
overlay (WASD, space, mouse buttons with clicks per second) draw in the
HUD layer, hidden with F1. docs/extras.md describes all the extras.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:39:20 +02:00
Ludovic Marie b7fc86279c Merge pull request #151 from rdbtCVS/t3code/fix-creative-fall-damage-sound
Fix fall damage sounds in creative mode
2026-10-14 19:37:28 +02:00
Ludovic MarieandClaude Opus 5.5 40fce014f1 Controllers: hurt rumble only within one player's life
The health baseline (HealthWatch) is forgotten when there is no player,
on the title screen, and whenever a new player entity takes over (spawn
and respawn, joining a server, End and Nether dimension changes), so
arriving with less health than the last player had isn't damage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:37:04 +02:00
Ludovic MarieandClaude Opus 5.5 ccdc4fe682 Controllers: rumble
Short, light pulses through gilrs force feedback, on by default
(Vibration in horizon-controller.txt): damage taken (stronger with the
damage, from any source, here or on a server), a block broken, an
explosion nearby (fading over four times its radius), a totem of undying.
Only the pad that is playing vibrates; macOS has no force feedback in
gilrs and stays still. pad status reports the last vibration asked for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:36:52 +02:00
Ludovic Marie b61455e562 Merge pull request #145 from ludovic111/feat/controllers-options
Controllers: Controller options, button binds and glyphs
2026-10-14 19:36:29 +02:00
Ludovic Marie d8ad1dad7a Merge pull request #156 from rdbtCVS/t3code/fix-classic-ui-scroll-limit
Fix classic options scroll limits
2026-10-14 19:34:45 +02:00
Ludovic Marie 3dcbd2228b Merge pull request #154 from rdbtCVS/t3code/fix-world-options-menu
Fix World Options pause and Escape behavior
2026-10-14 19:34:26 +02:00
Ludovic Marie 1be820b091 Merge pull request #150 from Online5277/fix/startup-settings
Apply saved audio and GUI settings at startup
2026-10-14 19:34:17 +02:00
Ludovic Marie 97a65cd5aa Merge pull request #155 from rdbtCVS/fix/keybind-category-translations
Fix keybind category translations
2026-10-14 19:34:10 +02:00
Ludovic Marie 61e5b70fde Merge pull request #153 from ludovic111/gitignore-run-files
Ignore the files the client writes to the repo root
2026-10-14 19:34:03 +02:00
Ludovic Marie 4ac53c0867 Merge pull request #152 from rdbtCVS/fix/options-held-input
Fix stuck keys when opening Options
2026-10-14 19:33:42 +02:00
Ludovic Marie afce3f7aa1 Merge pull request #56 from Online5277/fix/creative-inventory-toggle-drop
Fix creative inventory tab and toggle-sprint item drops
2026-10-14 19:33:37 +02:00
Ludovic MarieandClaude Opus 5.5 79e04e22d6 Monster spawners spawn, spin their mob and take spawn eggs
Spawners were never ticked: Horizon kept their block entity as loaded NBT,
and a freshly generated dungeon or mineshaft spawner lost even that, so no
mob ever came out, nothing turned inside and a spawn egg used on one spawned
a mob next to it instead of setting the spawner's.

The spawner is now a modelled block entity (mobs::spawner): BaseSpawner's
load and save with SpawnData's codec (normalised ids, custom spawn rules,
weighted potentials, the next data shared with the one potential when the
potentials are missing), serverTick (the player range, spawner_blocks_work,
the delay, getOrCreateNextSpawnData, Pos, the spawn box against blocks and
solid shulkers, custom light rules and peaceful, SpawnPlacements, the nearby
cap, the yaw, checkSpawnRules and checkSpawnObstruction, finalizeSpawn for
an id alone, the 2004 burst, entity_place and the spawn poof) and delay with
its block event, in the level random's order. The client copy keeps its own
delay for the spin and puffs smoke and flames; SpawnerRenderer draws the
next mob small and turning. SpawnEggItem.useOn on a spawner sets its mob
(setEntityId), tells the player when spawner_blocks_work is off and fails in
peaceful for monsters. Breaking one pops 15 + nextInt(15) + nextInt(15)
experience whatever the tool. LAN guests get the update tag, the block event
and the entity event.

tools/test_spawner_parity.py ticks vanilla's SpawnerBlockEntity on the
natural spawn oracle's level against mobs::spawner on the same worlds (6,000
cases); tools/test_use_item_on_parity.py now covers spawn eggs on spawners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:33:25 +02:00
Cosmic db1a895c05 fix(client): play the arrow impact sound for player hits 2026-10-14 13:22:12 -04:00
Ludovic Marie b5690ad295 Merge pull request #128 from ludovic111/perf/terrain-vertex-32
Terrain gets its own 28-byte vertex without a normal (was 36)
2026-10-14 19:20:17 +02:00
Ludovic MarieandClaude Opus 5.5 32bb3612fb Controllers: × for the PlayStation cross (the game fonts lack ✕)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:12:24 +02:00
Ludovic Marie e8ca05af19 Controllers: deadzone percentages rounded 2026-10-14 19:12:24 +02:00
Ludovic MarieandClaude Opus 5.5 ce85fd98ed Controllers: Controller options, button binds and glyphs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:12:24 +02:00
Ludovic Marie 2470ba9b83 Merge pull request #129 from ludovic111/feat/controllers-menus
Controllers: menus, screens and inventories (virtual cursor)
2026-10-14 19:12:05 +02:00
rdbtCVS 318988d767 Fix classic options scrolling at list boundaries 2026-10-14 18:10:11 +01:00
rdbtCVS 25d1d3cc07 Fix keybind category translations 2026-10-14 18:08:06 +01:00
Ludovic MarieandClaude Opus 5.5 840e8ad4a8 Give terrain its own 28-byte vertex without a normal
Since #52 the shared vertex carried an RGBA8_SNORM face normal for
entity.wgsl's directional light, so every section vertex grew from 32 to
36 bytes although vs_terrain never reads it. Vanilla's terrain uses
DefaultVertexFormat.BLOCK (position, colour, UV0, UV2: 28 bytes): the face
shade is baked into the colour by ModelBlockRenderer and terrain.vsh only
multiplies it by the lightmap.

Sections now mesh into TerrainVertex: position, UV, the sprite layer as a
u16 (bit 15 marks opaque leaves), the two light bytes as a u16 and the
RGBA8 colour, 28 bytes. Every value the shader reads is unchanged; entity
vertices keep their normal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:07:01 +02:00
Ludovic MarieandClaude Opus 5.5 ea93297579 Report the section arena and device memory in the control status
The status reply's renderer block gains the section vertex arena's buffer
bytes, the bytes resident sections use in it, and the backend allocator's
allocated/reserved device memory (Vulkan), to measure terrain memory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:06:49 +02:00
Ludovic Marie 51dc44f64f Merge pull request #121 from ludovic111/fix/creeper-white-flash
OverlayTexture texels from one tested table; creepers flash white while swelling
2026-10-14 19:06:16 +02:00
rdbtCVS 90115e5574 Fix World Options pause and Escape behavior 2026-10-14 18:05:30 +01:00
Ludovic MarieandClaude Opus 5.5 5cf5b6bfd2 Ignore the settings and files the client writes to the repo root
options.txt, horizon.txt, horizon-graphics.txt and the rest showed up as
untracked after running the game from the checkout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:05:30 +02:00
Ludovic Marie 02104eab29 Merge pull request #148 from ludovic111/release/launcher-0.1.5
Launcher 0.1.5
2026-10-14 19:04:55 +02:00
rdbtCVS 28b01e9eca Fix fall damage sounds in creative mode 2026-10-14 18:02:51 +01:00
rdbtCVS b6df5a91a9 Fix held keys getting stuck when opening Options 2026-10-14 18:02:35 +01:00
Ludovic MarieandClaude Opus 5.5 2edc69ef99 Launcher 0.1.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:59:23 +02:00
Cosmic 0dd8ed546a fix(client): apply saved settings before the title screen 2026-10-14 12:59:05 -04:00
Ludovic Marie 52975ad988 Merge pull request #132 from ludovic111/fix/release-yml-prepare
release.yml: build horizon-prepare's binary too
2026-10-14 18:46:54 +02:00
Ludovic MarieandClaude Opus 5.5 79ea17ffc5 Controllers: classic sliders are focus targets; the D-pad nudges with A held
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:42:19 +02:00
Ludovic MarieandClaude Opus 5.5 b58e96436a Controllers: Horizon-style menus' controls are focus targets too
Ui::interact records every enabled control (sidebar items, switches,
sliders, segments) as a D-pad focus target, apart from the labelled
button list horizon-ctl clicks by name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:40:31 +02:00
Ludovic MarieandClaude Opus 5.5 cbf4b53b0c Controllers: menus, screens and inventories with a virtual cursor
The left stick moves a cursor drawn over the screen, the D-pad steps
between slots and buttons (focus_step), and A / X / the right stick send
the window the pointer events a mouse would, so every screen keeps its
own mouse behaviour (drags, sliders, scrollbars). Y quick-moves in
inventories, LB/RB switch creative tabs, B and Start go back. Focus lands
on the first button, or the held hotbar slot, when a menu opens while the
pad is in use; moving the real mouse hands the pointer back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:40:31 +02:00
Ludovic Marie e145a32322 Merge pull request #120 from ludovic111/feat/controllers-input
Controllers: design note, gilrs input layer and gameplay
2026-10-14 18:40:10 +02:00
Ludovic MarieandClaude Opus 5.5 a28667923a release.yml: build horizon-prepare's binary too
With --bin flags, cargo builds only the named binaries of every selected
package, so -p horizon-prepare built nothing and the import checks failed on
target/release/horizon-prepare (every run since #92).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:38:13 +02:00
Cosmic f1e0b093a5 Merge newer main while retaining consumable guard and review fixes 2026-10-14 12:27:15 -04:00
Ludovic MarieandClaude Opus 5.5 e70b5e2882 Controllers: a pad press lasts at least one game tick
Vanilla samples keys once per tick, so a stick click shorter than 50 ms
(sprint) could come and go between two ticks and be lost: its release
waits for the next tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:25:33 +02:00
Ludovic Marie d1d727f838 Merge pull request #123 from ludovic111/feat/sounds-rust
horizon-prepare sounds: sound copy in Rust, run by the launcher (no Python for players)
2026-10-14 18:25:04 +02:00
Ludovic Marie 98c71567e3 Merge pull request #126 from ludovic111/feat/package-rust
xtask package: the release folder in Rust, replacing packaging/package.sh
2026-10-14 18:24:27 +02:00
Cosmic e9e4ef96a7 Merge main and address PR59 bucket, fire, mipmap and AI review 2026-10-14 12:19:54 -04:00
Ludovic MarieandClaude Opus 5.5 842d11efc4 xtask package: the release folder in Rust, replacing packaging/package.sh
`cargo run -p xtask -- package BINARY OUTDIR` does what package.sh did:
OUTDIR wiped and recreated, the `git archive HEAD tools
mods/horizon/src/test/java crates/horizon-prepare research-lock.json LICENSE`
tree extracted (the tar read in Rust, executables kept executable), the client,
horizon-ctl and horizon-prepare copied (.exe beside an .exe, the same error when
one is missing) and PLAY.txt. release.yml calls it; package.sh is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:16:20 +02:00
Ludovic Marie 425021dda5 Merge pull request #122 from ludovic111/fix/nether-portals-94
Nether portals: vanilla timing and linking; items and mobs travel
2026-10-14 18:15:59 +02:00
Ludovic MarieandClaude Opus 5.5 b7330ac0cc Controllers: gilrs input layer, in-game movement, camera, actions and hotbar
Vanilla 26.3 has no gamepad input, so this is Horizon's own, on Bedrock's
default layout (docs/controllers.md). gilrs reads the pads; the pure part
(bindings, radial deadzones, the look curve, button edges, the
horizon-controller.txt settings file) is in gamepad.rs with unit tests.

Buttons act as the vanilla key mapping they're bound to, through the same
path a rebound keyboard key takes (the keyboard's post-rebinding handling
moves into Game::mapped_key). The left stick is an analog move vector,
the right stick turns the view per frame, LB/RB step the hotbar, B and
Start leave screens and A respawns. horizon-ctl pad injects a virtual pad.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:15:40 +02:00
Cosmic e905c10400 Address creative inventory review on current main 2026-10-14 12:11:53 -04:00
Ludovic MarieandClaude Opus 5.5 8355a6ff14 horizon-prepare sounds: the sound copy in Rust, run by the launcher
tools/fetch_sounds.py (and the lab.py functions it used) becomes
`horizon-prepare sounds` (horizon_prepare::sounds): the pinned asset index,
sounds.json and every .ogg from the player's own launcher install, each SHA-1
checked, into .local/horizon-client/<version>. The launcher runs it as part of
Prepare (in-process, the version's own binary when its sources differ and it
knows the subcommand, in-process for older versions), and a launcher install
without the assets only logs that the game will be silent. Player docs no
longer mention Python for sound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:10:34 +02:00
Ludovic MarieandClaude Opus 5.5 74821bf188 Overlay texels from OverlayTexture, and the creeper's white flash
The hurt red and the white row's alphas come from overlay.rs, which
reproduces OverlayTexture's texels (checked against values printed by
the 26.3 JVM) and is prepended to entity.wgsl; a test parses and
validates the composed shader with naga.

Swelling creepers flash white as CreeperRenderer.getWhiteOverlayProgress
says (the white row's column, red winning while hurt; layers keep the
red only), and CreeperRenderer.scale's wobble uses the unclamped
swelling as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:07:31 +02:00
Ludovic MarieandClaude Opus 5.5 0818190ee4 docs: controller support design note
Vanilla 26.3 has no gamepad code; Horizon's own design follows Bedrock's
default controller layout. Mapping, menus, glyphs, options file, rumble,
Steam Deck and the PR plan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:06:50 +02:00
Cosmic 767ed4cb47 Use physical Ctrl state for stack dropping, not toggle sprint 2026-10-14 12:06:41 -04:00
Ludovic MarieandClaude Opus 5.5 1f06156649 Nether portals: vanilla timing, linking, and items and mobs travel
The player's portal timer reset to zero on any tick outside a portal and
came back from a trip with a 300-tick cooldown that only ran down outside
portals; the overworld exit search covered 32 blocks of loaded chunks;
nothing but the player could use a portal.

The player now follows Entity.setAsInsidePortal / handlePortal with
PortalProcessor (decay by 4, the cooldown of 10 that a portal keeps
resetting, players_nether_portal_default/creative_delay), and
NetherPortalBlock.getPortalDestination through the new
horizon_world::level::portal_travel: PortalForcer.findClosestPortalPosition
(16 / 128 blocks, loaded and saved chunks), createPortal,
BlockUtil.getLargestRectangleAround, PortalShape.getRelativePosition and
findCollisionFreePosition, with the turned yaw and motion and
block.portal.travel. LocalPlayer.handlePortalTransitionEffect closes
screens and plays block.portal.trigger. Items and mobs go through after one
tick with a 300-tick cooldown and wait as saved entities until their
dimension loads around the exit.

tools/test_nether_portal_parity.py compares the destination with vanilla's
getPortalDestination over two headless levels; the scene motion harness
gains portal fixtures for the player's processor and cooldown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:06:24 +02:00
Cosmic 9f0df29d6c Keep the inventory tab in the creative screen 2026-10-14 12:05:59 -04:00
Ludovic Marie b2462d54c4 Merge pull request #116 from ludovic111/fix/world-spawn-seed
Find a new world's spawn as vanilla does (NoiseSpawnFinder, setInitialSpawn, PlayerSpawnFinder)
2026-10-14 18:04:54 +02:00
Ludovic Marie eccb222e9f Merge pull request #115 from ludovic111/fix/sweet-berries-93
Sweet berries can be planted; picking and bone meal work
2026-10-14 18:04:47 +02:00
Ludovic Marie 6915757dd4 Merge pull request #118 from ludovic111/test/entity-model-normals
entity_model harness: compare per-vertex normals with vanilla
2026-10-14 18:04:39 +02:00
Ludovic Marie 1c957610bc Merge pull request #117 from ludovic111/fix/hurt-tint-order
Apply the entity hurt and white-flash overlay in vanilla's order
2026-10-14 18:04:26 +02:00
Ludovic MarieandClaude Opus 5.5 3f61995c8b test(entity_model): compare per-vertex normals with vanilla
The entity model parity harness now draws each posed model on both sides
under LivingEntityRenderer's root transform (body yaw, scale(-1, -1, 1),
the renderer's own scale) and compares every vertex's normal as the
signed bytes written to the vertex buffer: vanilla's ModelPart.render /
Cube.compile / PoseStack.Pose.transformNormal into a capturing
VertexConsumer, packed with BufferBuilder's own normalIntValue, against
Horizon's mobs::render::draw RGBA8_SNORM normals. A part-scale override
covers negative and non-uniform part scales (trusted sign flips and
untrusted 1/scale normalisation). A one-step difference is accepted only
where vanilla's float sits at a truncation step; a posed position
checksum per drawn part is compared too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:52:01 +02:00
Ludovic MarieandClaude Opus 5.5 a3d3297840 Apply the entity hurt/white overlay in vanilla's order
entity.fsh mixes the OverlayTexture texel into the textured, cardinal-lit
colour and only then multiplies by the lightmap. Horizon applied the lightmap
first and mixed a 0.3 red into the vertex colour, so the red was multiplied
by the texture and never darkened: hurt mobs glowed red at night and in caves.

The vertex stage now passes the lightmap colour and the overlay texel
(0xB2FF0000 hurt red, white alpha 63 for OverlayTexture.u(1.0)) to the
fragment stage, which mixes the overlay, then the lightmap, then glint and fog.

Layers vanilla submits with OverlayTexture.NO_OVERLAY no longer take the hurt
tint: armour (EquipmentLayerRenderer, glint stays), held items
(ItemInHandLayer) and the enderman's block (CarriedBlockLayer). The TNT
minecart flash uses the white overlay on the cart's light
(TntMinecartRenderer.submitWhiteSolidBlock) instead of plain full-bright white.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:50:22 +02:00
Ludovic MarieandClaude Opus 5.5 7ace13cc82 Find a new world's spawn as vanilla does
Horizon searched its own spiral of 48-block steps from 0 0 for a dry,
non-leaf top block, so seed 1891623539983467248 spawned at -48 -48 where
vanilla puts the world spawn at 144 71 144 (the player then lands within
the respawn radius, 152 145 in the report).

Vanilla (MinecraftServer.setInitialSpawn): NoiseBasedChunkGenerator.
getOrigin runs NoiseSpawnFinder over the noise settings' spawn_target
points (SpawnTargetPoint.sampleFitness, a 2048/512 then 512/32 radial
search biased towards 0 0); an 11 by 11 chunk spiral around that chunk
takes the first column PlayerSpawnFinder.getSpawnPosInChunk accepts
(getLevelRespawnPos: MOTION_BLOCKING, WORLD_SURFACE and OCEAN_FLOOR
heights, no liquid, a full-topped collision shape); without one, the
origin chunk's centre at the spawn height. The first player is then
placed by PlayerSpawnFinder.findSpawn: the respawn-radius square in a
random coprime order, a free player box with liquids colliding, else the
spawn with fixupSpawnHeight; adventure mode uses the spawn as is.

horizon-world gains worldgen::spawn_finder and OverworldGenerator::origin;
the client's world_spawn module and game/initial_spawn.rs follow
setInitialSpawn and PlayerSpawnFinder, for single player and the
dedicated server alike. Flat worlds use FlatLevelSource's origin and
spawn height.

tools/test_world_spawn_parity.py compares, for the report's seed, the
chunk harness's seeds and 50 random ones, the origins
(VanillaWorldSpawnOracle against the spawn_origin example) and the
level.dat spawn of worlds created by the vanilla dedicated server and by
Horizon's. Vanilla is not deterministic on chunk corners where trees of
neighbouring chunks overlap (decoration order follows its worker
threads), so a mismatching seed reruns vanilla up to three times.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:49:15 +02:00
Ludovic MarieandClaude Opus 5.5 80645bf0de Sweet berries, carrots and potatoes are planted instead of eaten
A right click with food that is also a block item tried to eat it before
placing it: in creative always, in survival whenever the player was
hungry. BlockItem.useOn places first and only eats a consumable that could
not be placed (counting the use, as ItemStack.useOn does). Picking a sweet
berry bush or cave vines dropped nothing: the harvest loot
(Block.dropFromBlockInteractLootTable) is now rolled and popped. Bone meal
on a bush that can still grow grows it (SweetBerryBushBlock.useItemOn
passes it on) instead of picking it, and counts the use with its
item_interact_finish vibration. Every block placement played its sound
twice; BlockItem.place's level event is now the only one.

tools/test_use_item_on_parity.py drives vanilla's real
ServerPlayerGameMode.useItemOn (and Minecraft.startUseItem's useItem
fallback) against use_item_as_player in a headless game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:43:24 +02:00
Ludovic Marie 45663c9e39 Merge pull request #98 from ludovic111/feat/logs-crash-reports
Logs and crash reports: game, launcher, CLI and MCP
2026-10-14 17:28:44 +02:00
Ludovic Marie f22e18114d Merge pull request #113 from ludovic111/feat/ctl-mouse-move
horizon-ctl: mouse-move, and turn/look-at --over through the player's mouse path
2026-10-14 17:27:01 +02:00
Cosmic 29fbebf3d7 Merge main and retain Dolphin Grace and Weaving effect flags 2026-10-14 11:25:55 -04:00
Ludovic MarieandClaude Opus 5.5 2cfeed118b horizon-ctl: mouse-move and eased turns through the player's mouse path
mouse-move DX DY (MCP mouse_move) adds relative motion to MouseHandler's
accumulated movement, so the next frame turns the player with
turnPlayer's sensitivity curve ((s*0.6F+0.2F)^3*8, no 8 while scoping),
the inverted axes and the cinematic camera, then Entity.turn. The motion
counts as a grabbed pointer in an active window until the real pointer
is grabbed or released again.

turn and look-at (and look-at-block/look-at-entity) take --over SECONDS:
the turn becomes that motion fed over the time with ease-in-out (the
shorter way round for look-at), replying once the last of it has been
turned. Without --over they still jump. turn is now an MCP tool. stop
cancels a turn in progress.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:18:12 +02:00
Ludovic Marie 452e2b7a9f Merge pull request #112 from ludovic111/fix/stuck-in-blocks
Cobwebs and sweet berry bushes slow the player
2026-10-14 17:16:19 +02:00
Ludovic MarieandClaude Opus 5.5 66c6bd6b13 F3 + C writes a crash report
The debug crash from #90 printed a stand-in report; it now ends the game
with a DebugCrash error that main reports as "Manually triggered debug
crash" with the stack where it was raised. The game keeps the error that
ends it as an error, not a string, so main can tell. Leading std backtrace
frames are dropped (macOS keeps them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:15:20 +02:00
Ludovic Marie 6c210fe9fd Merge pull request #111 from rdbtCVS/fix/website-footer-links
Fix footer links blocked by decorative Horizon word
2026-10-14 17:13:29 +02:00
Ludovic MarieandClaude Opus 5.5 39e8bbb2f4 Cobwebs and sweet berry bushes slow the player
Entity.applyEffectsFromBlocks now runs WebBlock.entityInside and
SweetBerryBushBlock.entityInside for the player through
Player.makeStuckInBlock (no slowdown while flying, impulse context reset),
with Weaving's lighter cobweb hold and the sweet_berry_bush damage of a
grown bush while the known movement is at least 0.003 on X or Z. The scene
motion harness gains cobweb and berry-bush fixtures run through vanilla's
real applyEffectsFromBlocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:10:01 +02:00
Ludovic Marie 2b9674af8f Merge pull request #108 from ludovic111/fix/wayland-fullscreen-clipboard
Keep the compositor's fullscreen; copy and paste in every text field
2026-10-14 17:08:33 +02:00
rdbtCVS 9bc9a1e159 fix(website): allow clicks through decorative footer word 2026-10-14 16:03:35 +01:00
Cosmic e828354040 Apply fluid currents to primed TNT and show fire in inventory preview 2026-10-14 11:02:27 -04:00
Ludovic MarieandClaude Opus 5.5 744b61efc3 Crash stacks start at the panicking frame, without the build machine's paths; multi-line log messages as log4j writes them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:58:35 +02:00
Ludovic MarieandClaude Opus 5.5 1569572ba0 Game logs and crash reports; launcher crash notice; logs in the CLIs and MCP
The game writes logs/latest.log in its home like vanilla's log4j config
(client-1.21.2.xml): [HH:mm:ss] [thread/LEVEL]: message, rolled at start and
at midnight to logs/<date>-<n>.log.gz, seven a day. Messages go through the
log facade and still print their old text on stderr.

A main-thread panic or a fatal error writes
crash-reports/crash-<date>_<time>-client.txt (error, stack, Horizon and
Minecraft versions, OS, CPU, memory, GPU adapter, render settings, resource
packs, world, last 50 log lines). Development builds crash on purpose with
HORIZON_DEBUG_CRASH.

The launcher shows "Horizon crashed" when the game it started ends badly,
with Open report, Copy report, Open logs folder and Report on Discord;
Settings gets a Logs row. horizon-launcher logs and horizon-ctl logs
(--lines N, --crash) print the log's end or the newest report, and both MCP
servers have a logs tool.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:58:35 +02:00
Ludovic MarieandClaude Opus 5.5 832a8e3f46 Keep the compositor's fullscreen, and copy/paste in every text field
Fullscreen: applying settings (any option button, joining a world,
travelling, reloading packs) compared the Fullscreen option with what the
window reported and forced the window back to the option. A window made
fullscreen by the compositor (a Hyprland keybind or window rule) left
fullscreen on the next button click. The window now follows blaze3d
Window's fullscreenRequested/fullscreen pair: it is only asked to change
when the option changes (updateFullscreenIfChanged), a change the window
system makes is adopted by the option (updateFullscreenState ->
Minecraft.fullscreenStateChanged), and the window is created fullscreen
when options.txt says so.

Clipboard: Ctrl+A/C/V/X (Cmd on macOS) in the chat, signs, book and quill
(page and title), anvil, creative and recipe-book searches, the language
and pack searches, the LAN port, world name and seed, and the server name
and address boxes, through the arboard clipboard (Wayland data-control,
falling back to X11). Shortcuts follow InputWithModifiers.isCopy/isPaste/
isCut/isSelectAll on the modifier state and the layout's letter; pasted
text goes through each field's own vanilla rules: StringUtil.filterText
and setMaxLength for EditBox, line breaks kept and the 1024-character and
14-line limits for MultiLineEditBox, formatting stripped and whole-or-
nothing width checks for TextFieldHelper on signs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:57:49 +02:00
Ludovic Marie ead31a40f9 Merge pull request #100 from ludovic111/fix/launcher-self-update-message
Launcher: say the self-update succeeded (not "No such file or directory") and restart into it
2026-10-14 16:57:17 +02:00
Ludovic Marie ac96218bfe Merge pull request #104 from ludovic111/fix/particles-63-item-emitters
Particle emitters: waxing, scraping, sparks, eating, snowballs, eggs, slimes (part of #63, stacked on #96)
2026-10-14 16:51:59 +02:00
Ludovic Marie dc74a3fe71 Merge pull request #96 from ludovic111/fix/particles-63-missing-types
Particles: 29 missing vanilla particle types (part of #63)
2026-10-14 16:51:35 +02:00
Ludovic Marie 51601ba2a3 Merge pull request #91 from ludovic111/fix/particles-63-emitters
Entity particle bursts: five angry villager particles and the other bursts as in vanilla (part of #63)
2026-10-14 16:51:30 +02:00
Ludovic Marie b746580c50 Merge pull request #92 from ludovic111/rust/prepare
Prepare resources in Rust: horizon-prepare replaces the Python preparer
2026-10-14 16:43:36 +02:00
Ludovic MarieandClaude Opus 5.5 70583cbf2a Particle emitters: waxing, scraping, sparks, eating, snowballs, eggs, slimes
The new particle types now come from the events that spawn them in
vanilla.

- Level events 3003 (wax on), 3004 (wax off), 3005 (scrape):
  ParticleUtils.spawnParticlesOnBlockFaces, three to five particles on
  each face, from the client level's random. They are sent by honeycomb
  on a block (HoneycombItem.useOn), axe scraping and wax removal
  (BlockTransformer's TransformParticle.send), dispensed honeycomb, and
  copper golems being waxed or scraped.
- Level event 3002: a struck lightning rod sends sparks along its axis
  (ParticleUtils.spawnParticlesAlongAxis); without an axis they cover
  the block's faces.
- Eating: LivingEntity.spawnItemParticles, five crumbs every
  four ticks and sixteen when consumed (Consumable.onConsume), placed in
  front of the eyes and thrown along the look, as item particles in full
  colour. They were greyed terrain crumbs above the eyes. They now
  follow Consumable.shouldEmitParticlesAndSounds with the remaining ticks
  before the countdown, as LivingEntity does: six times over 32 ticks,
  not seven, each one a tick earlier.
- Snowballs (Snowball.handleEntityEvent(3)): eight item crumbs at rest.
  Eggs (ThrownEgg): eight scattered by up to 0.04 a tick.
- Slime, magma cube and sulfur cube landings (AbstractCubeMob.tick):
  size × 16 item_slime, flame or sulfur_cube_goo particles around the
  body, from draws the mob's random also makes on the server.

New harness tools/test_emitter_particles_parity.py: the real
ParticleUtils emitters and LivingEntity.spawnItemParticles.

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:38:38 +02:00
Cosmic cadc4698c1 Merge current main; restore player hurt sounds and bucket fluid updates 2026-10-14 10:33:06 -04:00
Ludovic MarieandClaude Opus 5.5 158e0c6346 Launcher: a self-update after the binary was replaced says it updated, and Restart re-runs it
On Linux, once the launcher's file has been replaced under the running
process (an update from the command line or another window), current_exe()
is "<path> (deleted)" and self_replace failed with "No such file or
directory". The new binary now goes to the path the launcher started from
(copy beside it, rename over it; Windows still uses self_replace), and an
update the file already has is not downloaded again. The message reads
"Updated to X. Restart the launcher to use it."; Restart execs the new
binary with the same arguments (spawn and exit on Windows) and says so if
it cannot start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:33:02 +02:00
Ludovic MarieandClaude Opus 5.5 63bb9bc37c compare_prepare.py: a tar of the Python tree for machines without the repository; launcher formatting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:29:33 +02:00
Ludovic MarieandClaude Opus 5.5 a995fd3b46 Prepare with horizon-prepare: launcher, release folders, messages and docs
The launcher prepares a version in-process when it ships the preparer sources
the launcher was built with, with the version's own horizon-prepare when they
differ, and with the Python script only for versions made before it. Release
folders ship horizon-prepare and its sources; resources are reused between
versions with the same preparer. The game's messages, PLAY.txt, the docs and
release builds name horizon-prepare, and tools/prepare_native_client.py is a
wrapper of it for the development tools.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:29:33 +02:00
Ludovic MarieandClaude Opus 5.5 9fa3a684c6 horizon-prepare: the resource preparation in Rust
A port of tools/prepare_native_client.py and the helpers it imported from
lab.py and the test_*_parity.py modules: the player's pinned game files are
imported and checked against research-lock.json, the client's assets and data
copied, the vanilla exporters compiled and run with the JDK (JAVA_HOME, the
PATH, or java_home on macOS, at the pinned java_major), and the resources and
their manifest written byte for byte as the script wrote them, with its
sha256-keyed skips. tools/compare_prepare.py checks that against the Python
preparer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:29:14 +02:00
Ludovic MarieandClaude Opus 5.5 efa2f0e3be Particles: 29 missing vanilla particle types
Horizon's engine knew 91 of vanilla's 128 particle types. These come from
their real providers, constructor to tick:

- sweep_attack (AttackSweepParticle)
- glow, wax_on, wax_off, electric_spark, scrape (GlowParticle and its
  providers)
- squid_ink, glow_squid_ink (SquidInkParticle: sinks through air)
- item, item_slime, item_snowball, item_cobweb, sulfur_cube_goo
  (BreakingItemParticle: a quarter of the item's ground-model sprite in
  full colour, unlike terrain crumbs)
- flash (FireworkParticles.OverlayParticle, its sine pulse and fading
  alpha), which firework explosions now show as vanilla's Starter does
- firefly (FireflyParticle: wanders, fades in and out, vanishes inside
  blocks)
- trial_spawner_detection(_ominous) (TrialSpawnerDetectionParticle)
- trial_omen, raid_omen (SpellParticle)
- ominous_spawning (FlyStraightTowardsParticle, colour lerped like
  ARGB.srgbLerp)
- spit (ExplodeParticle falling at 0.5), fishing (WakeParticle),
  dust_plume (DustPlumeParticle)
- red/orange/yellow_poplar_leaves (FallingLeavesParticle.PoplarProvider)
- pause_mob_growth, reset_mob_growth (SimpleVerticalParticle)
- block_marker (BlockMarker)

ParticleType.getOverrideLimiter now lists every type vanilla registers
with it (gust, sonic_boom, sculk charges, campfire smoke, vault_connection
and the new ones were missing), so they show beyond 32 blocks.

The /particle commands take the new types; item particles resolve the
stack's ground model.

The particle_providers harness now builds all 120 types Horizon implements
through the real providers (1428 cases).

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:25:24 +02:00
Ludovic MarieandClaude Opus 5.5 383fc116c9 Entity particle bursts: as many particles as vanilla, about the body
Villagers showed one angry, happy or heart particle where vanilla's
AbstractVillager.addParticlesAroundSelf shows five, spread over the body
with Gaussian motion. Taming and love bursts were spread about the middle
of the body rather than over it, and drew from the world random.

particles::around_self places a burst as the client handlers do: per
particle three Gaussian speeds, then getRandomX(1), getRandomY() + y,
getRandomZ(1). Burst holds the counts, speeds and heights vanilla uses,
and entity_event_burst maps entity events to them. The client side uses
the particle engine's random, so no server draw moves.

- villager hurt by a player, failed breeding (13), breeding hearts and
  the child's (12), trades, job sites and beds (14): five particles about
  the villager, not one 1.8 above its feet.
- wolf, cat, parrot, nautilus and horse taming (7/6): seven hearts or
  smoke about the body.
- allay duplication (18): three hearts about the allay.
- llama growth from food: one particle at rest about the body.
- animals in love: the heart moves at the Gaussian speeds already drawn,
  exactly where vanilla puts it; forced growth's particle sits where it
  was placed (sendParticles with no spread) instead of being jittered.

New harness tools/test_entity_particles_parity.py: the real client
handleEntityEvent of Villager, Animal, TamableAnimal, AbstractHorse,
Ocelot, Dolphin and Allay against entity_event_burst and around_self.

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:13:38 +02:00
Ludovic Marie 27fc327195 Merge pull request #90 from ludovic111/fix/f3-combos-73
F3 key combinations, as 26.3's KeyboardHandler runs them
2026-10-14 16:12:40 +02:00
Ludovic Marie ada71df410 Merge pull request #89 from ludovic111/fix/particles-63-providers
Particles: vanilla random streams, and fixes to 20 providers (part of #63)
2026-10-14 16:08:48 +02:00
Cosmic 2236c9607f Apply burning-time scaling to Fire Aspect ignition too 2026-10-14 10:08:37 -04:00
Ludovic MarieandClaude Opus 5.5 70fad91ee5 F3 key combinations, as 26.3's KeyboardHandler runs them
F3 only toggled the debug overlay. 26.3 makes the debug keys rebindable
mappings (key.debug.*): the modifier (F3) held with another key runs
handleDebugKeys, released alone toggles the overlay unless it was used as
a modifier; F3+Escape pauses without the menu; F3+C held ten seconds
crashes the game.

- debug_keys: KeyboardHandler.keyPress's state (usedDebugKeyAsModifier,
  the modifier's down state, debugCrashKeyTime) and tick, with the actions
  in handleDebugKeys' order; unit tests replay key sequences.
- gizmos: 26.3's gizmo lines (colour, width in pixels, near-plane clip as
  GizmoFeatureRenderer.buildLines), ChunkBorderRenderer's geometry and
  EntityHitboxDebugRenderer's boxes, eye lines and view arrows; a coloured
  line pipeline draws them, depth tested or always on top.
- game/debug_keys: the actions. F3+A rebuilds every section, B hitboxes,
  C copies the location (/execute in ... run tp @s, %.2f half up), D
  clears chat, G chunk borders, H advanced tooltips (options.txt), I copies
  /setblock or /summon with data (Shift: client side), N toggles spectator
  and the previous mode with /gamemode's feedback, P pause on lost focus,
  T reloads resource packs, V dumps the version, X improved transparency;
  F4 without permission says debug.gamemodes.error. Messages carry the bold
  [Debug]: prefix in yellow (red for the crash countdown).
- PauseScreen(false): only "Game Paused" at the top; the
  pauseOnLostFocus option now decides whether losing focus pauses.
- The clipboard through arboard; `horizon-ctl status` reports the debug
  state and the text last copied.

Fixes #73

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:08:26 +02:00
Cosmic f3dda9c412 Address PR59 review: axolotl timers, fluid and fire parity, persistence and Rust tests 2026-10-14 10:05:41 -04:00
Ludovic Marie f881917d91 Merge pull request #88 from ludovic111/fix/powdered-snow-62
Powder snow: sinking, freezing, leather boots, buckets and falling blocks
2026-10-14 15:57:54 +02:00
Ludovic MarieandClaude Opus 5.5 d9461c6d2d Particles: build each one with vanilla's random streams, fix the providers that differed
Every vanilla Particle owns a RandomSource.create() of its own; providers
draw from ParticleEngine.random, a few from ClientLevel.random, and spells
from SpellParticle.RANDOM. Horizon drew everything from one generator, so
draws landed on the wrong stream. Particles now carry their own random,
and provider, level and spell draws use theirs, as in vanilla.

Fixed against the real providers:
- gust / small_gust: SingleQuadParticle(x, y, z) constructor, so the
  12..15 tick lifetime comes out of the right draws.
- explosion_emitter, gust emitters: NoRenderParticle draws no quad size.
- effect, instant_effect, entity_effect, witch, infested: x/z motion from
  SpellParticle.RANDOM; the witch's brightness from the provider.
- landing_honey, landing_obsidian_tear, falling_nectar, spore_blossom_air,
  crimson_spore, warped_spore, white_ash, sculk_charge(_pop), dolphin:
  provider draws (lifetimes, motion, alpha) from the provider random, after
  or before the sprite as the provider orders them; crimson spore motion
  scaled by the float constants; composter lifetime from the level random.
- enchanted_hit: MagicProvider's 0.3/0.8 tint after the constructor tick;
  damage_indicator lives 20 ticks (DamageIndicatorProvider).
- note: colour from Mth.sin's table.
- shriek: one random sprite, not animated.
- trail: Particle(x, y, z, xa, ya, za) constructor, ARGB.scaleRGB on the
  8-bit channels, Mth.lerp towards the target including the last tick.
- firework sparks: full bright (SimpleAnimatedParticle.getLightCoords).

New parity harness tools/test_particle_providers_parity.py: the real
ParticleResources providers build every type Horizon implements in a
stand-in ClientLevel (air, optionally a stone floor); the oracle recovers
each particle's clock-seeded random and the probe replays it; state,
requested particles and sounds are compared tick by tick.

Part of #63

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:53:34 +02:00
Ludovic MarieandClaude Opus 5.5 6ce83021b5 Powder snow: sinking, freezing, leather boots, buckets and falling blocks
Powder snow was inert: the player's collision context never carried its
fall distance or boots, block-inside effects never ran for the player,
frozen ticks stayed 0 and the empty bucket only took fluids.

Player (PlayerMotion, LivingEntity.aiStep order):
- PowderSnowBlock.getCollisionShape gets the player's fall distance and
  leather boots: it sinks through, walks on top in leather boots (sinks
  when crouching), lands on the 0.9-high shape after a fall of more
  than 2.5 blocks.
- Entity.applyEffectsFromBlocks over this tick's movement path (the
  ported block sweep, StepBasedCollector steps): entityInside makes it
  stuck (0.9, 1.5, 0.9) while its feet are in powder snow and sends the
  server's snowflake from the level random; FREEZE sets isInPowderSnow
  and adds a frozen tick per step; EXTINGUISH destroys the snow if the
  player burns and puts the fire out. ServerPlayer.onInsideBlock fires
  enter_block for every block entered (which also unlocks the boat
  recipes in water, and the end gateway advancement).
- baseTick's wasInPowderSnow lets leather boots climb out (living_air).
- Server freezing: thawing 2 a tick, the powder_snow frost modifier
  (-0.05 x percent frozen, not over air), freeze damage every 40 ticks
  when fully frozen; leather armor (#freeze_immune_wearables) and
  spectators don't freeze.
- HUD: the powder snow outline overlay and frozen hearts.

Items and blocks:
- BucketItem.use on powder snow: pickupBlock (2001), used statistic,
  fill sound, FLUID_PICKUP, createFilledResult, filled_bucket trigger.
- SolidBucketItem: the empty_powder_snow place sound and an empty bucket
  back in survival.
- Falling blocks land on powder snow, or end up inside it and break
  after a fall of more than 2.5 blocks.

test_native_scene_motion gains powder snow fixtures ("snow_tick": the
oracle runs vanilla's real applyEffectsFromBlocks and freezing) and
prints frozen ticks, the snow flags and the stuck multiplier.

Fixes #62

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:45:55 +02:00
Ludovic Marie 6f2823b652 Merge pull request #87 from ludovic111/fix/physical-shift-77
Read the physical Shift keys in container screens and drop a stack clicked outside
2026-10-14 15:44:49 +02:00
Ludovic Marie 4e36b2a217 Merge pull request #85 from ludovic111/fix/resource-packs-72
Resource packs: reload everything in game and merge stacked files like vanilla (fixes #72)
2026-10-14 15:44:44 +02:00
Ludovic Marie 4ce9a5642d Merge pull request #86 from ludovic111/fix/wolf-advancements-65
Match the variant components of entity predicates (taming grants only Best Friends Forever)
2026-10-14 15:42:26 +02:00
Ludovic Marie 2b5313b943 Merge pull request #81 from ludovic111/fix/feeding-sounds-68
Feeding sounds: each animal's own eating sound, or none, as in vanilla
2026-10-14 15:42:21 +02:00
Ludovic Marie 6ca6d0b919 Merge pull request #83 from ludovic111/fix/hand-sway-66
Hand sway: the first-person hands lag behind the view, as in vanilla
2026-10-14 15:42:12 +02:00
Ludovic Marie a56d63d03f Merge pull request #82 from ludovic111/fix/gamerules-persist-74
Game rules: save them with the world once, as they are now; save on Quit
2026-10-14 15:42:01 +02:00
Ludovic Marie 8dd63c69ef Merge pull request #84 from ludovic111/fix/fire-charge-pitch
Give the fire charge's use sound its own pitch
2026-10-14 15:41:51 +02:00
Ludovic MarieandClaude Opus 5.5 0b362e270f Match the variant components of entity predicates
Taming any wolf also granted The Whole Pack and A Complete Catalogue: entity
predicates treated minecraft:components as always true, so every
tame_animal criterion of those advancements (one per wolf or cat variant)
matched the first animal tamed. EntityPredicate tests them with
DataComponentExactPredicate against what the entity reports through
Entity.get (Wolf.get(WOLF_VARIANT) and so on): mobs now report their variant
component (wolf, cat, frog, pig, cow, chicken, zombie nautilus) and the
predicate compares it, an absent component never matching.

Checked against the real EntityPredicate of every criterion of the variant
advancements on real entities of every variant by
tools/test_entity_components_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:33:50 +02:00
Ludovic MarieandClaude Opus 5.5 0d0378916b Resource packs: reload everything in game and merge stacked files like vanilla (#72)
Choosing packs in the Resource Packs screen only reloaded the GUI atlas
(and only when it kept its size), item models and languages; block
textures and models changed after a restart, and sounds, clouds and the
world border never did. Block textures were also squeezed to 16x16, and
the pack overlay took every file from the top pack, so a pack's own
en_us.json, sounds.json, font or atlas definition replaced vanilla's
instead of adding to it (all other text, or every vanilla sound, gone).

- Minecraft.reloadResourcePacks: rebuild block sprites and models, mesh
  tables, the shared atlas, item models, sounds, languages, GUI
  metadata, clouds.png and forcefield.png from the new overlay; swap the
  mesher's context (workers read it per job), mesh every loaded section
  again (LevelRenderer.allChanged) and build a renderer with the new
  textures. Nothing switches if loading fails.
- The block sprite array takes the largest texture's resolution (power
  of two, at most 256 MiB), so 32x/64x packs keep their detail;
  animations upload at that size.
- Files vanilla reads as resource stacks are merged in the overlay:
  ClientLanguage (keys override), SoundManager (sounds add up unless an
  event replaces; a sounds.json Gson rejects is skipped whole),
  FontManager (top pack's providers first), SpriteSourceList (sources
  bottom up). Languages are read from every namespace, not only
  minecraft.
- Missing or broken block textures use vanilla's missing texture; packs
  that still break startup fall back to the default resources
  (Minecraft.rollbackResourcePacks).
- Open Pack Folder opens the folder on macOS and Windows too.

tools/test_resource_pack_parity.py checks pack discovery, overlays,
filters, which pack provides a file and the merged languages, sounds,
fonts and atlases against vanilla's FolderRepositorySource,
MultiPackResourceManager and reload listeners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:33:34 +02:00
Ludovic MarieandClaude Opus 5.5 d75ad778e3 Read the physical Shift keys in container screens and drop a stack clicked outside
Quick move, the recipe book's place-all, its search box and the hold-Shift
tooltips read self.keys, which holds remapped and toggled keys: with toggle
sneak, a Left Shift press behind a screen is dropped by the sneak mapping and
sync_toggle then removes ShiftLeft. Vanilla reads the event's own modifiers
(InputWithModifiers.hasShiftDown) or Screen.hasShiftDown: the physical keys.
Track them in physical_keys and read them through has_shift_down.

Clicking outside the window did nothing. AbstractContainerScreen.mouseClicked
sends slot -999 when hasClickedOutside (AbstractRecipeBookScreen's rule when
the screen has a book): a held stack starts a quick craft drag whose release
sends PICKUP -999, which AbstractContainerMenu.doClick turns into a drop of
the stack (left) or one item (right), thrown from the hand with a swing. The
mouse handling is now horizon_client::container_screen, checked against the
real screens by tools/test_container_screen_parity.py; servers get the same
ContainerClick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:31:49 +02:00
Ludovic MarieandClaude Opus 5.5 84adffc3f2 Hand sway: the first-person hands lag behind the view, as in vanilla
Vanilla keeps xBob and yBob on the LocalPlayer: applyInput eases them
halfway to the player's rotation every tick, and
FirstPersonHandsAndItemsRenderer turns both hands by
(viewXRot - xBob) * 0.1 degrees about X, then (viewYRot - yBob) * 0.1 about
Y, before drawing either (FirstPersonHandsAndItems.extractRenderState
interpolates the bobs). Horizon drew the hands fixed to the view.

- first_person::HandSway: the bobs, applyInput's tick and the rotation.
- The game ticks it with the hands and resets it where vanilla makes a new
  LocalPlayer (joining, respawning, changing dimension, leaving the End);
  hand_geometry turns the arm, held items and maps by it.

The first-person harness's steps can turn the player: the oracle sets the
LocalPlayer's rotation and calls the real applyInput, and both sides print
xBob, yBob, xBobO and yBobO every tick (turning scenarios plus random turns
in the random cases).

Fixes #66

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:30:30 +02:00
Ludovic MarieandClaude Opus 5.5 162e3d36f7 Give the fire charge's use sound its own pitch
Placing fire with a fire charge played item.firecharge.use at the flint and
steel's pitch (nextFloat * 0.4 + 0.8, one draw). FireChargeItem.playSound
uses (nextFloat - nextFloat) * 0.2 + 1.0, two draws from the level random.
The pitch is now horizon_client::items::fire_starter_pitch, checked against
the real FireChargeItem.playSound by tools/test_fire_starter_sound_parity.py.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:27:13 +02:00
Ludovic MarieandClaude Opus 5.5 9bbb674184 Game rules: save them with the world once, as they are now; save on Quit
Game rules lived twice: Level (loaded when the world opened, written to
data/minecraft/game_rules.dat by Level::write) and the rules the game and
/gamerule use. Every save wrote the file first with the rules loaded at
open, then again with the current ones; a failed second write (on Windows a
file just renamed into place can be held briefly by the indexer or an
antivirus) was only logged, leaving the old rules on disk, so the world
opened with them again.

- sync_level copies the current rules into Level, and Level::write is the
  only writer of game_rules.dat (written as vanilla writes saved data:
  `_new`, then renamed over the old file).
- The world is saved when the application ends without a window close
  (ApplicationHandler::exiting): on macOS, Quit (Cmd+Q) ends the
  application through applicationWillTerminate, while vanilla's GLFW window
  turns it into a window close and Minecraft.stop saves the world. Window
  close, `horizon-ctl quit` and exiting share save_for_exit, which saves
  once.

Tests: a unit test writes changed rules through Level::write and reads them
back (GameRules::load and Level::read); the save round-trip harness has
Horizon's player change a rule with /gamerule before saving and requires the
new value in the world vanilla then loads.

Fixes #74

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:16:32 +02:00
Ludovic MarieandClaude Opus 5.5 f1cc118df0 Feeding sounds: each animal's own eating sound, or none, as in vanilla
Animal.mobInteract plays playEatingSound(), which is empty in Animal and
overridden only by armadillos, cats, foxes, frogs, goats, pigs, sniffers and
sulfur cubes (plus nautili). Horizon fell back to entity.generic.eat for every
other animal (cows, sheep, chickens, rabbits, turtles, bees...), with a level
random pitch instead of each class's volume and pitch.

- mobs::ai::eating_sound: the overrides with their exact volume and pitch
  (makeSound with the voice pitch, playSound(1, 1), or Level.playSound with
  Mth.randomBetween(0.8, 1.2)); silent otherwise.
- mobs::ai::feed_animal: Animal.mobInteract's food part, shared by the game
  and the mob AI probe; Strider.mobInteract adds entity.strider.eat.
- AgeableMob.ageUp(seconds, true): forcedAge and the forced-age particle
  timer, whose positions draw from the mob's random on the server.
- Tame wolves and cats healed with food: TamableAnimal.feed (wolves silent).
- Parrots: Parrot.isFood is false; wild ones take parrot food with
  entity.parrot.eat and a one-in-ten taming chance.
- Baby pigs and chickens use their sound variant's baby_sounds.

The mob AI harness gains a `feed TICK MOB ITEM` event (the oracle calls the
real Animal.mobInteract) and feeding scenarios for adult and baby cows, pigs,
sheep and chickens.

Fixes #68

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:15:15 +02:00
Ludovic Marie 66ce394d2d Merge pull request #80 from ludovic111/fix/composters-70
Composters take compostable items by hand, from hoppers and from droppers
2026-10-14 15:09:21 +02:00
Ludovic Marie b4c63fa207 Merge pull request #78 from ludovic111/fix/rain-length-71
Rain and snow: tile the weather texture like WeatherEffectRenderer (fixes #71)
2026-10-14 15:09:11 +02:00
Ludovic Marie 97d3282161 Merge pull request #79 from ludovic111/fix/cloud-distance-67
Clouds: draw to the Cloud Distance option, not the render distance (fixes #67)
2026-10-14 15:09:07 +02:00
Ludovic Marie 115b0b856b Merge pull request #76 from ludovic111/fix/furnace-progress-69
Furnaces keep their cooking progress across menu clicks
2026-10-14 15:09:03 +02:00
Ludovic MarieandClaude Opus 5.5 0d3628a035 Composters take compostable items by hand, from hoppers and from droppers
Right-clicking a composter with wheat (or any compostable item) did
nothing: ComposterBlock.useItemOn wasn't implemented, only emptying a
full composter. Hoppers and droppers didn't see the composter as a
container either, and a hopper under one sucked items through it.

- useItemOn: below level 7 a compostable item adds a layer with the
  chance of its compostable component's provider (evaluated in the
  block-interaction loot context, drawing from the level random), then
  level event 1500, the used statistic and one item consumed (none in
  creative); level 7 still consumes the action; a full composter falls
  through to useWithoutItem and empties.
- Level event 1500 (handleFill): composter.fill(_success) and ten
  composter particles above the compost.
- getContainer: the input container takes one compostable item from
  above (hoppers, droppers, crafters), the output container gives a
  hopper below the bone meal of a full composter, emptying it twice as
  vanilla's setChanged calls do (and losing it when the hopper is full);
  levels 7 and other faces refuse.

tools/test_composter_parity.py runs right-clicks, hoppers above and
below, and droppers into composters at every level through
VanillaDispenserOracle (new "use" and "hopper" actions) and the game's
dispense fixture.

Fixes #70

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 15:00:41 +02:00
Ludovic MarieandClaude Opus 5.5 12eca8a5c1 Clouds: draw to the Cloud Distance option, not the render distance (#67)
The cloud mesh radius came from the render distance, the far plane was
capped at max(4 x render distance, 1024) and the cloud fog end was a
constant 2048, so lowering the render distance cut the clouds short.
Vanilla ties all three to options.cloudRange: CloudRenderer.prepare
builds ceil(range * 16 / 12) cells, Camera.depthFar is
max(4 x render distance, range * 16) (also the hand projection, the
frustum and the world border), and AtmosphericFogEnvironment ends
cloud fog at min(range * 16, visual/cloud_fog_end_distance).

The cloud render harness now checks the radius for Cloud Distance
2..128 against the shipped mesh builder, and the environment harness
covers visual/cloud_fog_end_distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:38:52 +02:00
Ludovic MarieandClaude Opus 5.5 8c0586db4b Furnaces keep their cooking progress across menu clicks
A click in the furnace menu reset the cooking timer whatever it did.
AbstractFurnaceBlockEntity.setItem restarts cooking only when the input
slot gets something other than the same item with the same components,
and menu clicks that take part of the input or merge into it never call
it. Furnace::input_clicked applies that after each click, with the new
input's total cook time.

test_menu_click_parity gains furnace_be, blast_furnace_be and smoker_be:
the server's furnace menus over real block entities, comparing the
cooking data slots after every click.

Fixes #69

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:34:32 +02:00
Ludovic MarieandClaude Opus 5.5 b723ef9828 Rain and snow: tile the weather texture like WeatherEffectRenderer (#71)
Horizon stretched a quarter of rain.png over each whole column, up to
20 blocks tall, so streaks were about five times too long. Vanilla
advances v by a quarter per block and lets the sampler repeat, so the
texture repeats every 4 blocks. Port createRainColumnInstance,
createSnowColumnInstance and prepareInstances exactly (seeded column
speed, game-time scroll, snow drift and brightening, camera-block size
table, square radius from the Weather Radius option, alpha falloff,
precipitation at the camera's y), and split each quad at the texture
seams so the atlas sprite repeats.

tools/test_weather_render_parity.py compares the columns and quads
with the shipped renderer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:34:26 +02:00
Cosmic 726f88fcfc Fix axolotl AI, fluid and fire integration, and world-local progress
Bind resident environment kernels and real fire clocks, fix sprite uploads and flame material, restore swim speed, throttle item lava audio, and emit fire extinguish events. Reset world-entry progress state without deleting saved data. Exact axolotl replay parity remains deferred to issue #64.
2026-10-14 02:20:13 -04:00
Cosmic 778890207a Merge remote-tracking branch 'origin/main' into fix/vanilla-item-use-animations 2026-10-13 22:41:21 -04:00
Cosmic 5903b5981d Fix item-use feedback, ranged poses, arrows and elytra rendering 2026-10-13 22:41:21 -04:00
Ludovic Marie 6c1000a374 Merge pull request #58 from ludovic111/remove-roadmap
Remove the roadmap
2026-10-14 02:24:25 +02:00
Ludovic MarieandClaude Sonnet 5.5 71cee5279c Remove the roadmap from the README, the website and the launcher
The README keeps the parity table, and its known differences move to a
Known issues section. The website drops the What's next section and its
nav links, and no longer fetches or requires a Roadmap section. The
launcher drops its What's next page, `next` subcommand and `roadmap`
MCP tool.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 02:18:29 +02:00
Ludovic Marie d1cf9b4930 Merge pull request #57 from ludovic111/website/contributors
Website: add a Contributors block to the community section
2026-10-14 02:06:21 +02:00
Ludovic MarieandClaude Sonnet 5.5 2b7c72255c Website: add a Contributors block to the community section
Lists the GitHub contributors of ludovic111/horizon (bots left out, the
owner first) with avatar, login, commit count and a link to the profile.
Built in by build.mjs like the releases (the build still succeeds when
GitHub does not answer, and the block stays hidden until the page loads
it) and refreshed by the page from the API. Adds a nav and menu entry,
lets avatars.githubusercontent.com through img-src, and updates the
README, LEGAL.md and the privacy note.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 02:01:43 +02:00
Ludovic Marie e5c061dba6 Merge branch 'pr-54' into merge-54 2026-10-14 01:07:56 +02:00
Ludovic Marie 3879ca8ec7 Merge remote-tracking branch 'origin/worldgen-trees' into merge-55 2026-10-14 00:51:14 +02:00
Ludovic MarieandClaude Opus 5.5 069acd9aba Streaming: wait only for lower-ranked chunks that can be decorated
A chunk in the decoration range whose diagonal neighbour falls outside the
generation range never gets its 3x3 protos, so it must not hold back the
higher-ranked chunks within two of it. Render distances 2, 5 and 12 load and
keep loading while flying without stalling.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:19:43 +02:00
Cosmic 1499c2a888 Fix GUI item lighting, preview seams and glyph bleeding 2026-10-13 18:16:09 -04:00
Ludovic MarieandClaude Opus 5.5 2c57fcc09f decorated_region: compare fully decorated regions decorated in vanilla's order
A new harness decorates a 13x13-chunk area around each site, on both sides,
in the order vanilla's chunk scheduler uses around a player standing there
(chunk_dump HORIZON_LIST_ORDER prints decoration_order::vanilla_order), and
compares every block state, quart biome, block entity and scheduled tick:
the two reported spots on seed 1891623539983467248 (taiga trees at
204 -239; a stronghold with sculk and a geode at 4870 401), eleven tree-heavy
biomes and the nearest mineshaft, village, ancient city and trial chambers of
two seeds. 21 sites, 3,549 chunks, 0 differences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:09:59 +02:00
Ludovic MarieandClaude Opus 5.5 317da28481 Structure pieces schedule the fluid ticks of the fluids they place
StructurePiece.placeBlock schedules a tick for the fluid of every block it
sets (the lava of a stronghold's portal room, a fortress's lava wells, water
in a monument or a mineshaft); in a WorldGenRegion it lands in the proto
chunk and runs once the chunk is live. The port treated it as a no-op, so
those fluids sat still until a neighbour changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 00:04:56 +02:00
Ludovic MarieandClaude Opus 5.5 147ea6661f Decorate chunks around the player in vanilla's order
Biome decoration is order dependent: a chunk's features write into its
neighbours, so a tree, ore blob or sculk patch placed first changes where the
next chunk's features fit and how many random draws they take. Horizon's
streamer decorated whichever chunk had its 3x3 protos ready, nearest first but
spread over non-overlapping regions for parallelism; around a teleported
player that swapped 45% of the overlapping chunk pairs against vanilla (whose
own runs swap 6%), and grew visibly different forests (seed
1891623539983467248 at 204 108 -239), ore veins, sculk and geodes.

worldgen::decoration_order replays vanilla's chunk scheduler for a standing
player: loading tickets ring by ring in fastutil hash order, four in flight,
FULL tasks decorating their 3x3 squares x-outer by level, INITIALIZE_LIGHT
tasks themselves, a ticket released once its 5x5 square is full. The streamer
ranks chunks with it whenever the player's chunk or the render distance
changes and decorates a chunk only when no lower-ranked chunk within two of it
is still waiting, so chunks further apart still decorate in parallel. Around
that spot the decorated blocks that differ from a vanilla server fall from
about 9000 to about 1000 (two vanilla runs differ by about 200).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:55:01 +02:00
Ludovic Marie 19ec3213fd Merge remote-tracking branch 'origin/review-52' into merge-52 2026-10-13 23:38:55 +02:00
Ludovic MarieandClaude Opus 5.5 4c4e579d3f Generated chunks keep the ticks their features scheduled
VanillaTerrain::finish dropped the proto chunk's block and fluid ticks; vanilla's
LevelChunk takes them over from the ProtoChunk (unpackBlockTicks), so the leaf
re-measurements and the fluid ticks of lakes and springs run once the chunk is
live. They now travel in the chunk's ChunkExtras, like a saved chunk's ticks,
and register with the level's scheduler on arrival; block ticks are translated
from worldgen's default state ids to the game's block indices.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:38:02 +02:00
Ludovic MarieandClaude Opus 5.5 2c8de2b015 chunk_generation: compare scheduled ticks; HORIZON_ORDER decoration order
Both dumps list each proto chunk's scheduled block and fluid ticks in
scheduling order (`t block|fluid NAME X Y Z PRIORITY`) and the harness
compares them: they run once the chunk is live, and the leaf ticks of
updateShapeAtEdge were missing without any block differing at FEATURES.

HORIZON_ORDER names a file of `x z` lines both sides decorate in, instead of
row-major: decoration is order dependent (a chunk's features write into its
neighbours), and vanilla's chunk system decorates around a player in its own
order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:38:02 +02:00
Ludovic MarieandClaude Opus 5.5 897ca2aa5e Worldgen shape updates schedule their ticks in the proto chunks
StructureTemplate.updateShapeAtEdge (every tree, structure pieces) and
Block.updateFromNeighbourShapes run updateShape on a WorldGenRegion, where
LeavesBlock.updateShape schedules a one-tick block tick to re-measure its
distance and waterlogged blocks schedule fluid ticks: vanilla keeps them in
ProtoChunkTicks and runs them once the chunk is live. Horizon ran updateShape
against a throwaway tick scheduler and lost them, so leaves where trees
overlap kept the distance their own tree gave them (one more than vanilla's
for thousands of leaves in a taiga), and could decay where vanilla's don't.

level::Ticks gains a capture mode; the decoration world schedules what a
shape update captured, in order, before the update's own setBlock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:38:02 +02:00
Ludovic Marie 8ad1e52067 Merge remote-tracking branch 'origin/trading' into merge-50-53 2026-10-13 23:30:15 +02:00
Ludovic Marie 373fa31a35 Merge remote-tracking branch 'origin/agent-control' into merge-50-53 2026-10-13 23:30:15 +02:00
Ludovic MarieandClaude Opus 5.5 0944280fb3 Merge PR #52 (entity directional lighting) into review-52
Conflicts with #51: entity.wgsl keeps the crumbling sprite UVs and
samples with the per-face colour; gfx.rs keeps crumbling_sprite and
outline_params and puts the Nether flag in time.y; game.rs keeps #51's
breaking and cloud geometry. #51's new vertices (breaking decals,
cloud faces, the batching test) get the new zero normal, so they stay
unshaded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:18:40 +02:00
Ludovic Marie a72dd98e2a Merge pull request #51 from ludovic111/review-cosmic
Cosmic's camera, breaking, clouds and outline fixes (#42 + #47), integrated
2026-10-13 23:14:30 +02:00
Cosmic 7b21d4bc6c fix(client): restore directional lighting for mob models 2026-10-13 17:08:34 -04:00
Ludovic MarieandClaude Opus 5.5 c810a3213c Wandering traders trade again: only villagers refuse while asleep
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:07:02 +02:00
Ludovic Marie 193c08e90f Merge remote-tracking branch 'origin/main' into review-cosmic 2026-10-13 23:06:15 +02:00
Ludovic MarieandClaude Opus 5.5 393d113dfb Merchant screen: the restock tooltip over an out-of-stock result
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:02:59 +02:00
Ludovic MarieandClaude Opus 5.5 92209ddeee Trading on the dedicated server, compared with vanilla's
test_dedicated_server_parity summons a librarian beside the spawn and the
vanilla protocol client right-clicks it: the OpenScreen packet (title as
Entity.getDisplayName, UUIDs left out) and the MerchantOffers packet must
match vanilla's server. Villagers keep Xp, LastRestock and RestocksToday
before their offers are made (vanilla's null offers).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 23:01:02 +02:00
Ludovic MarieandClaude Opus 5.5 5cb14ee496 Trade over the network: LAN guests and Horizon as a client
The host hands a trading screen a guest's interaction opens to that guest:
OpenScreen (minecraft:merchant), MerchantOffers when the offers change,
the select trade packet, container clicks run on the merchant menu engine,
the TRADE reputation event and prices for the guest's UUID, the payment back
on close. As a client, Horizon shows a server's trading window from its
MerchantOffers and sends select trade and clicks. Stored enchantments, dyed
colours and map ids now travel in item stacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:44:16 +02:00
Ludovic Marie 5a5e8ffa71 Merge pull request #49 from ludovic111/creative
Creative inventory: every tab and the search as vanilla builds them; scrollbar drag
2026-10-13 22:41:39 +02:00
Ludovic MarieandClaude Opus 5.5 d17e5da848 Merge origin/main into review-cosmic
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:32:35 +02:00
Ludovic MarieandClaude Opus 5.5 7fab5ec1ff test_creative_parity: no Rust scope (creative.rs reaches item_data and save)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:30:39 +02:00
Ludovic MarieandClaude Opus 5.5 43cff3b44e Trade in game through the verified merchant menu
The trading screen runs MerchantMenu on the menu engine with the villager's
merchant::Session: level-ups add offers while it is open, prices follow the
TRADE reputation event a tick later, orbs drop at the villager with its own
random, the yes/no voice and regeneration as vanilla; special prices stay
after closing. Offers use the world's RandomSequences (now saved as
random_sequences.dat), the villager's position, its namespaced type (desert,
snow... trades) and exploration maps found through /locate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:30:33 +02:00
Ludovic MarieandClaude Opus 5.5 2e7e249096 Merge #47 (selection outline width and depth) into review-cosmic
Resolve the gfx.rs conflict with #42 and keep the shared Globals uniform
consistent: Rust, terrain.wgsl and entity.wgsl now all end with
time, crumbling_sprite, outline_params. The automatic merge left
terrain.wgsl reading outline_params at crumbling_sprite's offset. A test
compares the three declarations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:29:31 +02:00
Ludovic Marie 59ce240633 Merge pull request #48 from ludovic111/sound-menus
Fix the looping grass-step sound; classic menus lead back to Horizon's
2026-10-13 22:28:47 +02:00
Ludovic MarieandClaude Opus 5.5 502c8b9311 agent_control_e2e: pick up a drop from within a block (it can land under the trunk)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:28:14 +02:00
Ludovic Marie 2ee7d7af9f Merge branch 'pr-42' into review-cosmic 2026-10-13 22:26:55 +02:00
Ludovic MarieandClaude Opus 5.5 a3dd34a8f3 Trading parity harness: offers, prices, restocks, the merchant menu and NBT against vanilla
VanillaTradingOracle runs villagers, wandering traders and MerchantMenu on a
real flat server; trading_fixture runs horizon_client::merchant and a new
merchant kind of the menu engine (MerchantContainer, MerchantResultSlot,
tryMoveItems, quick move) over a merchant::Session. Generation now covers
random dyes, random potions, stew effects, exploration maps (a MapMaker hook),
sum/binomial providers and the trade's max uses / xp / discount providers;
getRecipeFor honours the selection hint as vanilla, Offers leave defaults out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:25:27 +02:00
Ludovic MarieandClaude Opus 5.5 299280baa2 Classic menus: Menu Style on the Video Settings page leads back to Horizon's
Choosing Classic in Options > Interface left no way back. The classic
Video Settings page, which already starts with Horizon's graphics row,
gets a Horizon-only "Menu Style: Classic" button below it: it switches to
Horizon's menus, saves horizon.txt and opens Options > Interface. The
classic main Options screen keeps vanilla's exact layout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:24:57 +02:00
Ludovic MarieandClaude Opus 5.5 60fc617402 Sound: player footsteps once per metre walked, not every tick
The client's MotionWorld.apply_movement_emission pushed a footstep event
on every Entity.move, and the game played the block's step sound for
each. Gravity moves a grounded player every tick, so standing on grass
played block.grass.step 20 times a second, overlapping into an endless
rustle (19 instances alive at once). The audio stream had been muted by a
saved WirePlumber setting until v2026.10.13.1, which hid it.

Route the player through horizon_world::movement_emission (vanilla's
applyMovementEmissionAndPlaySound: moveDist, nextStep, the supporting
block checks) with Player.getMovementEmission (nothing while flying or
sneaking on the ground). The status sound census now lists the events
holding channels, which is how the loop was found.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:24:57 +02:00
Ludovic MarieandClaude Opus 5.5 cac89a9995 Creative inventory: every tab and the search as vanilla builds them; scrollbar drag
The creative-tabs.json export failed to build the functional blocks tab (no
data-pack registries: banner patterns), and its fallback read decompiled
sources from a path that no longer exists, so the tab was empty and anvils,
crafting and smithing tables were missing from the tabs and the search. The
search also matched only an approximate display name.

VanillaCreativeTabsExporter now builds every tab with vanilla's own
CreativeModeTabs.tryRebuildTabContents over the vanilla data pack's
registries and writes creative-inventory.json: stacks with components, and
the search tab's creative tooltip lines and tags. The client searches them
as SessionSearchTrees does (FullTextSearchTree, IdSearchTree after '#').
Changing tab empties the search box, as in selectTab.

The scrollbar only jumped to the pressed point; holding it now scrolls as
the mouse moves, anywhere, until release (mouseClicked/mouseDragged), and
the wheel and the scroller follow ItemPickerMenu.

tools/test_creative_parity.py compares every tab and 6,793 searches with
the vanilla client's own search trees. horizon-ctl `type` also fills the
creative search box.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:24:55 +02:00
Cosmic 5ea60fbd8d fix(client): match vanilla block selection outline width and depth
Hardware LineList forced one-pixel strokes, while the guessed 0.002 endpoint growth did not separate the edges from block surfaces. Render screen-space strips with vanilla framebuffer width scaling and perspective/view depth layering, leaving the shape edges unchanged. Apply normal outline fog and add three regression tests.

Offline gate passed on this main-based branch. Actual vanilla GLSL vs Horizon WGSL GPU comparison covers 3,072 vertices; maximum projected difference is 0.0036621 pixels. No code from PR #42 is included.
2026-10-13 16:04:10 -04:00
Ludovic MarieandClaude Opus 5.5 c0f468a871 horizon-ctl: horizon-ctl - returns the last failure's status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 22:00:44 +02:00
Ludovic MarieandClaude Opus 5.5 6716b0d953 Docs: agent control with horizon-ctl and its MCP server
README gets an Agent control section (out of the roadmap: it's done), the
native client guide describes every command group, the blocking semantics,
exit codes, launch discovery, MCP and the two transports, and PLAY.txt tells
release players about horizon-ctl.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:59:35 +02:00
Ludovic MarieandClaude Opus 5.5 601fbb533a Ship horizon-ctl in the release folders
release.yml builds horizon-ctl beside the client (checked for system-only
imports like it), packaging/package.sh copies it from beside the client binary
and refuses to package without it (the macOS folders made by hand go through
the same script), and package_native_client.py adds it to private archives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:59:34 +02:00
Ludovic MarieandClaude Opus 5.5 772e0a7070 tools/agent_control_e2e.py: play a survival start through the CLI and MCP
On a virtual screen: create a world through the title menus, walk to a tree,
break a log, craft planks in the inventory screen, place one and read it
back, once with horizon-ctl commands and once over horizon-ctl mcp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic MarieandClaude Opus 5.5 24a70e84a8 horizon-ctl: help for every command, launch from the launcher, MCP server
One command table drives horizon-ctl help, COMMAND --help (with examples)
and the MCP tools. Replies stay one JSON line; errors are error objects with
exit status 1 (game), 2 (usage) or 3 (unreachable). script runs a file or
stdin with shell-like quoting. launch finds the game beside horizon-ctl or
the newest launcher install (--version, --game), with its prepared resources
and the game home, and --wait waits for the world. horizon-ctl mcp serves
MCP over stdio in the launcher's style, one tool per command, with the
screenshot returned as an image. Existing commands and replies are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic MarieandClaude Opus 5.5 7bb3347edd Agent control: observe and act from the control socket
game/agent.rs adds commands an agent can play with: state, looking-at,
entities, block, blocks, find-block, inventory, screen and messages read the
game as JSON; move, jump, goto (A* over standable blocks), look-at, attack,
break, place, interact, use-item, select, drop, open-inventory, click-button,
click-at, command, respawn, pause, wait and stop act like a player. Actions
that take time run as a task advanced every frame and reply when done, with
what happened (break reports drops and pickups, place the block placed).

Buttons are clicked by label: the classic and Horizon button drawers record
what they draw each frame (widgets.rs), and a click is a synthesized pointer
move and press, so it behaves exactly like the mouse on every screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic MarieandClaude Opus 5.5 2df60cd388 Control socket: a token-guarded localhost TCP transport beside the Unix socket
Windows has no Unix domain sockets, so the control endpoint there is a random
127.0.0.1 port described by a JSON file (port and a 128-bit token, readable by
the user only); a client's first line is auth TOKEN. Linux and macOS keep the
Unix socket, and any endpoint path ending in .json picks the TCP transport, so
it is tested on Linux too. control::connect and Stream serve both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:57:09 +02:00
Ludovic Marie e67ccd5589 Merge pull request #46 from ludovic111/launcher-014
Launcher 0.1.4: reuse prepared resources across versions; clippy and fmt
2026-10-13 21:22:29 +02:00
Ludovic Marie 97245773d1 Merge pull request #45 from ludovic111/readme
README: new banner, current screenshots, what changed in v2026.10.13.1
2026-10-13 21:11:50 +02:00
Ludovic MarieandClaude Opus 5.5 1404dd5a0a Launcher: catch up versions an older launcher installed without preparing them
Launcher 0.1.3 auto-installed v2026.10.13.1 unprepared; after updating to 0.1.4 nothing installs
it again, so install's reuse never ran for it. backend::reuse_prepared_everywhere() applies the
same reuse (same compatibility checks, copied then renamed) to every installed version that is
not prepared, oldest first. It runs when the window starts (on a thread; the page refreshes when
it is done), in the CLI's `versions` and `update`, and in the MCP list_versions and check_updates
tools. The partial folder is now named per copy, and a version prepared meanwhile is left as it is,
since the window's start and an install can copy at the same time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:11:43 +02:00
Cosmic 870f2b45f7 Merge remote-tracking branch 'origin/main' into fix/vanilla-camera-cracks-clouds 2026-10-13 15:11:33 -04:00
Ludovic MarieandClaude Opus 5.5 8fa3eef809 Launcher 0.1.4: a new version reuses resources another installed version prepared
After installing a version (CLI install and update, the window's automatic install, MCP),
when it is not prepared and another installed version with the same Minecraft version is,
the launcher copies that version's prepared resources. Players who auto-updated to
v2026.10.13.1 no longer prepare again, and can play it without their Minecraft folder.

The prepared output depends on the Horizon version as well as Minecraft's: the manifest
records the hashes of the vanilla exporter sources it was made with, and
prepare_native_client.py writes every file and the manifest's schema. So resources are reused
only when both versions have the same pinned jars in research-lock.json, the same
prepare_native_client.py and the same exporter sources the manifest lists. They are copied
(prepare rewrites files in place, so hard links would tie the versions together), into a
partial folder renamed when complete. install and the MCP tool say when it is prepared already.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:08:06 +02:00
Ludovic MarieandClaude Opus 5.5 22756c28d8 README: retake the launcher's Play page, prepared and ready
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:07:17 +02:00
Ludovic MarieandClaude Opus 5.5 f0be91777f README: a new banner, current screenshots, what changed in v2026.10.13.1
- A banner in the website's design (Instrument Serif wordmark over a dimmed
  sunset, purple accent), rendered from docs/media/banner.html with Playwright.
- Screenshots of Horizon's own menus and graphics settings, the in-game scenes
  as WebP (from the website's media), and the launcher 0.1.3 Play and Patch
  notes pages; the JPEGs the README no longer uses are removed.
- A "What changed" section matching the v2026.10.13.1 release notes.
- Download only through the launcher (playhorizon.dev or launcher-v releases).
- Roadmap: performance optimisation, mod support and agent control.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:07:17 +02:00
Cosmic b797839f77 fix(client): follow block shapes for cracks and terrain particles
The crack overlay and particle emitters assumed a unit cube. Use selected baked model quads and projected decal UVs for cracks, outline bounds for mining crumbs, and per-box density for the destruction burst. Keep translucent overlays after translucent terrain.

Distinguish model offsets from outline offsets and preserve double-precision shape coordinates. Add regression tests and actual-client differential oracles: 2,740 model cases and 2,740 particle cases, each passing three runs after the upstream merge. Particle enqueue positions match exactly; model max error is 1.40395e-7. Offline gate, camera/first-person/cloud comparisons and Vulkan smoke checks pass.
2026-10-13 15:06:46 -04:00
Ludovic MarieandClaude Opus 5.5 3a522af067 Launcher: fix clippy's 7 warnings (unused re-exports, complex markdown types, large Err in send closures)
Behaviour is unchanged: the threads still ignore a closed channel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:06:39 +02:00
Ludovic MarieandClaude Opus 5.5 c989692bd9 Launcher: cargo fmt (formatting only)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:06:31 +02:00
Ludovic MarieandClaude Opus 5.5 a96580aa2e Tests: the supervise trial-merge test sets its own git identity (CI runners have none)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 21:06:28 +02:00
Ludovic Marie cfdcd7f7eb Merge pull request #44 from ludovic111/website
Website: playhorizon.dev, in the launcher's design language
2026-10-13 21:01:55 +02:00
Cosmic 7f7cdf01c2 Merge remote-tracking branch 'origin/main' into fix/vanilla-camera-cracks-clouds 2026-10-13 14:51:09 -04:00
Ludovic MarieandClaude Opus 5.5 6cd64ddcae Website: let the page fetch its own origin (robots.txt checks) and preconnect to GitHub
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:48:10 +02:00
Ludovic MarieandClaude Opus 5.5 f4719d974d Website: the social card keeps its text clear of the sun and names playhorizon.dev
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:47:03 +02:00
Ludovic MarieandClaude Opus 5.5 4ecb4c85e7 Launcher: use HORIZON_GITHUB_TOKEN for GitHub's API when given; CI passes its own
Shared CI runners hit GitHub's anonymous rate limit in the smoke test (launcher 0.1.2's
macOS build failed twice on it). Players never set the variable, so nothing changes for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:46:36 +02:00
Ludovic MarieandClaude Opus 5.5 189119a861 Website: a cinematic one-page site in the launcher's design language, on playhorizon.dev
The launcher's palette, violet accent, Instrument Serif and Geist, without its sidebar
and settings rows: a full-viewport hero over Horizon's own sunset (slow drift,
parallax, grain), then sections for what it is (a statement that lights up as it
scrolls), a screenshot mosaic, the parity count and a grid of the 14 subsystems, the
launcher beside Horizon's own menus, the latest patch notes, the roadmap, community
and the footer. Reveals, staggered letters and magnetic buttons stop under
prefers-reduced-motion; a translucent top bar and a full-screen phone menu replace
the sidebar.

playhorizon.dev: canonical, og:url, absolute og/twitter images, sitemap.xml and its
robots.txt line, and a 301 from *.up.railway.app (Railway's healthcheck host is still
served).

The full-page screenshots are no longer committed (about 17 MB): `npm run shots`
writes them to the gitignored shots/. The unused blurred background is gone; LEGAL.md
lists the hero and the settings screenshot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:46:19 +02:00
Ludovic Marie 3738c876bc Merge pull request #43 from ludovic111/domain
Add the playhorizon.dev website link
2026-10-13 20:40:03 +02:00
Ludovic MarieandClaude Sonnet 5.5 1c0a47e012 Website: playhorizon.dev in the README, docs, PLAY.txt and the launcher (0.1.3)
links.json gains a website key; the launcher shows a quiet Website link under
Join Discord and lists it in the links CLI command and MCP tool.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 20:35:22 +02:00
Ludovic Marie 2518075339 Merge remote-tracking branch 'origin/prepare-fix' into parity-base 2026-10-13 20:20:20 +02:00
Ludovic MarieandClaude Opus 5.5 6d7f11feb2 Prepare: require only this platform's libraries; accept republished 26.3 manifests
lab.prepare imported every client library in the version JSON, ignoring
its OS rules, so a Linux install failed on the macOS-only
java-objc-bridge the official launcher never downloads there. The rule
evaluation moves from runtime.py into lab.py and one selector,
client_library_artifacts, now serves preparation, the runtime and the
model baking harness: OS rules as the launcher applies them, and the
natives for this architecture (classifier suffixes, plain natives being
the x64 build only when an ARM variant is listed). Required libraries
are still checksummed and a missing one still errors.

Mojang republished 26.3.json twice (702fe591, then 11f1aefe, the current
one) with only assetIndex changed: non-English translations, same jars
and libraries. The lock pins the current file and accepts both earlier
ones; preparation takes the launcher's copy so it matches the asset
index installed with it, and an unknown republish errors clearly.
Fixes #41

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:20:17 +02:00
Cosmic 17f0029d08 Fix vertical camera flip, breaking crack blending and cloud face culling 2026-10-13 14:04:01 -04:00
Ludovic MarieandClaude Opus 5.5 f53f198f03 Website: one static page in the launcher's design, for Railway
website/ holds Horizon's site: the horizon-theme tokens and bundled fonts, the
launcher's sidebar, rows, buttons and segmented control, over Horizon's own frozen
landscape. The parity table, harness count and roadmap are generated from the README
at build time; releases, patch notes, the roadmap and links.json are refreshed in the
browser from GitHub, with the build's copy as fallback. The download button picks the
newest launcher-v* asset for the visitor's OS.

Legal: the Minecraft disclaimer in the hero, footer and 404; no Minecraft logo or
wordmark; no game files; the launcher's background (licence unconfirmed) and the
screenshots showing it are left out; LEGAL.md lists every asset and its licence. No
cookies, analytics or third-party assets.

Railway: root directory website, Dockerfile (Node build, Caddy on $PORT with a strict
CSP). Checked with Playwright at 360-1920 px and 1250x700 (docs/widths/); Lighthouse
96/100/96/92 on mobile, 100/100/96/92 on desktop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 20:03:56 +02:00
Ludovic Marie 383afd81eb Merge remote-tracking branch 'origin/graphics' into parity-base 2026-10-13 18:55:46 +02:00
Ludovic MarieandClaude Opus 5.5 3f3f294c52 Merge origin/main: game-bugs and launcher 0.1.2
Villager POI search: main's per-search state classification replaces this
branch's state table (same sections skipped, same positions in order).
status keeps main's mob and block-entity counts and this branch's pending
uploads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 18:41:36 +02:00
Ludovic MarieandClaude Opus 5.5 b6ee20d0e8 Graphics benchmark results: baseline against 9006566b on the RTX 4080 and lavapipe
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:50:18 +02:00
Ludovic MarieandClaude Sonnet 5.5 85851909d6 launcher CI: the game's Linux build failing its checks is a warning, not a launcher failure
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 17:38:41 +02:00
Ludovic MarieandClaude Sonnet 5.5 929fa4ec5a Launcher 0.1.2: Discord link, auto-install on by default, menu style setting, smoke tests
- links.json on main gives the Discord and sponsor links (built-in fallback offline,
  empty hides the button); 'links' command and MCP tool.
- Install new versions automatically is on unless explicitly turned off (new settings
  key, so 0.1.1's written default does not count as a choice).
- Settings: Horizon / Classic menu style, the horizon.txt menuStyle line the game reads;
  'menu-style' command and MCP tool.
- launcher workflow: install latest into a temp home, check imports (Windows, Linux client)
  and that the client starts, on all three platforms; also runs on launcher-* branches.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 17:27:31 +02:00
Ludovic MarieandClaude Opus 5.5 9006566bbd Graphics docs and screenshots for the Video section of the Horizon options
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:21:40 +02:00
Ludovic MarieandClaude Opus 5.5 73ca04b10d Merge origin/main: Horizon menus; graphics settings in the Video section
The Horizon options shell's Video section now holds the graphics settings:
a Simple / Advanced switch (remembered in horizon-graphics.txt), then the
level's rows, each with its frame-time cost and one-line description
(ui_theme::described_row). Horizon settings draw as switches, sliders or
value buttons like vanilla options. The Classic style keeps the Video
Settings buttons to the same screens. Main's menu blur and the world's
resolution-scale pass are separate (post.wgsl, world_post.wgsl).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:17:43 +02:00
Ludovic MarieandClaude Opus 5.5 9dc0803a24 Graphics benchmark: games run with HORIZON_NO_AUDIO=1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:12:30 +02:00
Ludovic Marie 87733155b3 Merge remote-tracking branch 'origin/game-bugs' into parity-base 2026-10-13 17:11:28 +02:00
Ludovic MarieandClaude Opus 5.5 7f1e197428 Section arena in pages; graphics docs and benchmark comparison
At render distance 32 the single section arena grew past the 4 GiB buffer
limit and the renderer panicked. Sections now live in pages of at most
256 MiB (or the device's buffer limit); draws keep their order and bind each
page once per run of consecutive draws in it, so translucent sections stay
back to front. docs/graphics.md describes the settings, the benchmark and
what changed; graphics_bench.py --compare prints a before/after table and
keeps each game's log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:10:07 +02:00
Ludovic MarieandClaude Opus 5.5 9bc1425177 Villagers: find POIs without reading every block of a 48-block sphere
AcquirePoi scanned every block within 48 of the villager (about 460,000),
naming each with string matches, every 20 to 40 ticks for each villager missing
a home, job site or meeting point. Villages gain such villagers over time
(babies, more villagers than beds), so the game thread's work grew with play
time: in a village, 18% of samples at the start and 30-42% eight minutes on,
frame rate dropping and staying low.

Block states are classified once per search, and 16^3 sections whose palette
holds no POI block are skipped whole (PoiManager only visits sections with
records). The same positions are visited in the same order, so the result,
ties in the distance sort included, is unchanged. Same session after: 18%
throughout, frames at the vsync cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:58:33 +02:00
Ludovic MarieandClaude Opus 5.5 ad9cd6c67e Resolution scale, FXAA and mipmap levels
With a resolution scale other than 100% or FXAA on, the world renders into
its own target at the scaled size and one full-screen pass copies it
(bilinear) or anti-aliases it (FXAA 3.11) onto the screen; the hand, screen
effects and HUD stay at full resolution. Off, the world renders straight to
the screen as before. Vanilla's Mipmap Levels option now limits the mip
levels block textures sample (the default 4 is the full chain, as before).
Both are in the Advanced screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:44:20 +02:00
Ludovic Marie ef4fc67f1b Merge remote-tracking branch 'origin/menus' into parity-base 2026-10-13 16:39:24 +02:00
Ludovic MarieandClaude Opus 5.5 e0283c70db Graphics settings, Simple and Advanced, with a performance overlay
Video Settings opens two Horizon screens (graphics_ui): Simple has the
Performance / Balanced / Quality / Ultra presets with render distance, FOV,
max framerate, VSync and GUI scale; Advanced groups every knob under
Rendering, Quality, Display, Performance and Overlay. Each row shows a
one-line description and its frame-time cost, and applies live. Layout lives
in graphics_ui, every colour, size and widget call in graphics_style, so a
menu theme can restyle them in one place.

Horizon-only knobs (horizon-graphics.txt, never options.txt): preset, cave
culling, chunk updates and upload budget per frame (nearest sections first),
sky, fog, weather, animated textures, present mode, frame pacing, worker
threads and the performance overlay (FPS, frame/CPU/GPU times and a
frame-time graph). Defaults keep vanilla's rendering; vanilla options keep
their options.txt keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:37:31 +02:00
Ludovic Marie c2c7614a2f Merge remote-tracking branch 'origin/feel' into parity-base 2026-10-13 16:28:56 +02:00
Ludovic MarieandClaude Opus 5.5 ad701d73d9 Fewer stalls: region writes on a writer thread, culled entities, villager POI scans skip empty sections
- Unloaded chunks and entity chunks are encoded and written by a background
  region writer, in order; loads see queued records first, and synchronous
  saves wait for the queue. Deleting entity chunks a region doesn't hold no
  longer rewrites the region file.
- Mobs render within Entity.shouldRenderAtSqrDistance (64 x bounding-box size
  x entity distance) and inside the view frustum (EntityRenderer.shouldRender);
  block entities outside the frustum are skipped, beams and structure blocks
  excepted.
- Minecarts and dropped items tick only in entity-ticking chunks, like mobs.
- Villager POI searches skip sections whose palette has no POI block.
- Streaming bookkeeping uses the Fx hash and checks a column's light
  readiness once per frame instead of once per dirty section.
- HORIZON_SPIKES=MS prints the timed spans of slow frames; frame summaries
  give the phase split of the slowest 1%.

RD 32 village flight on the 4080: 81 -> 230 fps; 1% low 5.8 -> 18.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:14:57 +02:00
Ludovic MarieandClaude Opus 5.5 1a9452d408 Memory: drop late chunks whole, bound the decoded sounds, count what stays resident
A column that finished loading or generating after the player moved out of
range was dropped, but its block entities and saved entities were added to the
world first. The entities spawned in a column that never became resident, so
nothing saved or removed them: travelling, mobs piled up (141 to 5,028 in an
8-minute flight), and when the column loaded later its entities spawned again,
so villages near the view edge doubled their villagers. Such a column is now
dropped with everything in it. Mesh results for a column unloaded meanwhile are
discarded too, rather than uploaded with nothing left to remove them.

Decoded static sounds were kept for the whole session: every sound heard, up to
1.4 GiB of samples, and a village plays hundreds. They now stay within 64 MiB,
least recently used out, and are decoded on a thread of their own instead of the
game loop (vanilla's buffers load asynchronously too). Stopping a source that
is still waiting for its sound stops it, as alSourceStop does, so a late decode
cannot start it.

status reports mobs, block entities, resident GPU sections and the sound
engine's tables, for soak tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:10:32 +02:00
Ludovic MarieandClaude Opus 5.5 45bd1a7be8 Renderer and tick performance: section arena with indirect multi-draw, dense occlusion graph, entity ticking within the simulation distance, fast chunk-map hashing
- Terrain sections live in one vertex arena (first-fit free list, grown on the
  GPU) and draw with one multi_draw_indexed_indirect per pass instead of a
  buffer bind and draw per section; per-frame scratch vectors are reused.
- The SectionOcclusionGraph search runs over dense arrays around the camera
  instead of hash sets, with per-column visibility.
- Mobs outside the entity-ticking chunks (Chebyshev distance of the
  simulation distance, DistanceManager.inEntityTickingRange) only check
  despawning, as in ServerLevel.tick; before, every loaded mob ticked.
- World chunks use the Fx hash; sky darkening is sampled once per time and
  weather instead of once per mob; creaking-heart scans compare state ids.
- Horizon-only graphics settings (horizon-graphics.txt): frame pacing without
  spinning, present mode, animated textures, worker threads.

RD 32 village flight on the 4080: 3.1 -> 81 fps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:34:48 +02:00
Ludovic MarieandClaude Opus 5.5 d9351ec054 Menus: Horizon-style options screen, pause polish, screenshots
Options open as the launcher's Settings page: a sidebar with the sliding
selection (Video, Audio, Controls, Accessibility, Language, Chat, Interface)
and rows with a switch, slider or value button per option
(options_horizon.rs; new sections or rows slot into SECTIONS). Key binds,
the language list and resource packs open vanilla's pages. Interface holds
Menu style (Horizon / Classic). The pause menu hides the in-game HUD over the
blurred world. Scripted and frame-limited runs default to Classic, so the
harnesses see vanilla's screens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:33:15 +02:00
Ludovic MarieandClaude Opus 5.5 eb18bb42af Menus: Horizon style for the title, worlds, create, multiplayer and pause screens
One theme for the launcher and the game: crates/horizon-theme holds the
palette, type scale, spacing, radii, control sizes, motion and the bundled
fonts; launcher/src/theme.rs and the game's ui_theme both read it. ui_theme
ports the launcher's components (button with hover layer and press state,
sidebar list with the sliding selection, segmented control, switch, rows)
and draws Geist and Instrument Serif through the HUD atlas.

The title is laid out like the launcher (sidebar, page header, rows) over a
live backdrop: Horizon generates an ice-spikes landscape for a fixed seed,
orbits a camera over it with falling snow, then blurs and cold-grades the
frame (post.rs). The launcher's opening (wordmark, then the interface) plays
once per session and a click or key skips it. Menu style Horizon / Classic
lives in horizon.txt, never options.txt; Classic keeps vanilla's drawing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:19:14 +02:00
Ludovic MarieandClaude Opus 5.5 c982fadaf5 Graphics benchmark: scripted flights over four scenes at render distances 8/16/32, with CPU and GPU frame times
horizon-ctl gains 'fly' (a constant-speed camera path that replies with the
frame summary); frame summaries add CPU time per frame (waits for the GPU,
display and limiter excluded), per-phase averages and GPU time from timestamp
queries. --offscreen renders into a texture on the discrete GPU so the 4080
can be measured under the headless compositor. Baselines: lavapipe on vscreen
and the RTX 4080 offscreen, seed 1234.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:04:23 +02:00
Ludovic MarieandClaude Opus 5.5 1a10d4efe8 Windows: launcher without VCRUNTIME140.dll, a clear message without Vulkan, import checks in CI
The Windows launcher was built without crt-static and imported VCRUNTIME140.dll,
which only the Visual C++ redistributable installs: players without it got a
missing-DLL dialog before anything started. The game itself links only system
DLLs (vulkan-1.dll is loaded at run time).

- launcher.yml links the C runtime on Windows.
- tools/check_imports.py reads PE import and delay-import tables (and ELF
  loader, needed libraries and run paths); release.yml and launcher.yml fail
  when a Windows binary imports a non-system DLL.
- release.yml builds Linux on Ubuntu 22.04: the NixOS-made Linux release asked
  for a /nix/store loader, so it ran nowhere but NixOS.
- No adapter: the game says the Vulkan driver is missing and what to install,
  and on Windows keeps the console open until Enter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
Ludovic MarieandClaude Opus 5.5 59fd98dea4 Sound: play under the launcher's own stream name, open any sample format, say what opened
PipeWire remembers mute per application name. A test run's stream, muted once
in the mixer, left "PipeWire ALSA [horizon-client]" muted for every later game,
the release played from the launcher included: no sound at all, with the device,
the stream and the mixer all working.

- The launcher starts the game as "Horizon" (media.role Game), its own entry.
- The game opens no output on a vscreen, where agents run it (HORIZON_AUDIO=1
  forces it, for a null sink), so nobody needs to mute it again.
- Any device sample format is accepted (the mixer converts from f32): a plain
  ALSA default reporting i16 used to fail.
- Other output devices are tried when the default does not open, and the log
  names the output opened or every reason none did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 15:02:15 +02:00
2018 changed files with 392699 additions and 23373 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Docker builds (the relay) need the Rust sources only.
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+5
View File
@@ -0,0 +1,5 @@
# Generated files that tests compare byte for byte with what they generate: LF on every OS, also
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
+16
View File
@@ -0,0 +1,16 @@
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
# (tools/ci/protect_pipeline.sh, docs/security.md). They are what builds, signs and ships releases, the code that
# checks downloads before installing them (backend.rs downloads, installs and updates the launcher itself; links.json
# says where from; the Cargo.toml files pick the crates that do it), and this file. Everything else merges as soon as
# `verify` passes.
/.github/ @ludovic111
/tools/ci/ @ludovic111
/tools/release_train.py @ludovic111
/crates/horizon-release-sign/ @ludovic111
/crates/horizon-server/src/update.rs @ludovic111
/crates/horizon-server/Cargo.toml @ludovic111
/launcher/src/signed.rs @ludovic111
/launcher/src/backend.rs @ludovic111
/launcher/links.json @ludovic111
/launcher/Cargo.toml @ludovic111
/packaging/ @ludovic111
+3
View File
@@ -0,0 +1,3 @@
self-hosted-runner:
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
labels: [horizon-game, ludovics-mac-mini]
+19
View File
@@ -0,0 +1,19 @@
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
## Vanilla reference
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
## Screenshots
<!-- For anything players see: before and after. Delete this section otherwise. -->
<!-- Please tick "Allow edits by maintainers": we push small fixups ourselves instead of a round trip.
See CONTRIBUTING.md. -->
+1
View File
@@ -0,0 +1 @@
0.2
+27
View File
@@ -0,0 +1,27 @@
name: cancel-on-close
# A closed or merged pull request's queued and running checks free the runners at once.
# pull_request_target so the token can cancel runs of pull requests from forks too;
# it checks out nothing and runs no code from the pull request.
on:
pull_request_target:
types: [closed]
permissions:
actions: write
jobs:
cancel:
runs-on: ubuntu-latest
steps:
- name: Cancel the pull request's unfinished runs
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.pull_request.head.ref }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
for status in queued in_progress waiting pending requested; do
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
--json databaseId,event,headSha \
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
done | sort -u | while read -r id; do
gh run cancel -R "$REPO" "$id" || true
done
+52 -5
View File
@@ -1,14 +1,31 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
# never start this workflow. See docs/release-flow.md.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
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
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
@@ -16,22 +33,51 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Keep game artifacts out of Git
run: |
python tools/check_public_tree.py
python tools/check_public_tree.py --history
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA headers the client's audio links against
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy --target wasm32-unknown-unknown
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting and lint
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
# With the game's code mods (on in every release build, off by default).
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
- name: Test Rust core and voxel kernel correctness
run: cargo +stable test --workspace
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
parity:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/parity.yml
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
@@ -493,3 +539,4 @@ jobs:
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
+281 -35
View File
@@ -1,86 +1,332 @@
name: launcher
# Builds the launcher on Linux, macOS and Windows. A pushed `launcher-vX.Y.Z` tag also puts the
# three archives on a draft GitHub release of that tag, to publish by hand. Horizon's own
# releases are tagged `vYYYY.MM.DD`; the launcher reads only `launcher-v*` releases for itself.
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
# 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 [email protected] 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
on:
workflow_dispatch:
push:
branches: [launcher]
tags: ['launcher-v*']
branches: [main, launcher, 'launcher-*']
paths:
# Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Build everything and write the notes, but publish nothing
type: boolean
default: true
force:
description: Release even without launcher changes, before merges settle or while paused
type: boolean
default: false
permissions:
contents: read
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
timeout-minutes: 10
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
head: ${{ steps.decide.outputs.head }}
version: ${{ steps.decide.outputs.version }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: mode
# Off main every run is a dry run (a launcher branch's push only builds).
run: echo "dry_run=${{ github.ref != 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && inputs.dry_run) }}" >> "$GITHUB_OUTPUT"
- id: decide
env:
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
# A push or a run off main builds its own commit; otherwise main's newest commit with green checks.
HEAD_SHA: ${{ (github.event_name == 'push' || steps.mode.outputs.dry_run == 'true') && github.sha || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
run: |
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)
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"
echo "head=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
fi
build:
needs: decide
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
- name: linux
os: ubuntu-22.04
asset: horizon-launcher-linux-x86_64.tar.gz
binary: horizon-launcher
- os: macos-latest
- name: macos
mac: true
asset: horizon-launcher-macos-arm64.zip
binary: horizon-launcher
- os: windows-latest
- name: windows
os: windows-latest
asset: horizon-launcher-windows-x64.zip
binary: horizon-launcher.exe
runs-on: ${{ matrix.os }}
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
# 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 }}
# 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 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
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: launcher
shared-key: launcher-${{ matrix.name }}
- name: Linux libraries GPUI links to
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
- name: The tag matches Cargo.toml
if: startsWith(github.ref, 'refs/tags/launcher-v')
run: grep -qx "version = \"${GITHUB_REF_NAME#launcher-v}\"" Cargo.toml
- run: cargo test --release --locked --lib
- run: cargo build --release --locked
- name: Package
- name: Metal shaders compiled now, or at run time without the Metal toolchain
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
if: runner.os == 'macOS'
run: |
mkdir -p dist
cp target/release/${{ matrix.binary }} dist/
cd dist
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
tar czf ../${{ matrix.asset }} ${{ matrix.binary }}
elif [[ "$RUNNER_OS" == "Windows" ]]; then
7z a ../${{ matrix.asset }} ${{ matrix.binary }}
else
zip ../${{ matrix.asset }} ${{ matrix.binary }}
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: The version is the release's
run: |
out=$(target/release/${{ matrix.binary }} --version)
echo "$out"
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
- name: Only system libraries
run: |
file=target/release/${{ matrix.binary }}
case "$RUNNER_OS" in
Windows) python ../tools/check_imports.py "$file" ;;
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::links $others"; exit 1; } ;;
esac
- name: Smoke test
env:
HORIZON_LAUNCHER_NO_SELF_UPDATE: '1'
HORIZON_GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
rm -rf "$HORIZON_LAUNCHER_HOME"
launcher="$PWD/target/release/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
retry "$launcher" versions
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
# The game's Linux build is the game's to keep portable (release.yml checks it): a
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
if [[ "$RUNNER_OS" == "Windows" ]]; then
python ../tools/check_imports.py "$client"
elif [[ "$RUNNER_OS" == "Linux" ]]; then
soft python3 ../tools/check_imports.py "$client"
fi
# The client starts: --help prints before any window or GPU is needed.
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), 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
rm -rf out/Horizon.app
else
mkdir -p dist
cp target/release/${{ matrix.binary }} dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
else
(cd dist && 7z a ../out/${{ matrix.asset }} ${{ matrix.binary }})
fi
fi
ls -l out
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: launcher/${{ 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]
runs-on: ubuntu-latest
timeout-minutes: 10
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-launcher-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: bash tools/ci/sign_release.sh "$TAG" dist/horizon-launcher-*
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
release:
if: startsWith(github.ref, 'refs/tags/launcher-v')
needs: build
needs: [decide, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
# A draft, and never marked latest: Horizon's own releases stay the repository's latest.
- uses: softprops/action-gh-release@v2
- name: The signature, checked as launchers check it
env:
TAG: ${{ needs.decide.outputs.tag }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
# Never marked latest: Horizon's own releases stay the repository's latest.
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest=false dist/horizon-launcher-* dist/SHA256SUMS "${signature[@]}"
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
tag_name: ${{ github.ref_name }}
name: Horizon launcher ${{ github.ref_name }}
draft: true
make_latest: false
files: dist/*
name: launcher-release-notes
path: notes/
retention-days: 3
+83
View File
@@ -0,0 +1,83 @@
name: nightly audit
# Every vanilla harness, from scratch, on main, at 03:00 Paris time on the Mac mini's runner (it holds the
# maintainer's own Minecraft files). The resources are prepared again first when the preparer or its exporters
# changed (tools/ci/vanilla-data.sh). A harness that passed in the previous audit and fails now gets an issue
# labelled parity-regression (one per harness); its issue closes when it passes again
# (tools/ci/parity_issues.py). The bugs session picks those up. See docs/release-flow.md.
# gh workflow run "nightly audit" # now
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
on:
schedule:
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
- cron: '7 1 * * *'
- cron: '7 2 * * *'
workflow_dispatch:
inputs:
dry_run:
description: Run the audit, but only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
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]
timeout-minutes: 150
steps:
- name: 03:00 in Paris
id: when
run: |
hour=$(TZ=Europe/Paris date +%H)
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
if: steps.when.outputs.run == 'true'
with:
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
clean: false
- uses: dtolnay/rust-toolchain@stable
if: steps.when.outputs.run == 'true'
- name: Tools from the runner
if: steps.when.outputs.run == 'true'
run: bash tools/ci/runner-env.sh
- name: Vanilla data, prepared again if the preparer changed
if: steps.when.outputs.run == 'true'
run: bash tools/ci/vanilla-data.sh
- name: Every harness
id: audit
if: steps.when.outputs.run == 'true'
env:
PARITY_HOSTS: ''
run: |
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
set +e
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Issues for the harnesses that regressed
if: steps.when.outputs.run == 'true' && always() && steps.audit.outputs.since
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Logs of the failed harnesses
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
uses: actions/upload-artifact@v4
with:
name: nightly-parity-logs
path: .local/parity-runner/logs/
if-no-files-found: ignore
retention-days: 7
+54
View File
@@ -0,0 +1,54 @@
name: parity
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's
# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
on:
workflow_call:
inputs:
base:
description: The pull request's base commit (the harnesses its changes since then can affect)
type: string
required: true
head:
description: The commit to check out and test
type: string
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]
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
steps:
- uses: actions/checkout@v4
with:
# The commit the event names: a fork's later pushes need another run-parity label.
ref: ${{ inputs.head }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
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: ''
BASE_SHA: ${{ inputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
with:
name: parity-logs
path: .local/parity-runner/logs/
if-no-files-found: ignore
retention-days: 3
+373 -16
View File
@@ -1,27 +1,384 @@
name: release builds
# Started by hand: gh workflow run release.yml --ref <branch>
# Builds the Windows x64 client and uploads the playable folder as an artifact.
# Linux and macOS builds are made on the development machines.
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 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; so does an open issue labelled release-blocker, forced runs too.
# 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 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 the release and GitHub's latest: one
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
# is open (tools/release_train.py gate); otherwise nothing is published. 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:
workflow_run:
workflows: [checks]
types: [completed]
branches: [main]
schedule:
- cron: '2-59/10 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Build everything and write the notes, but publish nothing
type: boolean
default: true
force:
description: Release even without player-facing changes, before merges settle or while paused
type: boolean
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
concurrency:
group: game-release
cancel-in-progress: false
jobs:
windows:
runs-on: windows-latest
decide:
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
runs-on: ubuntu-latest
timeout-minutes: 10
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:
- uses: actions/checkout@v4
- name: Install Rust toolchain
run: rustup toolchain install stable --profile minimal
- name: Build the client
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
with:
fetch-depth: 0
- id: mode
# A dry run never publishes; a hand-started run off main is always one.
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
- id: decide
env:
RUSTFLAGS: -C target-feature=+crt-static
run: cargo +stable build --release --locked -p horizon-client --bin horizon-client
- name: Package
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
# The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
# main's newest commit with green checks.
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
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
build:
needs: decide
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
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
run: packaging/package.sh target/release/horizon-client.exe dist/horizon-windows-x64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "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.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# 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
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
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: horizon-windows-x64
path: dist/horizon-windows-x64
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
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)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
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]
runs-on: ubuntu-latest
timeout-minutes: 10
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [decide, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
issues: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
TAG: ${{ needs.decide.outputs.tag }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test passed, no release-blocker is open)
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
# reports what a real run would do.
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The release and the latest at once: there is one channel (docs/release-flow.md).
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: release-notes
path: notes/
retention-days: 3
+24
View File
@@ -0,0 +1,24 @@
name: run-parity
# A fork's pull request runs the parity job (parity.yml) on the Mac mini only when a maintainer who has read the
# change adds the run-parity label: once, on the commit the pull request has then. Remove the label and add it
# again after new pushes. A separate workflow, so that labels never start `checks`: there a label's run reported a
# skipped `verify`, and GitHub counts a skipped required check as passed.
on:
pull_request:
types: [labeled]
permissions:
contents: read
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'
uses: ./.github/workflows/parity.yml
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
+34
View File
@@ -0,0 +1,34 @@
name: telemetry-server
# The ingest server is its own workspace (crates/horizon-telemetry-server), so the checks job doesn't build
# it: this one does, when it or the crate it shares with the launcher and the game changes.
on:
push:
branches: [main]
paths:
- 'crates/horizon-telemetry/**'
- 'crates/horizon-telemetry-server/**'
- '.github/workflows/telemetry-server.yml'
pull_request:
paths:
- 'crates/horizon-telemetry/**'
- 'crates/horizon-telemetry-server/**'
- '.github/workflows/telemetry-server.yml'
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: crates/horizon-telemetry-server
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates/horizon-telemetry-server
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo +stable fmt --check
- run: cargo +stable clippy --all-targets --locked -- -D warnings
- run: cargo +stable test --locked
+13
View File
@@ -16,5 +16,18 @@
/dist/
/mods/horizon/build/
/saves/
/backups/
/tools/logs/
.DS_Store
# What the client writes to its working directory when run from the repo root.
/options.txt
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
/horizon-extras.txt
/horizon-player.txt
/servers.dat
/screenshots/
/.horizon-cache/
/crash-reports/
/debug/
+175
View File
@@ -0,0 +1,175 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
## Crates
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
## Vanilla reference
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
([docs/decompilation-status.md](docs/decompilation-status.md)).
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
random draws must happen in vanilla's order.
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
## The two single-player paths
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
open `[server-thread]` issues list the gaps.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
## Docs worth knowing
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+86
View File
@@ -0,0 +1,86 @@
# Contributing to Horizon
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
[Build from source](README.md#build-from-source).
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
probe against Horizon, and compares their outputs. There are about 290 of them.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
Say in your pull request which vanilla classes and methods you followed.
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
## Before asking for review
Run the self-check and paste the block it prints into your pull request:
```sh
python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on the main repository
```
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
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
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
- **Labels** (we set them if you can't): `feature` when players get something new (it appears under "New" in the
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
## Parity checks on forks
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
harnesses on a maintainer's machine, which holds the game files, so it never runs a fork's code on its own: a
maintainer reads your change, then adds the `run-parity` label, which runs it once on your current commit. After
new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+2608 -15
View File
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
@@ -16,6 +16,14 @@ type_complexity = "allow"
needless_range_loop = "allow"
large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
[profile.dev]
+64 -74
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/media/banner.jpg" alt="Horizon: Minecraft Java Edition 26.3, rewritten from scratch in Rust" width="100%">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
</p>
<p align="center">
@@ -12,83 +12,85 @@
</p>
<p align="center">
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="#screenshots"><b>Screenshots</b></a> ·
<a href="#project-status"><b>Parity status</b></a> ·
<a href="docs/README.md"><b>Documentation</b></a>
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
</p>
# Horizon
A Rust rewrite of Minecraft: Java Edition 26.3 built for one-to-one vanilla parity, checked against the game itself with differential tests.
Every subsystem matches vanilla in all 281 parity harnesses, on Linux and macOS: 100% of what the harnesses compare (see [Project status](#project-status)). The native client is playable on Linux, macOS (Apple Silicon) and Windows.
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
## Download
**[Download the Horizon launcher](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true)** for Linux x86-64, macOS Apple Silicon or Windows x64: take the newest `launcher-v*` release (currently [launcher-v0.1.1](https://github.com/ludovic111/horizon/releases/tag/launcher-v0.1.1)); the launcher then updates itself. The launcher is the way to get Horizon: it installs and updates the game, prepares its resources from your own copy of Minecraft, and starts the client or a dedicated server.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** Minecraft: Java Edition installed with the official launcher, with 26.3 played once; Python 3.10 or newer and Java 25 (a JDK, e.g. Eclipse Temurin 25) for the one-time resource preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac.
### The launcher
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="launcher/docs/play.png" alt="The launcher's Play page"></td>
<td width="50%"><img src="launcher/docs/versions.png" alt="The launcher's Versions page"></td>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
</tr>
</table>
It lists Horizon's releases and installs the build for your platform, shows each release's patch notes and the roadmap, updates Horizon and itself, and doubles as a command-line tool and an MCP server. See [the launcher's README](launcher/README.md).
## What works today
## Screenshots
The [documentation](https://playhorizon.dev/docs/) covers each of these.
Captured in the Rust client itself, with vanilla assets and no mods or shaders.
- **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'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:
<table>
<tr>
<td colspan="2"><img src="docs/media/sunset.jpg" alt="Sunset over a savanna river valley and a plains village"></td>
<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/village.jpg" alt="A plains village with farms, an iron golem and animals"></td>
<td width="50%"><img src="docs/media/survival.jpg" alt="Survival at dusk: a zombie attacks near the village"></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/nether.jpg" alt="A crimson forest in the Nether with a zombified piglin"></td>
<td width="50%"><img src="docs/media/night.jpg" alt="Moonrise over a torch-lit village and river"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/underwater.jpg" alt="Salmon swimming in a river"></td>
<td width="50%" valign="middle">
Vanilla world generation ported to Rust (terrain, aquifers, carvers, features, villages and the Nether), every block model with smooth lighting and biome tints, the vanilla sky and fog, mobs, the HUD, chat and advancements: everything in these frames is drawn by Horizon.
</td>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
<td width="50%"><img src="docs/media/nether.webp" alt="A crimson forest in the Nether with a zombified piglin"></td>
</tr>
</table>
## What it does
## Host a server
Horizon reimplements the Minecraft Java client and its game logic in Rust, one subsystem at a time, and compares each piece with the vanilla game. Each `tools/test_*_parity.py` harness drives the same inputs through Horizon and through the vanilla client and server bytecode from your own copy of the game, then checks that the outputs are identical. It is for players and developers interested in game reimplementation, and for anyone who wants to see how far a behaviour-exact native rewrite can go.
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)).
The [native client](docs/native-client.md) opens on a vanilla-style title screen with world creation and selection, and generates worlds with the Rust port of the vanilla generators: noise terrain, aquifers, surface rules, carvers and placed features, all checked against vanilla. It streams and lights chunks on worker threads and renders every vanilla block model with smooth lighting, biome tints, the vanilla sky, sun, moon, stars and clouds. Gameplay includes:
## Modpacks
- validated player physics, health and hunger;
- mining with vanilla tool rules and loot tables;
- placing, crafting, chests and furnaces;
- dropped items, particles and sounds;
- passive and hostile mobs with combat and vanilla AI, including guardians, shulkers, illagers, ravagers, the wither, the ender dragon fight, the breeze, the creaking, the sniffer, the happy ghast, llamas and the copper golem;
- villagers that take jobs, trade, restock, meet at the bell and sleep in beds, on a port of vanilla's brain;
- raids and pillager patrols;
- dispensers, crafters, the loom and the recipe book;
- every command, scoreboards and teams, the player list, subtitles and the HUD overlays;
- boats, minecarts, item frames, paintings and armor stands;
- shields, maps, books, fireworks, beacons, advancements and statistics;
- Anvil saves, joining vanilla servers, and opening a world to LAN.
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
## Agent control
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
horizon-ctl state # position, health, food, target, screen...
horizon-ctl find-block birch_log # the nearest logs
horizon-ctl goto -0.5 65 -2.5 --near 2.5 # walks there along a path
horizon-ctl break -1 65 -3 # mines it; replies with the drops
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA development headers (`libasound2-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
@@ -98,18 +100,29 @@ Horizon contains no game files and never downloads them. It builds its resources
```sh
git clone https://github.com/ludovic111/horizon.git
cd horizon
python3 tools/prepare_native_client.py --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
python3 tools/fetch_sounds.py # optional: sounds from your launcher install
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
cargo run --release -p horizon-client
```
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl`, and the architecture are described in [the native client guide](docs/native-client.md). The rest of the documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
```sh
git config core.hooksPath .githooks
python3 tools/pr_ready.py
```
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
## Project status
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences and the final checks are listed in the roadmap.
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
### Java parity: 100%
@@ -138,39 +151,16 @@ them too (Rust uses Java's own float maths on each platform).
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
### Roadmap
### Known issues
**Known differences.** Online-mode servers (Mojang's session servers) are not supported; skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
**Final checks.** A from-scratch audit (`tools/parity.py --all`) on Linux and on macOS, and the launch checks on the packaged Linux, macOS and Windows builds listed in [Rust rewrite status](docs/rust-parity.md#completion-criteria-and-playable-builds).
**Next.** With vanilla parity as the base:
- **Performance:** optimize the client and the server as far as they can go (rendering, chunk generation and meshing, memory, multithreaded ticking) while the differential harnesses keep gameplay identical to vanilla.
- **Modding support:** a stable API for writing mods against Horizon.
- **Agent control:** an MCP server and a CLI so AI agents and scripts can play and drive the game, growing out of today's `horizon-ctl` scripting socket.
- **Built-in recording:** capture gameplay video in a keypress, without external tools.
- **Automatic version updates:** a pipeline that ports each new Minecraft release on its own: compare the new version with the pinned one, regenerate the data, rewrite the changed parts and prove them with the same differential harnesses.
## Contributing
Issues and pull requests are welcome. Before opening a pull request, run the offline checks:
```sh
git config core.hooksPath .githooks
python3 -m unittest discover -s tests -v
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
python3 tools/check_public_tree.py --history
```
CI runs these checks on GitHub's Linux runners. The vanilla comparisons need the game, which Horizon never downloads, so they run only when started by hand on a self-hosted runner holding the maintainer's own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
## Author
Made by [Ludovic Marie](https://lu4ovic.com) ([@ludovic111](https://github.com/ludovic111)).
+50
View File
@@ -0,0 +1,50 @@
# Third-party notices
## CullLeaves
The optional leaf and mangrove-root face-culling behavior is adapted from
[CullLeaves](https://github.com/TeamMidnightDust/CullLeaves) by MidnightDust.
Its MIT license is reproduced below.
```text
MIT License
Copyright (c) 2020 MidnightDust
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
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`).
@@ -0,0 +1,78 @@
# Entity model document loading
The three startup batches (boats/chest boats, minecarts, armor stands/player armor)
now share one parsed document. For this fixture, that replaces 43 full document
reads/parses (boat discovery plus 42 selected layers) with one. Selected models
still produce independent `Part` trees; the document is dropped after the batches
are built. Other entity loaders retain their existing behavior.
## Reproduce
From the repository root:
```sh
python3 benchmarks/entity-model-document-2026-10-14/generate_fixture.py /tmp/horizon-entity-model-document-fixture.json
ENTITY_MODEL_BENCH_DOCUMENT=/tmp/horizon-entity-model-document-fixture.json \
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo test -p horizon-client --profile fast --bin horizon-client \
entity_model_document_benchmark -- --ignored --nocapture
```
`CARGO_TARGET_DIR` is optional and can be changed for another machine. Supply a
prepared `entity-models.json` through `ENTITY_MODEL_BENCH_DOCUMENT` to benchmark
that document instead; the harness copies only that file to a temporary root.
`ENTITY_MODEL_BENCH_REPETITIONS` controls repetitions (default 15).
The opt-in Rust test calls the actual three optimized production loaders and
compares every selected part name, pose, polygon normal/vertex and child against
the previous algorithm using the compatible per-model `load_model` API. Timings
include reading, parsing, constructing, normalizing into a common result map, and
dropping the result. Each variant gets three warmups and 15 measured calls, with
order alternating. `black_box` consumes inputs/results. No other builds or
optimization benchmarks ran during the measured calls.
## Input and limitations
Prepared resources were unavailable in the repository and local Horizon asset
directories. This is a **synthetic** 2,473,695-byte document with 458 model layers,
each containing 12 posed parts with six exported quads and normals. It selects 22
boat/chest-boat layers, six minecart layers and 14 armor/player layers (42 total).
One wood name is synthetic (`test_wood`); boat discovery accepts arbitrary wood
names. The extra layers model the cost of parsing a full production-size document
for each selected model.
File data is warm in the OS page cache. Equipment files are absent, so equipment
texture loading is excluded. This measures the entity-model batch only and does
not establish overall startup, frame time or FPS improvement. Shapes/layer counts
in real prepared documents can produce different absolute timings.
Machine: AMD Ryzen 5 5600X (6 cores / 12 threads), Linux x86_64,
Rust 1.99.0 (b940084d7, LLVM 23.1.1), Cargo `fast` profile (opt-level 3,
16 codegen units, no LTO). Baseline algorithm corresponds to main
`73a019967e9665c6b96332988c13cb66679d8477`.
## Results
| Variant | Median batch time | Full document reads/parses |
| --- | ---: | ---: |
| Previous per-model algorithm | 1,434.974 ms | 43 |
| Shared document | 33.420 ms | 1 |
The measured batch is **42.94× faster** on this synthetic input. All 15 samples
for both variants, warmup count, document size and selected model count are in
[`results.json`](results.json).
## Validation
```sh
cargo fmt --all -- --check
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo test -p horizon-client --profile fast --bin horizon-client \
entity_model_loading_tests -- --nocapture
CARGO_TARGET_DIR=/home/bob/Documents/horizon/target \
cargo clippy -p horizon-client --profile fast --lib --bin horizon-client --tests -- -D warnings
```
The regular tests cover full/sparse documents, unrelated layers, a missing file,
invalid JSON, non-object JSON, missing model keys, independent model trees, and
equipment loading when the document is unavailable.
@@ -0,0 +1,31 @@
"""Generate a production-size synthetic document; no prepared assets required."""
import json
import pathlib
import sys
woods = "oak spruce birch jungle acacia dark_oak mangrove cherry bamboo pale_oak test_wood".split()
carts = "minecart chest_minecart hopper_minecart furnace_minecart tnt_minecart command_block_minecart".split()
keys = [f"minecraft:{prefix}/{wood}#main" for prefix in ("boat", "chest_boat") for wood in woods]
keys += [f"minecraft:{cart}#main" for cart in carts]
keys += [f"minecraft:{model}#{slot}" for model in ("player", "armor_stand", "armor_stand_small")
for slot in ("helmet", "chestplate", "leggings", "boots")]
keys += [f"minecraft:{model}#main" for model in ("armor_stand", "armor_stand_small")]
face = [[0, 0, 0, 0, 0], [16, 0, 0, 1, 0], [16, 16, 0, 1, 1], [0, 16, 0, 0, 1]]
part = {"pose": [1, 2, 3, 0.25, -0.5, 0.75, 1, 1, 1], "polygons": [face] * 6,
"normals": [[0, 0, 1]] * 6}
model = {"pose": [0, 0, 0, 0, 0, 0, 1, 1, 1],
"children": {f"part_{i}": part for i in range(12)}}
encoded = json.dumps(model, separators=(",", ":"))
entries = [json.dumps(key) + ":" + encoded for key in keys]
size = 2 + sum(map(len, entries)) + len(entries) - 1
index = 0
while size < 2_470_000:
entry = json.dumps(f"minecraft:fixture_{index}#main") + ":" + encoded
entries.append(entry)
size += 1 + len(entry)
index += 1
output = pathlib.Path(sys.argv[1])
output.write_text("{" + ",".join(entries) + "}")
print(json.dumps({"path": str(output), "bytes": output.stat().st_size,
"layers": len(entries), "selected_layers": len(keys)}))
@@ -0,0 +1,43 @@
{
"baseline_median_ms": 1434.973835,
"baseline_ms": [
1400.008286,
1406.761694,
1408.9131360000001,
1418.6680549999999,
1419.149658,
1427.082872,
1433.088092,
1434.973835,
1449.7688150000001,
1452.143883,
1453.987998,
1455.518231,
1457.0334540000001,
1458.588552,
1485.34133
],
"document_bytes": 2473695,
"optimized_median_ms": 33.419768000000005,
"optimized_ms": [
32.64795,
32.812732,
33.054488,
33.056914,
33.102999000000004,
33.23532,
33.28287,
33.419768000000005,
33.610569,
33.874749,
33.915657,
33.93877,
34.009893999999996,
34.550526,
36.096408000000004
],
"repetitions": 15,
"selected_models": 42,
"speedup": 42.937875421516985,
"warmup_pairs": 3
}
+546
View File
@@ -0,0 +1,546 @@
{
"label": "after-4080",
"date": "2026-10-13",
"commit": "9006566b",
"host": "ludodesktop",
"offscreen": true,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 133,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 941908352
},
"village": {
"avg_fps": 2409.179688612205,
"cpu": {
"avg_ms": 0.41004928978864236,
"max_ms": 18.15971565246582,
"p50_ms": 0.2718150019645691,
"p95_ms": 0.7112299799919128,
"p99_ms": 1.316559076309204
},
"frames": 28910,
"gpu": {
"avg_ms": 0.09866036293994015,
"max_ms": 0.25088000297546387,
"p50_ms": 0.08806400001049042,
"p95_ms": 0.1740799993276596,
"p99_ms": 0.19968000054359436
},
"low_1pct_fps": 160.80638022914025,
"max_ms": 18.175312042236328,
"p50_ms": 0.2760099768638611,
"p95_ms": 0.7161250114440918,
"p99_ms": 1.4078609943389893,
"phases_avg_ms": {
"prepare": 0.20953580908804606,
"render": 0.12439006986512069,
"tick": 0.05299293669455945,
"update": 0.023244019639293335
},
"seconds": 11.999935138359666,
"slow_phases_avg_ms": {
"prepare": 0.4052206484151298,
"render": 0.41893499056840766,
"tick": 5.252727109792939,
"update": 0.0936474929949076
}
},
"forest": {
"avg_fps": 1099.4635681039538,
"cpu": {
"avg_ms": 0.9003613499442573,
"max_ms": 23.31729507446289,
"p50_ms": 0.6657320261001587,
"p95_ms": 0.9898859858512878,
"p99_ms": 9.592857360839844
},
"frames": 13194,
"gpu": {
"avg_ms": 0.2639814413552015,
"max_ms": 3.0013439655303955,
"p50_ms": 0.2549760043621063,
"p95_ms": 0.3553279936313629,
"p99_ms": 0.37171199917793274
},
"low_1pct_fps": 82.87026130908886,
"max_ms": 27.990703582763672,
"p50_ms": 0.6705269813537598,
"p95_ms": 1.0082690715789795,
"p99_ms": 9.64776611328125,
"phases_avg_ms": {
"prepare": 0.571024256178027,
"render": 0.12442494783992657,
"tick": 0.1706307831885292,
"update": 0.03442592136820218
},
"seconds": 12.000397632777691,
"slow_phases_avg_ms": {
"prepare": 0.7136096191225629,
"render": 0.43175565880356415,
"tick": 10.713303016893791,
"update": 0.05390534066651581
}
},
"ocean": {
"avg_fps": 687.0806476294383,
"cpu": {
"avg_ms": 1.4490217101725757,
"max_ms": 29.75178337097168,
"p50_ms": 0.9425880312919617,
"p95_ms": 1.6511069536209106,
"p99_ms": 15.603099822998047
},
"frames": 8245,
"gpu": {
"avg_ms": 0.3711021953564546,
"max_ms": 1.5022079944610596,
"p50_ms": 0.3665919899940491,
"p95_ms": 0.43827199935913086,
"p99_ms": 0.4679679870605469
},
"low_1pct_fps": 47.486143092805435,
"max_ms": 29.770263671875,
"p50_ms": 0.947784960269928,
"p95_ms": 1.6628600358963013,
"p99_ms": 15.621638298034668,
"phases_avg_ms": {
"prepare": 0.8449565766073126,
"render": 0.1554101111230668,
"tick": 0.3840128544967872,
"update": 0.06481705411812297
},
"seconds": 12.000046906352043,
"slow_phases_avg_ms": {
"prepare": 1.2885668083845851,
"render": 0.45316039797771407,
"tick": 19.216593845781073,
"update": 0.08380924088947744
}
},
"mountain": {
"avg_fps": 343.77852072094186,
"cpu": {
"avg_ms": 1.5961282111390964,
"max_ms": 22.24184799194336,
"p50_ms": 0.8254669904708862,
"p95_ms": 11.660508155822754,
"p99_ms": 14.398658752441406
},
"frames": 4113,
"gpu": {
"avg_ms": 0.3449904050323872,
"max_ms": 2.9040639400482178,
"p50_ms": 0.3338240087032318,
"p95_ms": 0.43724799156188965,
"p99_ms": 0.5171200037002563
},
"low_1pct_fps": 29.95597087331344,
"max_ms": 33.422760009765625,
"p50_ms": 0.8299460411071777,
"p95_ms": 33.33942413330078,
"p99_ms": 33.37727355957031,
"phases_avg_ms": {
"prepare": 0.7147062004496516,
"render": 0.14707977256612884,
"tick": 0.6855548198381525,
"update": 0.04897363143349227
},
"seconds": 11.96409825539589,
"slow_phases_avg_ms": {
"prepare": 0.8949875491006034,
"render": 0.6183000469491595,
"tick": 14.63794419879005,
"update": 0.8699657806594457
}
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 16462208
},
"village": {
"avg_fps": 1041.3622747953375,
"cpu": {
"avg_ms": 0.9549695556642304,
"max_ms": 26.009796142578125,
"p50_ms": 0.4810290038585663,
"p95_ms": 2.5506999492645264,
"p99_ms": 9.879941940307617
},
"frames": 12496,
"gpu": {
"avg_ms": 0.22781361336253403,
"max_ms": 1.7469439506530762,
"p50_ms": 0.18534399569034576,
"p95_ms": 0.6113280057907104,
"p99_ms": 0.809984028339386
},
"low_1pct_fps": 64.04348583959742,
"max_ms": 26.032779693603516,
"p50_ms": 0.48582199215888977,
"p95_ms": 2.5584940910339355,
"p99_ms": 9.892412185668945,
"phases_avg_ms": {
"prepare": 0.37678885421859964,
"render": 0.3450905326050295,
"tick": 0.1742891420732452,
"update": 0.0590072420405441
},
"seconds": 11.999666496902703,
"slow_phases_avg_ms": {
"prepare": 1.4649118003845214,
"render": 1.1414484503269196,
"tick": 12.881806396484375,
"update": 0.11011978423595428
}
},
"forest": {
"avg_fps": 316.2068527731055,
"cpu": {
"avg_ms": 3.154168446387541,
"max_ms": 26.147607803344727,
"p50_ms": 2.2156569957733154,
"p95_ms": 12.254712104797363,
"p99_ms": 17.7707462310791
},
"frames": 3795,
"gpu": {
"avg_ms": 0.7502970771905776,
"max_ms": 2.599936008453369,
"p50_ms": 0.7239680290222168,
"p95_ms": 1.3158400058746338,
"p99_ms": 1.3772799968719482
},
"low_1pct_fps": 49.59272936233826,
"max_ms": 26.164094924926758,
"p50_ms": 2.222438097000122,
"p95_ms": 12.270298957824707,
"p99_ms": 17.786956787109375,
"phases_avg_ms": {
"prepare": 1.5666706890613822,
"render": 0.7056146899387927,
"tick": 0.7469899302897249,
"update": 0.13521884845002838
},
"seconds": 12.001637430429458,
"slow_phases_avg_ms": {
"prepare": 1.9408628783727948,
"render": 1.194917630208166,
"tick": 16.762235842253034,
"update": 0.24904731876756014
}
},
"ocean": {
"avg_fps": 385.09978163469907,
"cpu": {
"avg_ms": 2.589322079316407,
"max_ms": 18.984909057617188,
"p50_ms": 2.0551369190216064,
"p95_ms": 8.740593910217285,
"p99_ms": 11.559625625610352
},
"frames": 4622,
"gpu": {
"avg_ms": 0.6778284030938654,
"max_ms": 2.398207902908325,
"p50_ms": 0.7833600044250488,
"p95_ms": 0.9431040287017822,
"p99_ms": 1.511423945426941
},
"low_1pct_fps": 71.97424221133898,
"max_ms": 19.0070858001709,
"p50_ms": 2.061105966567993,
"p95_ms": 8.753417015075684,
"p99_ms": 11.577004432678223,
"phases_avg_ms": {
"prepare": 1.3568510900633204,
"render": 0.7359144338432817,
"tick": 0.41578289517198963,
"update": 0.0810434000275639
},
"seconds": 12.002084188103677,
"slow_phases_avg_ms": {
"prepare": 1.6523635336693296,
"render": 1.2426407299143203,
"tick": 10.818658037388579,
"update": 0.16372418847489864
}
},
"mountain": {
"avg_fps": 219.70895893367268,
"cpu": {
"avg_ms": 3.098228604710274,
"max_ms": 23.518693923950195,
"p50_ms": 2.0422279834747314,
"p95_ms": 10.511551856994629,
"p99_ms": 16.436803817749023
},
"frames": 2634,
"gpu": {
"avg_ms": 0.9377313036310374,
"max_ms": 9.588735580444336,
"p50_ms": 0.8488960266113281,
"p95_ms": 0.9728000164031982,
"p99_ms": 3.4201600551605225
},
"low_1pct_fps": 29.957383952355947,
"max_ms": 33.38852310180664,
"p50_ms": 2.0481808185577393,
"p95_ms": 33.341556549072266,
"p99_ms": 33.377445220947266,
"phases_avg_ms": {
"prepare": 1.3405477057013806,
"render": 0.8202416233113071,
"tick": 0.8190081106798682,
"update": 0.11872799601335143
},
"seconds": 11.988587141752243,
"slow_phases_avg_ms": {
"prepare": 1.648652986243919,
"render": 1.7585702317732352,
"tick": 14.372832368921351,
"update": 1.0560209235107456
}
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 212,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 255897088
},
"village": {
"avg_fps": 48.3055081533635,
"cpu": {
"avg_ms": 9.376506657416572,
"max_ms": 861.2249145507812,
"p50_ms": 3.0502548217773438,
"p95_ms": 32.94725036621094,
"p99_ms": 74.04153442382812
},
"frames": 1271,
"gpu": {
"avg_ms": 1.7700931664342303,
"max_ms": 20.69094467163086,
"p50_ms": 1.2410880327224731,
"p95_ms": 7.598080158233643,
"p99_ms": 15.925248146057129
},
"low_1pct_fps": 0.761712600806078,
"max_ms": 14330.3583984375,
"p50_ms": 3.071429967880249,
"p95_ms": 33.099002838134766,
"p99_ms": 75.7547378540039,
"phases_avg_ms": {
"prepare": 1.518610580008767,
"render": 1.6828305973582713,
"tick": 5.15484752775804,
"update": 1.020680267595335
},
"seconds": 26.31169919514656,
"slow_phases_avg_ms": {
"prepare": 0.9654910931220422,
"render": 3.5708756630237284,
"tick": 196.6683132465069,
"update": 14.971596607795128
}
},
"forest": {
"avg_fps": 27.53318156189366,
"cpu": {
"avg_ms": 40.13187835841161,
"max_ms": 1166.3994140625,
"p50_ms": 6.5535569190979,
"p95_ms": 63.877315521240234,
"p99_ms": 1108.11865234375
},
"frames": 271,
"gpu": {
"avg_ms": 2.297342118961785,
"max_ms": 22.554624557495117,
"p50_ms": 0.7065600156784058,
"p95_ms": 7.245823860168457,
"p99_ms": 9.055232048034668
},
"low_1pct_fps": 0.8848759980013957,
"max_ms": 1166.4168701171875,
"p50_ms": 6.675865173339844,
"p95_ms": 59.150550842285156,
"p99_ms": 1108.134521484375,
"phases_avg_ms": {
"prepare": 2.83869483858017,
"render": 2.5961379855321343,
"tick": 33.04425997872419,
"update": 1.6535338119387186
},
"seconds": 9.842669267654419,
"slow_phases_avg_ms": {
"prepare": 2.766425689061483,
"render": 4.919464270273845,
"tick": 1112.3431803385417,
"update": 10.057182629903158
}
},
"ocean": {
"avg_fps": 67.01857611699128,
"cpu": {
"avg_ms": 14.669945189483148,
"max_ms": 113.66764068603516,
"p50_ms": 4.473541736602783,
"p95_ms": 51.374263763427734,
"p99_ms": 94.75230407714844
},
"frames": 802,
"gpu": {
"avg_ms": 2.9074973299021734,
"max_ms": 22.526975631713867,
"p50_ms": 1.8206720352172852,
"p95_ms": 8.869888305664062,
"p99_ms": 11.596799850463867
},
"low_1pct_fps": 10.111656570136391,
"max_ms": 113.68443298339844,
"p50_ms": 4.488469123840332,
"p95_ms": 51.39033508300781,
"p99_ms": 94.76870727539062,
"phases_avg_ms": {
"prepare": 2.4291724538862556,
"render": 2.242118674918006,
"tick": 9.334139847333992,
"update": 0.6651962902814967
},
"seconds": 11.966831384181976,
"slow_phases_avg_ms": {
"prepare": 2.7512698968251548,
"render": 3.2687110900878906,
"tick": 90.47323608398438,
"update": 2.3865103986528187
}
},
"mountain": {
"avg_fps": 28.70327462003782,
"cpu": {
"avg_ms": 16.062305691622306,
"max_ms": 67.72319793701172,
"p50_ms": 10.215509414672852,
"p95_ms": 45.747154235839844,
"p99_ms": 63.64399337768555
},
"frames": 345,
"gpu": {
"avg_ms": 8.470281650363535,
"max_ms": 21.519359588623047,
"p50_ms": 8.845312118530273,
"p95_ms": 15.868927955627441,
"p99_ms": 17.557504653930664
},
"low_1pct_fps": 15.182693348952107,
"max_ms": 67.74352264404297,
"p50_ms": 33.36533737182617,
"p95_ms": 46.64082336425781,
"p99_ms": 63.65925979614258,
"phases_avg_ms": {
"prepare": 0.8729904708654984,
"render": 3.4296869785889337,
"tick": 8.941314909811826,
"update": 2.8190046198558116
},
"seconds": 12.01953451538086,
"slow_phases_avg_ms": {
"prepare": 0.8918330073356628,
"render": 4.432150959968567,
"tick": 50.667978286743164,
"update": 9.854933142662048
}
}
}
},
"load_before": [
1.86328125,
2.111328125,
3.9072265625
],
"load_after": [
8.4560546875,
5.08544921875,
4.68798828125
]
}
+546
View File
@@ -0,0 +1,546 @@
{
"label": "after-lavapipe",
"date": "2026-10-13",
"commit": "9006566b",
"host": "ludodesktop",
"offscreen": false,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 156,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 1278284288
},
"village": {
"avg_fps": 58.3354572261441,
"cpu": {
"avg_ms": 11.363309368576322,
"max_ms": 36.24496841430664,
"p50_ms": 13.213258743286133,
"p95_ms": 19.535802841186523,
"p99_ms": 24.83148956298828
},
"frames": 700,
"gpu": {
"avg_ms": 13.518205827985492,
"max_ms": 34.97639083862305,
"p50_ms": 13.195829391479492,
"p95_ms": 18.039182662963867,
"p99_ms": 23.378856658935547
},
"low_1pct_fps": 31.312386939419746,
"max_ms": 36.36259078979492,
"p50_ms": 16.02706527709961,
"p95_ms": 21.249738693237305,
"p99_ms": 29.130090713500977,
"phases_avg_ms": {
"prepare": 0.2306461141364915,
"render": 9.720663217795746,
"tick": 1.3112367039202113,
"update": 0.10169148096282567
},
"seconds": 11.99956310081482,
"slow_phases_avg_ms": {
"prepare": 0.35282185673713684,
"render": 25.05345916748047,
"tick": 3.1022580585309436,
"update": 0.38026727948869976
}
},
"forest": {
"avg_fps": 41.90001789962103,
"cpu": {
"avg_ms": 18.045572770611635,
"max_ms": 36.546627044677734,
"p50_ms": 20.543725967407227,
"p95_ms": 30.5231990814209,
"p99_ms": 34.017364501953125
},
"frames": 504,
"gpu": {
"avg_ms": 21.35815803588383,
"max_ms": 36.23066329956055,
"p50_ms": 19.825437545776367,
"p95_ms": 29.274858474731445,
"p99_ms": 33.32203674316406
},
"low_1pct_fps": 20.022920779459263,
"max_ms": 99.08029174804688,
"p50_ms": 22.576229095458984,
"p95_ms": 32.772239685058594,
"p99_ms": 36.67753982543945,
"phases_avg_ms": {
"prepare": 0.22351377572686898,
"render": 16.234788014183916,
"tick": 1.4387834655649459,
"update": 0.14943464463281017
},
"seconds": 12.028634479522704,
"slow_phases_avg_ms": {
"prepare": 0.2705736731489499,
"render": 32.015567461649574,
"tick": 2.741411516830946,
"update": 0.3076988315830628
}
},
"ocean": {
"avg_fps": 60.413862302111134,
"cpu": {
"avg_ms": 10.874024665643429,
"max_ms": 29.430055618286133,
"p50_ms": 12.944002151489258,
"p95_ms": 17.997587203979492,
"p99_ms": 25.568126678466797
},
"frames": 725,
"gpu": {
"avg_ms": 12.736994123787715,
"max_ms": 28.143625259399414,
"p50_ms": 12.234904289245605,
"p95_ms": 15.61594295501709,
"p99_ms": 24.476089477539062
},
"low_1pct_fps": 34.82401357005238,
"max_ms": 33.36631393432617,
"p50_ms": 16.01299476623535,
"p95_ms": 18.74709701538086,
"p99_ms": 26.6020565032959,
"phases_avg_ms": {
"prepare": 0.4042872669162421,
"render": 9.401332649341946,
"tick": 0.9126873913477977,
"update": 0.15663292961387798
},
"seconds": 12.000557030677795,
"slow_phases_avg_ms": {
"prepare": 0.3962315060198307,
"render": 26.506223678588867,
"tick": 0.0020812499860767275,
"update": 0.5454612672328949
}
},
"mountain": {
"avg_fps": 37.77761368324693,
"cpu": {
"avg_ms": 11.491443304826092,
"max_ms": 34.474151611328125,
"p50_ms": 13.871685981750488,
"p95_ms": 20.351163864135742,
"p99_ms": 23.77434539794922
},
"frames": 453,
"gpu": {
"avg_ms": 14.109076927302953,
"max_ms": 35.115882873535156,
"p50_ms": 13.555350303649902,
"p95_ms": 18.336809158325195,
"p99_ms": 30.865131378173828
},
"low_1pct_fps": 27.343844611059406,
"max_ms": 39.0676155090332,
"p50_ms": 33.33863830566406,
"p95_ms": 33.36062240600586,
"p99_ms": 34.297752380371094,
"phases_avg_ms": {
"prepare": 0.22927819613457778,
"render": 9.350584785242217,
"tick": 1.7383085893987211,
"update": 0.17420387428015405
},
"seconds": 11.991228556632995,
"slow_phases_avg_ms": {
"prepare": 0.2908208012580872,
"render": 25.648976135253907,
"tick": 1.9043863375205548,
"update": 0.7624145835638046
}
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 36807552
},
"village": {
"avg_fps": 20.701493975921895,
"cpu": {
"avg_ms": 31.808356682459515,
"max_ms": 74.47195434570312,
"p50_ms": 41.86561584472656,
"p95_ms": 56.75548553466797,
"p99_ms": 73.00084686279297
},
"frames": 249,
"gpu": {
"avg_ms": 40.500388481140135,
"max_ms": 67.22461700439453,
"p50_ms": 40.38219451904297,
"p95_ms": 50.01243209838867,
"p99_ms": 62.00723648071289
},
"low_1pct_fps": 8.83914293384798,
"max_ms": 133.2972869873047,
"p50_ms": 47.37559509277344,
"p95_ms": 61.3382682800293,
"p99_ms": 74.61792755126953,
"phases_avg_ms": {
"prepare": 0.30830518620081215,
"render": 25.584145257750667,
"tick": 5.116575629957185,
"update": 0.8002672145165592
},
"seconds": 12.028117404937744,
"slow_phases_avg_ms": {
"prepare": 0.3641819953918457,
"render": 44.77838750680288,
"tick": 6.285618623097737,
"update": 22.415442685286205
}
},
"forest": {
"avg_fps": 18.118065350089793,
"cpu": {
"avg_ms": 36.827518428137545,
"max_ms": 85.7597427368164,
"p50_ms": 49.536197662353516,
"p95_ms": 62.31198501586914,
"p99_ms": 66.591064453125
},
"frames": 218,
"gpu": {
"avg_ms": 47.82228247616269,
"max_ms": 76.9173355102539,
"p50_ms": 46.98957824707031,
"p95_ms": 57.49673843383789,
"p99_ms": 69.92066192626953
},
"low_1pct_fps": 11.528998473378772,
"max_ms": 93.62928009033203,
"p50_ms": 53.71598434448242,
"p95_ms": 66.69908905029297,
"p99_ms": 80.71000671386719,
"phases_avg_ms": {
"prepare": 0.29564340877423595,
"render": 30.678313442326466,
"tick": 4.647872053591402,
"update": 1.206680115073099
},
"seconds": 12.032189739227295,
"slow_phases_avg_ms": {
"prepare": 0.37793900569279987,
"render": 66.15962092081706,
"tick": 5.583541075388591,
"update": 1.5741689602533977
}
},
"ocean": {
"avg_fps": 8.272097120557788,
"cpu": {
"avg_ms": 96.70907358733974,
"max_ms": 208.84027099609375,
"p50_ms": 89.04510498046875,
"p95_ms": 169.3397979736328,
"p99_ms": 208.84027099609375
},
"frames": 98,
"gpu": {
"avg_ms": 57.298700176939676,
"max_ms": 66.62165832519531,
"p50_ms": 57.89077377319336,
"p95_ms": 65.73504638671875,
"p99_ms": 66.62165832519531
},
"low_1pct_fps": 4.785729156618201,
"max_ms": 208.95457458496094,
"p50_ms": 140.0601348876953,
"p95_ms": 178.57022094726562,
"p99_ms": 208.95457458496094,
"phases_avg_ms": {
"prepare": 3.3946731230434106,
"render": 34.994675042982,
"tick": 57.14290783599932,
"update": 1.177816742536973
},
"seconds": 11.847056262969971,
"slow_phases_avg_ms": {
"prepare": 7.7776899337768555,
"render": 70.36097717285156,
"tick": 128.46734619140625,
"update": 2.2351090908050537
}
},
"mountain": {
"avg_fps": 11.65883600782651,
"cpu": {
"avg_ms": 56.6912090659987,
"max_ms": 107.11215209960938,
"p50_ms": 77.04230499267578,
"p95_ms": 97.60908508300781,
"p99_ms": 104.08527374267578
},
"frames": 141,
"gpu": {
"avg_ms": 70.81221792731486,
"max_ms": 96.33728790283203,
"p50_ms": 69.16046905517578,
"p95_ms": 86.61634063720703,
"p99_ms": 92.56858825683594
},
"low_1pct_fps": 4.93309783391495,
"max_ms": 216.64273071289062,
"p50_ms": 81.59197235107422,
"p95_ms": 104.20377349853516,
"p99_ms": 188.7820281982422,
"phases_avg_ms": {
"prepare": 0.31791428133105554,
"render": 45.35119773817401,
"tick": 9.391353438086544,
"update": 1.6317692605104852
},
"seconds": 12.093831657409668,
"slow_phases_avg_ms": {
"prepare": 0.348672017455101,
"render": 86.63534545898438,
"tick": 13.065274715423584,
"update": 5.5503233671188354
}
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 119,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 222619264
},
"village": {
"avg_fps": 5.310476400246329,
"cpu": {
"avg_ms": 111.2176975607872,
"max_ms": 224.16696166992188,
"p50_ms": 76.4469985961914,
"p95_ms": 218.82273864746094,
"p99_ms": 224.16696166992188
},
"frames": 64,
"gpu": {
"avg_ms": 121.35315322875977,
"max_ms": 171.11207580566406,
"p50_ms": 125.45780944824219,
"p95_ms": 166.71322631835938,
"p99_ms": 171.11207580566406
},
"low_1pct_fps": 3.59762228435401,
"max_ms": 277.96136474609375,
"p50_ms": 192.1078338623047,
"p95_ms": 232.86318969726562,
"p99_ms": 277.96136474609375,
"phases_avg_ms": {
"prepare": 0.524940284434706,
"render": 56.89460512250662,
"tick": 38.0364211499691,
"update": 15.762759290635586
},
"seconds": 12.051649452209473,
"slow_phases_avg_ms": {
"prepare": 0.5129500031471252,
"render": 147.3928985595703,
"tick": 62.813438415527344,
"update": 13.448556900024414
}
},
"forest": {
"avg_fps": 3.899515654388116,
"cpu": {
"avg_ms": 155.46855975211935,
"max_ms": 270.7467041015625,
"p50_ms": 83.60679626464844,
"p95_ms": 268.6576843261719,
"p99_ms": 270.7467041015625
},
"frames": 47,
"gpu": {
"avg_ms": 192.77972445280656,
"max_ms": 225.69723510742188,
"p50_ms": 189.4962615966797,
"p95_ms": 217.36190795898438,
"p99_ms": 225.69723510742188
},
"low_1pct_fps": 3.3773198460417087,
"max_ms": 296.0927734375,
"p50_ms": 259.49017333984375,
"p95_ms": 283.4007568359375,
"p99_ms": 296.0927734375,
"phases_avg_ms": {
"prepare": 0.5114446409205173,
"render": 99.58217374821926,
"tick": 43.03971359577585,
"update": 12.336258025879555
},
"seconds": 12.052778900146484,
"slow_phases_avg_ms": {
"prepare": 0.5626839995384216,
"render": 208.71124267578125,
"tick": 53.47932052612305,
"update": 7.994467258453369
}
},
"ocean": {
"avg_fps": 2.9455219461811217,
"cpu": {
"avg_ms": 287.98993862376494,
"max_ms": 464.7646484375,
"p50_ms": 286.4447326660156,
"p95_ms": 444.6121520996094,
"p99_ms": 464.7646484375
},
"frames": 34,
"gpu": {
"avg_ms": 116.96576734270369,
"max_ms": 158.26394653320312,
"p50_ms": 106.45819854736328,
"p95_ms": 158.1454620361328,
"p99_ms": 158.26394653320312
},
"low_1pct_fps": 2.151113067092828,
"max_ms": 464.8756103515625,
"p50_ms": 315.1367492675781,
"p95_ms": 456.3304138183594,
"p99_ms": 464.8756103515625,
"phases_avg_ms": {
"prepare": 4.395756518139558,
"render": 69.52432030789993,
"tick": 206.13154108384077,
"update": 7.93927874284632
},
"seconds": 11.542945739746093,
"slow_phases_avg_ms": {
"prepare": 4.144052982330322,
"render": 166.14959716796875,
"tick": 285.5132751464844,
"update": 8.958683967590332
}
},
"mountain": {
"avg_fps": 3.983176689759444,
"cpu": {
"avg_ms": 171.41208145466257,
"max_ms": 413.8367919921875,
"p50_ms": 164.3318328857422,
"p95_ms": 360.7731628417969,
"p99_ms": 413.8367919921875
},
"frames": 47,
"gpu": {
"avg_ms": 166.80018520355225,
"max_ms": 304.8785400390625,
"p50_ms": 143.19728088378906,
"p95_ms": 275.3953552246094,
"p99_ms": 304.8785400390625
},
"low_1pct_fps": 2.418344167836982,
"max_ms": 413.5060729980469,
"p50_ms": 230.7030029296875,
"p95_ms": 365.4028625488281,
"p99_ms": 413.5060729980469,
"phases_avg_ms": {
"prepare": 0.496992789684458,
"render": 94.4349257743105,
"tick": 61.33542941478973,
"update": 15.14569672117842
},
"seconds": 11.799627197265625,
"slow_phases_avg_ms": {
"prepare": 0.4748440086841583,
"render": 312.3215637207031,
"tick": 86.37409973144531,
"update": 14.667168617248535
}
}
}
},
"load_before": [
8.4560546875,
5.08544921875,
4.68798828125
],
"load_after": [
10.98291015625,
7.201171875,
5.5615234375
]
}
+392
View File
@@ -0,0 +1,392 @@
{
"label": "baseline-4080",
"date": "2026-10-13",
"commit": "27a32cc3",
"host": "ludodesktop",
"offscreen": true,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 158,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 1137747200
},
"village": {
"avg_fps": 362.94072531330323,
"cpu": {
"avg_ms": 2.734311161203731,
"max_ms": 68.06377410888672,
"p50_ms": 1.4820960760116577,
"p95_ms": 7.922675609588623,
"p99_ms": 35.78989028930664
},
"frames": 4373,
"gpu": {
"avg_ms": 0.4188921034758625,
"max_ms": 2.5876479148864746,
"p50_ms": 0.4106239974498749,
"p95_ms": 0.5283839702606201,
"p99_ms": 0.5427200198173523
},
"low_1pct_fps": 21.64332698060583,
"max_ms": 68.08219146728516,
"p50_ms": 1.4894599914550781,
"p95_ms": 7.936410427093506,
"p99_ms": 36.54151153564453,
"seconds": 12.048799418210983
},
"forest": {
"avg_fps": 613.1553113846195,
"cpu": {
"avg_ms": 1.6186122214214547,
"max_ms": 216.98960876464844,
"p50_ms": 1.10076105594635,
"p95_ms": 3.0016868114471436,
"p99_ms": 10.144570350646973
},
"frames": 6746,
"gpu": {
"avg_ms": 0.395751640401594,
"max_ms": 1.99782395362854,
"p50_ms": 0.3983359932899475,
"p95_ms": 0.4833280146121979,
"p99_ms": 0.5375999808311462
},
"low_1pct_fps": 49.639170314777054,
"max_ms": 217.0211181640625,
"p50_ms": 1.109616994857788,
"p95_ms": 3.0161218643188477,
"p99_ms": 10.163111686706543,
"seconds": 11.002106439828873
},
"ocean": {
"avg_fps": 323.23791637473414,
"cpu": {
"avg_ms": 2.7230400277200597,
"max_ms": 4748.7744140625,
"p50_ms": 0.9233829975128174,
"p95_ms": 1.677724003791809,
"p99_ms": 7.7397589683532715
},
"frames": 3870,
"gpu": {
"avg_ms": 0.24116999250656182,
"max_ms": 0.954367995262146,
"p50_ms": 0.2754560112953186,
"p95_ms": 0.3389439880847931,
"p99_ms": 0.3532800078392029
},
"low_1pct_fps": 5.421154869716093,
"max_ms": 4748.80615234375,
"p50_ms": 0.9285120368003845,
"p95_ms": 1.7079449892044067,
"p99_ms": 33.33257293701172,
"seconds": 11.97260532861948
},
"mountain": {
"avg_fps": 28.19080143244723,
"cpu": {
"avg_ms": 12.358911560308298,
"max_ms": 187.3409881591797,
"p50_ms": 9.513923645019531,
"p95_ms": 25.783824920654297,
"p99_ms": 110.71585845947266
},
"frames": 338,
"gpu": {
"avg_ms": 0.46002139662497143,
"max_ms": 1.5626239776611328,
"p50_ms": 0.4546560049057007,
"p95_ms": 0.5140479803085327,
"p99_ms": 0.5959680080413818
},
"low_1pct_fps": 7.428141780247131,
"max_ms": 187.36033630371094,
"p50_ms": 33.334835052490234,
"p95_ms": 35.65903854370117,
"p99_ms": 110.73509216308594,
"seconds": 11.98972653579712
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 54284928
},
"village": {
"avg_fps": 7.569165639843409,
"cpu": {
"avg_ms": 138.25648041658624,
"max_ms": 877.6473999023438,
"p50_ms": 103.97663879394531,
"p95_ms": 318.5262145996094,
"p99_ms": 877.6473999023438
},
"frames": 86,
"gpu": {
"avg_ms": 3.585262137790059,
"max_ms": 8.415231704711914,
"p50_ms": 3.299328088760376,
"p95_ms": 7.171072006225586,
"p99_ms": 8.415231704711914
},
"low_1pct_fps": 2.44461512120682,
"max_ms": 409.0623474121094,
"p50_ms": 104.01687622070312,
"p95_ms": 318.5569763183594,
"p99_ms": 409.0623474121094,
"seconds": 11.361886381149292
},
"forest": {
"avg_fps": 27.65529078792617,
"cpu": {
"avg_ms": 37.926895780927815,
"max_ms": 3484.08349609375,
"p50_ms": 3.398620128631592,
"p95_ms": 44.56385040283203,
"p99_ms": 684.4393920898438
},
"frames": 314,
"gpu": {
"avg_ms": 2.683724634467986,
"max_ms": 9.881600379943848,
"p50_ms": 1.8923519849777222,
"p95_ms": 7.221248149871826,
"p99_ms": 9.04089641571045
},
"low_1pct_fps": 0.4753349245465373,
"max_ms": 3484.115234375,
"p50_ms": 3.5420219898223877,
"p95_ms": 44.591251373291016,
"p99_ms": 159.3817901611328,
"seconds": 11.354066113710404
},
"ocean": {
"avg_fps": 59.16125632179682,
"cpu": {
"avg_ms": 16.807428855239674,
"max_ms": 106.0348129272461,
"p50_ms": 9.335607528686523,
"p95_ms": 44.57380294799805,
"p99_ms": 84.97154998779297
},
"frames": 712,
"gpu": {
"avg_ms": 1.958949391112736,
"max_ms": 8.635392189025879,
"p50_ms": 1.1182080507278442,
"p95_ms": 5.493760108947754,
"p99_ms": 7.108607769012451
},
"low_1pct_fps": 10.542641239411056,
"max_ms": 106.06544494628906,
"p50_ms": 9.364562034606934,
"p95_ms": 45.14366912841797,
"p99_ms": 84.9927749633789,
"seconds": 12.034903317928315
},
"mountain": {
"avg_fps": 26.78175419456072,
"cpu": {
"avg_ms": 20.230309660251326,
"max_ms": 128.05508422851562,
"p50_ms": 15.369550704956055,
"p95_ms": 67.15973663330078,
"p99_ms": 104.6961898803711
},
"frames": 325,
"gpu": {
"avg_ms": 7.143625645270714,
"max_ms": 21.25823974609375,
"p50_ms": 7.187456130981445,
"p95_ms": 12.228608131408691,
"p99_ms": 19.310592651367188
},
"low_1pct_fps": 7.777799947825389,
"max_ms": 155.30685424804688,
"p50_ms": 33.335182189941406,
"p95_ms": 68.94293975830078,
"p99_ms": 109.09304809570312,
"seconds": 12.135127431869506
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 0,
"gpu": "NVIDIA GeForce RTX 4080",
"present_mode": "Immediate",
"size": [
1280,
720
],
"uploaded_bytes": 4829184
},
"village": {
"avg_fps": 4.1477100728464,
"cpu": {
"avg_ms": 244.17284844846142,
"max_ms": 386.7664489746094,
"p50_ms": 244.8783416748047,
"p95_ms": 363.0765380859375,
"p99_ms": 386.7664489746094
},
"frames": 49,
"gpu": {
"avg_ms": 10.357822744213806,
"max_ms": 23.010303497314453,
"p50_ms": 9.227264404296875,
"p95_ms": 19.309568405151367,
"p99_ms": 23.010303497314453
},
"low_1pct_fps": 2.5853636290905797,
"max_ms": 386.79278564453125,
"p50_ms": 230.8652801513672,
"p95_ms": 363.0941162109375,
"p99_ms": 386.79278564453125,
"seconds": 11.813747619628906
},
"forest": {
"avg_fps": 8.3624969613463,
"cpu": {
"avg_ms": 118.74922356747165,
"max_ms": 278.8187255859375,
"p50_ms": 123.03407287597656,
"p95_ms": 231.6608123779297,
"p99_ms": 260.8450622558594
},
"frames": 101,
"gpu": {
"avg_ms": 15.898816661669477,
"max_ms": 72.880126953125,
"p50_ms": 20.789247512817383,
"p95_ms": 28.441600799560547,
"p99_ms": 60.816383361816406
},
"low_1pct_fps": 3.7057290596141406,
"max_ms": 278.83917236328125,
"p50_ms": 123.19290161132812,
"p95_ms": 231.68026733398438,
"p99_ms": 260.8656921386719,
"seconds": 12.077732340812684
},
"ocean": {
"avg_fps": 2.3954947141714045,
"cpu": {
"avg_ms": 424.7621285574777,
"max_ms": 625.8532104492188,
"p50_ms": 435.64044189453125,
"p95_ms": 577.7593994140625,
"p99_ms": 625.8532104492188
},
"frames": 28,
"gpu": {
"avg_ms": 9.483117733682905,
"max_ms": 12.973055839538574,
"p50_ms": 9.90719985961914,
"p95_ms": 12.610560417175293,
"p99_ms": 12.973055839538574
},
"low_1pct_fps": 1.5977632874291703,
"max_ms": 625.8749389648438,
"p50_ms": 433.9859619140625,
"p95_ms": 577.7803955078125,
"p99_ms": 625.8749389648438,
"seconds": 11.688608551025391
},
"mountain": {
"avg_fps": 3.3756775332390268,
"cpu": {
"avg_ms": 300.512197249975,
"max_ms": 407.4363708496094,
"p50_ms": 292.483642578125,
"p95_ms": 394.8424377441406,
"p99_ms": 407.4363708496094
},
"frames": 39,
"gpu": {
"avg_ms": 9.728866460995796,
"max_ms": 18.930688858032227,
"p50_ms": 8.820735931396484,
"p95_ms": 17.399808883666992,
"p99_ms": 18.930688858032227
},
"low_1pct_fps": 2.4541792422631716,
"max_ms": 407.46820068359375,
"p50_ms": 292.4177551269531,
"p95_ms": 394.8649597167969,
"p99_ms": 407.46820068359375,
"seconds": 11.553236236572266
}
}
}
}
+392
View File
@@ -0,0 +1,392 @@
{
"label": "baseline-lavapipe",
"date": "2026-10-13",
"commit": "27a32cc3",
"host": "ludodesktop",
"offscreen": false,
"seed": "1234",
"flight_seconds": 12.0,
"scenes": {
"village": {
"from": [
-960.0,
125.0,
80.0
],
"to": [
-960.0,
125.0,
272.0
],
"pitch": 35.0
},
"forest": {
"from": [
-1100.0,
120.0,
-560.0
],
"to": [
-1100.0,
120.0,
-368.0
],
"pitch": 25.0
},
"ocean": {
"from": [
-300.0,
85.0,
-420.0
],
"to": [
-300.0,
85.0,
-228.0
],
"pitch": 10.0
},
"mountain": {
"from": [
560.0,
260.0,
860.0
],
"to": [
752.0,
260.0,
860.0
],
"pitch": 30.0
}
},
"distances": {
"8": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 146,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 1288851072
},
"village": {
"avg_fps": 16.38225374384963,
"cpu": {
"avg_ms": 48.17248754105975,
"max_ms": 134.31028747558594,
"p50_ms": 44.57072067260742,
"p95_ms": 97.15985870361328,
"p99_ms": 127.31073760986328
},
"frames": 199,
"gpu": {
"avg_ms": 26.732369270324707,
"max_ms": 47.3107795715332,
"p50_ms": 26.284116744995117,
"p95_ms": 35.006874084472656,
"p99_ms": 39.38005828857422
},
"low_1pct_fps": 6.569673692138871,
"max_ms": 169.97352600097656,
"p50_ms": 56.791988372802734,
"p95_ms": 109.15220642089844,
"p99_ms": 134.45559692382812,
"seconds": 12.147290788650512
},
"forest": {
"avg_fps": 19.917172468484612,
"cpu": {
"avg_ms": 31.039839970568817,
"max_ms": 106.39506530761719,
"p50_ms": 33.26064682006836,
"p95_ms": 70.09876251220703,
"p99_ms": 86.21255493164062
},
"frames": 240,
"gpu": {
"avg_ms": 37.272586759195285,
"max_ms": 77.586669921875,
"p50_ms": 34.38500213623047,
"p95_ms": 59.649635314941406,
"p99_ms": 66.52396392822266
},
"low_1pct_fps": 9.909291086862112,
"max_ms": 106.57542419433594,
"p50_ms": 46.05862808227539,
"p95_ms": 80.38935852050781,
"p99_ms": 94.94140625,
"seconds": 12.049903186798096
},
"ocean": {
"avg_fps": 27.969763129786234,
"cpu": {
"avg_ms": 25.69895526429727,
"max_ms": 930.7354125976562,
"p50_ms": 18.981081008911133,
"p95_ms": 39.42117691040039,
"p99_ms": 168.07225036621094
},
"frames": 336,
"gpu": {
"avg_ms": 18.856842384451912,
"max_ms": 57.835872650146484,
"p50_ms": 18.172868728637695,
"p95_ms": 28.62451171875,
"p99_ms": 44.414398193359375
},
"low_1pct_fps": 1.527565223414601,
"max_ms": 930.8734741210938,
"p50_ms": 26.45800018310547,
"p95_ms": 50.053672790527344,
"p99_ms": 180.92478942871094,
"seconds": 12.012972667694092
},
"mountain": {
"avg_fps": 26.5942562164059,
"cpu": {
"avg_ms": 22.482411369495093,
"max_ms": 104.89199829101562,
"p50_ms": 22.033512115478516,
"p95_ms": 46.385555267333984,
"p99_ms": 61.15697479248047
},
"frames": 320,
"gpu": {
"avg_ms": 23.762589550018312,
"max_ms": 48.10239028930664,
"p50_ms": 22.580347061157227,
"p95_ms": 35.14606475830078,
"p99_ms": 42.47938919067383
},
"low_1pct_fps": 11.197907022053611,
"max_ms": 105.07585906982422,
"p50_ms": 33.362815856933594,
"p95_ms": 53.36695098876953,
"p99_ms": 74.80219268798828,
"seconds": 12.03267342376709
}
},
"16": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 16106880
},
"village": {
"avg_fps": 10.754658718707336,
"cpu": {
"avg_ms": 69.22208800682655,
"max_ms": 193.21267700195312,
"p50_ms": 66.95130157470703,
"p95_ms": 154.44834899902344,
"p99_ms": 191.29208374023438
},
"frames": 130,
"gpu": {
"avg_ms": 49.041961078495945,
"max_ms": 73.83775329589844,
"p50_ms": 48.90928268432617,
"p95_ms": 60.60416793823242,
"p99_ms": 66.1044921875
},
"low_1pct_fps": 4.171866301284614,
"max_ms": 252.21458435058594,
"p50_ms": 79.8940200805664,
"p95_ms": 173.05947875976562,
"p99_ms": 227.18716430664062,
"seconds": 12.087784782409669
},
"forest": {
"avg_fps": 12.167278778001634,
"cpu": {
"avg_ms": 58.366991678873696,
"max_ms": 156.683349609375,
"p50_ms": 69.24014282226562,
"p95_ms": 110.83757019042969,
"p99_ms": 134.16554260253906
},
"frames": 147,
"gpu": {
"avg_ms": 56.45229888606716,
"max_ms": 78.26596069335938,
"p50_ms": 55.92947006225586,
"p95_ms": 63.54412841796875,
"p99_ms": 66.5202407836914
},
"low_1pct_fps": 5.722095670390665,
"max_ms": 212.99729919433594,
"p50_ms": 77.36986541748047,
"p95_ms": 122.84784698486328,
"p99_ms": 136.52499389648438,
"seconds": 12.081583950042724
},
"ocean": {
"avg_fps": 7.681804845753147,
"cpu": {
"avg_ms": 98.35419226740743,
"max_ms": 385.2493591308594,
"p50_ms": 78.85044860839844,
"p95_ms": 225.36231994628906,
"p99_ms": 385.2493591308594
},
"frames": 91,
"gpu": {
"avg_ms": 71.51263585297957,
"max_ms": 128.29212951660156,
"p50_ms": 67.92412567138672,
"p95_ms": 101.93219757080078,
"p99_ms": 128.29212951660156
},
"low_1pct_fps": 2.5948566279945817,
"max_ms": 385.37774658203125,
"p50_ms": 123.91693878173828,
"p95_ms": 225.49420166015625,
"p99_ms": 385.37774658203125,
"seconds": 11.846174411773681
},
"mountain": {
"avg_fps": 5.886844433101332,
"cpu": {
"avg_ms": 124.87309415575484,
"max_ms": 321.2157897949219,
"p50_ms": 117.2811050415039,
"p95_ms": 237.34304809570312,
"p99_ms": 321.2157897949219
},
"frames": 71,
"gpu": {
"avg_ms": 95.46965434816148,
"max_ms": 166.9488067626953,
"p50_ms": 83.54202270507812,
"p95_ms": 160.2403564453125,
"p99_ms": 166.9488067626953
},
"low_1pct_fps": 3.111272818975726,
"max_ms": 321.411865234375,
"p50_ms": 153.0963134765625,
"p95_ms": 275.37579345703125,
"p99_ms": 321.411865234375,
"seconds": 12.060790939331055
}
},
"32": {
"renderer": {
"backend": "Vulkan",
"drawn_sections": 1,
"gpu": "llvmpipe (LLVM 21.1.8, 256 bits)",
"present_mode": "Mailbox",
"size": [
1600,
1000
],
"uploaded_bytes": 19154560
},
"village": {
"avg_fps": 2.5956714101148957,
"cpu": {
"avg_ms": 345.63314671670236,
"max_ms": 474.2013854980469,
"p50_ms": 347.07415771484375,
"p95_ms": 468.0090026855469,
"p99_ms": 474.2013854980469
},
"frames": 31,
"gpu": {
"avg_ms": 85.50063645839691,
"max_ms": 134.11460876464844,
"p50_ms": 82.40946960449219,
"p95_ms": 133.71929931640625,
"p99_ms": 134.11460876464844
},
"low_1pct_fps": 2.01406006678458,
"max_ms": 496.509521484375,
"p50_ms": 390.230224609375,
"p95_ms": 493.8070373535156,
"p99_ms": 496.509521484375,
"seconds": 11.942960067749024
},
"forest": {
"avg_fps": 2.2283930626963917,
"cpu": {
"avg_ms": 354.7813931978666,
"max_ms": 611.8099975585938,
"p50_ms": 293.61376953125,
"p95_ms": 601.3502197265625,
"p99_ms": 611.8099975585938
},
"frames": 26,
"gpu": {
"avg_ms": 201.54420654296874,
"max_ms": 357.0310363769531,
"p50_ms": 172.65980529785156,
"p95_ms": 341.8655700683594,
"p99_ms": 357.0310363769531
},
"low_1pct_fps": 1.52339962842959,
"max_ms": 656.4265747070312,
"p50_ms": 407.1051330566406,
"p95_ms": 611.97119140625,
"p99_ms": 656.4265747070312,
"seconds": 11.667600494384766
},
"ocean": {
"avg_fps": 2.6137193379326398,
"cpu": {
"avg_ms": 330.783615620931,
"max_ms": 730.7304077148438,
"p50_ms": 296.65692138671875,
"p95_ms": 680.8717041015625,
"p99_ms": 730.7304077148438
},
"frames": 30,
"gpu": {
"avg_ms": 113.80178985595703,
"max_ms": 216.57342529296875,
"p50_ms": 104.23703002929688,
"p95_ms": 193.67672729492188,
"p99_ms": 216.57342529296875
},
"low_1pct_fps": 1.1784160273210693,
"max_ms": 848.5967407226562,
"p50_ms": 346.120849609375,
"p95_ms": 681.0716552734375,
"p99_ms": 848.5967407226562,
"seconds": 11.477896484375
},
"mountain": {
"avg_fps": 2.72441042065798,
"cpu": {
"avg_ms": 325.24547207740045,
"max_ms": 578.7177124023438,
"p50_ms": 295.25457763671875,
"p95_ms": 569.8099975585938,
"p99_ms": 578.7177124023438
},
"frames": 31,
"gpu": {
"avg_ms": 104.99177145957947,
"max_ms": 163.95635986328125,
"p50_ms": 99.96784210205078,
"p95_ms": 160.04824829101562,
"p99_ms": 163.95635986328125
},
"low_1pct_fps": 1.7544071931551564,
"max_ms": 569.9931030273438,
"p50_ms": 378.246337890625,
"p95_ms": 555.5369873046875,
"p99_ms": 569.9931030273438,
"seconds": 11.378608657836914
}
}
}
}
@@ -0,0 +1,69 @@
# Reuse parsed model states
`models::prepare` parsed `model-states.json` to select blocks, then
`StateModels::load` reopened and parsed the same document for binding and baking.
`StateModels::from_facts` now borrows the first parsed map. The public `load`
entry point still reads, parses and validates its own document. Both paths retain
the `fs::read` + `serde_json::from_slice` strategy merged in PR #205.
The shared map uses `BTreeMap`, preserving the original catalog loader's JSON
validation, including rejection of top-level `null`. The old selection map was
`serde_json::Map`, also backed by a BTreeMap with the project's current features.
Catalog binding, model selection and baking are unchanged. Preparation no longer
holds a second complete JSON tree while constructing the catalog; no persistent
cache or extra clones were added.
## Measurement
The committed `model_states_bench` example runs main's two-parse sequence after PR #205
(first a serde JSON map, then `StateModels::load`) and the new one-parse sequence
(first a BTreeMap, then `StateModels::from_facts`). Both select blocks from the
same generated document with slice parsing and call the actual catalog binding/baking code. Timing
includes file reads, parsing, filtering, binding, baking and result destruction.
Fixture generation and equality checks run outside the timed region.
Prepared Minecraft resources were unavailable locally, so this is a synthetic
2,836,696-byte document containing 843 block schemas and 53,952 states. Schemas
combine integer and named properties. Blockstate resources use weighted and
conditional multipart choices with rotations and UV locking, sharing one model
with a baked face. Its absent texture uses the normal missing-sprite fallback.
Both paths read identical files with a warm OS cache. The workload does not
represent the full diversity of production model and texture assets.
On AMD Ryzen 5 5600X, Linux 7.2.8-zen1-2-zen x86_64, rustc 1.99.0
(b940084d7 2026-09-28), Cargo `fast` profile, serial execution:
| Path | Median (15 samples) |
| --- | ---: |
| Before: parse twice, with #205 slice readers | 96.080 ms |
| After: reuse first parse, with #205 slice readers | 73.585 ms |
**1.306x speedup; 23.41% less elapsed time** for this loading segment after PR #205. Each path
has two warmups, sample order alternates, and inputs/results use `black_box`.
This excludes gallery placements, GPU work and overall application startup.
These measurements supersede the original unbuffered-reader timings
(2732.004 ms → 1386.060 ms, 1.971x) after rebasing onto main at
`e96e1571`. The reviewer measured approximately 1.05x with real prepared resources;
the generated fixture's balance of parsing and binding differs from production.
The change still removes one complete document read, parse and JSON tree.
Equality validation compares every state's selected variants and baked geometry
at three positions, plus sprite layers and opacity/translucency flags. The
focused test also checks matching schema/registry errors, malformed/missing
source files, rejection of `null`, and successful shared-facts loading after the
source file has been deleted.
## Reproduce
```sh
export CARGO_TARGET_DIR=/home/bob/Documents/horizon/target
export CARGO_INCREMENTAL=0
cargo run -p horizon-client --profile fast --example model_states_bench -- 15
cargo test -p horizon-client --profile fast --example model_states_bench
cargo clippy -p horizon-client --all-targets --profile fast -- -D warnings
cargo fmt --all -- --check
```
The target directory can be changed for another machine. The example creates and
removes its fixtures in the system temporary directory.
@@ -0,0 +1,54 @@
{
"date": "2026-10-15",
"environment": "Linux x86_64/ext4/NVMe, Ryzen5 5600X, Rust1.99.0, Cargo fast",
"scope": "actual resource_root vs original wrapper/builder; warmed storage; output equivalence outside timing",
"warmups": 2,
"median_samples": 11,
"prepared_minecraft": "26.3",
"prepared_schema": 7,
"prepared_manifest_sha256": "fd03c686302748748e132ef3978b7b0bcf42024257e802985a5cc7175cc74298",
"packs": [
{
"project": "faithful-32x",
"version": "lDYpMiqk",
"version_name": "[26.3] Faithful 32x \u2013 September 2026 Release",
"publisher_page": "https://modrinth.com/resourcepack/faithful-32x/version/lDYpMiqk",
"download_url": "https://cdn.modrinth.com/data/w0TnApzs/versions/lDYpMiqk/Faithful%2032x%20-%2026.3.zip",
"zip_bytes": 13116019,
"zip_sha512": "e90ec63af7f239dbb99aa20c98b23d830e76898f65d3e31b7f0b53bb36db12dcd8c38d6930c743237c9c3db7bf708423fa1d1b2fd497dd6c281dbe216b779db9",
"asset_files": 5063,
"assets_uncompressed_bytes": 12524266,
"original_ms": 159.827,
"cold_ms": 162.711,
"hit_ms": 0.707
},
{
"project": "default-hd-128x",
"version": "rErHu3BE",
"version_name": "Default HD 128x Demo/Alpha 1.8.2.7",
"publisher_page": "https://modrinth.com/resourcepack/default-hd-128x/version/rErHu3BE",
"download_url": "https://cdn.modrinth.com/data/orfTV6nk/versions/rErHu3BE/Default%20HD%20128x%20Demo%201.8.2.7.zip",
"zip_bytes": 16876419,
"zip_sha512": "f5f9606e92b986cd9c8b8e5ff2bb52db720cc7b38e013355bdc90089e01975a08d09750ef2fd727f753d88ec3fbcd067355179b490587f667b2995f8be81640f",
"asset_files": 2318,
"assets_uncompressed_bytes": 17315416,
"original_ms": 111.501,
"cold_ms": 114.386,
"hit_ms": 0.31
},
{
"project": "fpbr",
"version": "dynf82xk",
"version_name": "support 1.21.9 and newer",
"publisher_page": "https://modrinth.com/resourcepack/fpbr/version/dynf82xk",
"download_url": "https://cdn.modrinth.com/data/nvEpbd8t/versions/dynf82xk/FPBR-Main-1.21.9-R1.3.zip",
"zip_bytes": 46737770,
"zip_sha512": "64f1bbd036e205118f1d9c49a79558db2c1356ee988fb939c0023db1967d78e1bd05047f341ce6d65355717ba7ddb1330af57edbb4d5b8713c96ae62987274c5",
"asset_files": 7001,
"assets_uncompressed_bytes": 56356903,
"original_ms": 347.639,
"cold_ms": 348.012,
"hit_ms": 0.448
}
]
}
+174
View File
@@ -0,0 +1,174 @@
# Dedicated server against vanilla 26.3, with bots (2026-10-17)
`horizon-client --dedicated` (run by `horizon-server`) against the pinned vanilla 26.3 `server.jar`, on `main`
at `7bbbf6e9` (in release v2026.10.17.17). That build has the movement checks and chunk pacing of #586, the
equal-work method of #670, and the server performance PRs #701–#707 and #713.
This replaces the data first published here, which came from a Mac mini overloaded by other work (load 55–141 on
14 threads). An earlier run of this method, on `main` at `002f14ce`, is kept in `earlier-002f14ce/` and compared
at the end.
## Setup
- **Server machine:** ThinkPad, Intel i7-7500U (2 cores, 4 threads), 32 GB, NixOS. Nothing else heavy ran on it:
its load average was 0.4 when idle. Both servers ran under `nice -n 19`.
- **Bots:** `horizon-bots` on another machine (Mac mini M4 Pro), over the tailnet. That machine was shared with
other work (load 36–170 on 14 threads, mean 78). The bots need little CPU, but a busy bot host can answer later.
Both servers saw the same conditions, since they took turns at each bot count.
- **Horizon:** release build of `7bbbf6e9` (`horizon-server`, `horizon-client`).
- **Vanilla:** the pinned 26.3 `server.jar` on OpenJDK 25.0.4.1, with `-Xms4g -Xmx4g`.
- **Properties:**
- `level-seed=12345`, view and simulation distance 10, creative (forced);
- `online-mode=false`, `difficulty=easy`, `max-players=200`;
- default game rules on both (the locator bar is on).
- **Each run:** a fresh server, bots joining one per second, a 60 s warm-up, then 180 s measured.
- **Commands** (on the server machine):
```
python3 tools/server_bench.py --servers horizon,vanilla --nice 19 --warmup 60 --seconds 180 \
--bots-via "ssh <bot host>" --bots-bin <horizon-bots> --connect <server address> \
--bots 1,10,25,50,100 --behaviour walk,explore
python3 tools/server_bench.py ...same... --bots 1,10,25,50 --behaviour explore --explore-speed 0.8
```
- **Columns:**
- **MSPT** (mean) and **P95** come from `/tick query`, in ms. **TPS** is the tick rate.
- **RSS** is the mean over the run. **CPU** is the whole process tree, as a percentage of one core.
- **Chunks/s** is what the bots received. **New chunks/s** counts each chunk once per bot.
- **Pulled back** counts the times the server moved a bot back (vanilla's "moved too quickly" and similar).
- **Load** is the mean load average during that run: the server machine (which includes the server's own
threads) and the bot host.
## Results
### Equal work: bots explore at the same pace (`--explore-speed 0.8`)
Each bot flies outwards at 0.8 blocks a second, which both servers keep up with. They send the same chunks, so
the tick times compare the same work.
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | Pulled back | Load (server / bots) |
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | 3.16 | 5.78 | 20.0 | 0.76 GB | 22 % | 2 | 1.27 | 0 | 2.8 / 63 |
| 1 | Vanilla | 16.82 | 27.3 | 20.0 | 3.37 GB | 69 % | 2 | 1.35 | 0 | 1.8 / 94 |
| 10 | Horizon | 9.83 | 19.01 | 20.0 | 0.86 GB | 77 % | 14 | 12.31 | 1051 | 2.2 / 74 |
| 10 | Vanilla | 24.38 | 39.58 | 20.0 | 3.57 GB | 132 % | 14 | 12.32 | 1027 | 2.5 / 64 |
| 25 | Horizon | 16.87 | 30.88 | 20.0 | 0.89 GB | 111 % | 34 | 29.92 | 3770 | 5.1 / 51 |
| 25 | Vanilla | 30.29 | 43.47 | 20.0 | 3.61 GB | 176 % | 33 | 29.29 | 5139 | 5.2 / 55 |
| 50 | Horizon | 23.57 | 47.53 | 20.0 | 0.92 GB | 127 % | 67 | 59.42 | 7434 | 8.5 / 64 |
| 50 | Vanilla | 42.44 | 54.84 | 20.0 | 3.67 GB | 201 % | 65 | 58.11 | 10604 | 5.1 / 59 |
### `walk`: bots walk around the spawn
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | Pulled back | Load (server / bots) |
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | 4.01 | 8.19 | 20.0 | 0.78 GB | 59 % | 10 | 0.43 | 0 | 1.4 / 70 |
| 1 | Vanilla | 18.22 | 29.34 | 20.0 | 3.43 GB | 69 % | 10 | 0.47 | 0 | 1.9 / 63 |
| 10 | Horizon | 8.79 | 17.35 | 20.0 | 0.78 GB | 119 % | 95 | 3.58 | 0 | 2.9 / 69 |
| 10 | Vanilla | 18.67 | 38.26 | 20.0 | 3.50 GB | 101 % | 101 | 4.38 | 5 | 3.2 / 59 |
| 25 | Horizon | 15.01 | 31.12 | 20.0 | 0.80 GB | 161 % | 234 | 8.49 | 0 | 6.3 / 43 |
| 25 | Vanilla | 18.78 | 28.16 | 20.0 | 3.52 GB | 155 % | 252 | 11.89 | 10 | 6.7 / 57 |
| 50 | Horizon | 22.72 | 60.19 | 20.0 | 0.81 GB | 180 % | 465 | 16.94 | 0 | 14.7 / 50 |
| 50 | Vanilla | 34.3 | 55.55 | 20.0 | 3.58 GB | 267 % | 497 | 26.74 | 39 | 9.5 / 56 |
| 100 | Horizon | 63.33 | 125.04 | 15.79 | 0.87 GB | 246 % | 678 | 31.8 | 0 | 32.9 / 95 |
| 100 | Vanilla | stopped by its watchdog (a 60 s tick) | | | | | | | | 15.1 / 120 |
Both servers sent each walking bot the same number of distinct chunks over the whole run (680–690). Horizon's
"new chunks/s" inside the measured window is lower, so compare the totals, not that column.
### `explore` at full speed: unequal work
Bots fly outwards as fast as chunks arrive (`horizon-bots`' default, up to 22 blocks a second). Both servers now pull them back ("moved too quickly"), but Horizon sends
more new terrain, so this pass doesn't compare equal work. The server-machine load of Horizon's 1-bot run is
estimated from the time between the runs around it: its log was overwritten before it was read.
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | Pulled back | Load (server / bots) |
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | 9.73 | 20.99 | 20.0 | 0.84 GB | 330 % | 41 | 35.96 | 0 | 4.7 / 129 |
| 1 | Vanilla | 9.71 | 20.37 | 20.0 | 3.61 GB | 247 % | 35 | 33.61 | 0 | 4.3 / 156 |
| 10 | Horizon | 21.27 | 39.15 | 20.0 | 1.18 GB | 322 % | 46 | 46.11 | 9856 | 5.0 / 114 |
| 10 | Vanilla | 16.94 | 30.28 | 20.0 | 3.95 GB | 254 % | 25 | 25.01 | 11123 | 5.5 / 89 |
| 25 | Horizon | 25.76 | 45.01 | 20.0 | 1.17 GB | 314 % | 61 | 60.7 | 31264 | 6.6 / 82 |
| 25 | Vanilla | 24.93 | 37.09 | 20.0 | 4.04 GB | 305 % | 38 | 37.84 | 33467 | 6.1 / 91 |
| 50 | Horizon | 39.31 | 65.33 | 20.0 | 1.25 GB | 325 % | 115 | 112.25 | 64163 | 6.9 / 95 |
| 50 | Vanilla | stopped by its watchdog (a 60 s tick) | | | | | | | | 7.7 / 86 |
| 100 | Horizon | 52.7 | 85.73 | 18.97 | 1.36 GB | 328 % | 154 | 151.83 | 116161 | 13.4 / 79 |
| 100 | Vanilla | stopped by its watchdog (a 60 s tick) | | | | | | | | 10.0 / 72 |
Vanilla's watchdog stopped it after a 60-second tick in three runs (walk 100, explore 50 and 100). Each time its
server thread was in entity tracking (`ChunkMap$TrackedEntity.updatePlayer`).
## What it shows
- **Same work, 1 to 50 players:** Horizon's mean tick is 44–81 % shorter than vanilla's, its 95th percentile is
lower, and its process uses 32–63 % of vanilla's CPU.
- **Walking, 1 to 50 players:** Horizon's mean tick is shorter at every count (4.0 against 18.2 ms with 1 bot,
22.7 against 34.3 ms with 50).
- **Where vanilla wins:**
- With 25 and 50 walking bots, vanilla's 95th percentile is lower (28.2 against 31.1 ms, 55.6 against
60.2 ms). Horizon's slow ticks are spikier.
- In the full-speed explore pass, vanilla's mean tick is shorter with 10 and 25 bots (16.9 against 21.3 ms,
24.9 against 25.8 ms), while Horizon sends 1.6–1.8 times as many new chunks.
- With 50 walking bots, Horizon makes the server machine busier (load 14.7 against 9.5). Its connection
writer threads compress every packet (below).
- **100 players:** Horizon keeps 15.8 TPS walking and 19.0 TPS exploring at full speed. Vanilla's watchdog
stopped it in both.
- **Memory:** Horizon used 0.76–0.92 GB in every walk and equal-work run, and 1.36 GB at most when 100 bots
explored at full speed. Vanilla used 3.4–4.0 GB with its 4 GB heap.
- **Start:** Horizon's fresh world was ready in 9.2–9.9 s, vanilla's in 14.9–17.2 s.
## Where Horizon's time goes (50 walking bots)
`perf record -e cycles:u --call-graph lbr` on the engine for 30 s, during a separate 50-bot `walk` run on the same
build and machine (MSPT 21.7 ms, 178 % CPU). Shares of all the engine's CPU samples:
| Thread | Share | Mostly |
|---|---:|---|
| Connection writers (`horizon-lan-out`) | 55 % | 53 % in `miniz_oxide` deflate, from `horizon_protocol::frame::encode` |
| World generation workers | 33 % | noise, surface rules, aquifers |
| Server thread (the tick) | 12 % | see below |
Inside the tick:
- the locator bar (`Waypoints::tick`): a third of the tick;
- `Hub::tick`: a quarter;
- block entities, creatures, chunk encoding (`ChunkEncoder::encode_in`) and `Streaming::update`: 7–10 % each;
- SipHash of `u128` keys (the default `HashMap` hasher): about 10 % of the tick.
**The next biggest cost is compressing packets.** Every chunk packet is compressed again for each player who gets
it, with `flate2`'s default `miniz_oxide` backend at level 6. On this 4-thread CPU that competes with the tick
and with world generation. Two fixes, which can be combined:
- the `zlib-rs` backend for `flate2`: 2.2–10 times faster than `miniz_oxide` at level 6 in a quick test, with
output about 3 % larger;
- compressing a chunk packet once and sending the same bytes to every player who needs it.
## Before and after the performance PRs
The earlier run, on `main` at `002f14ce` with #670's bench changes (build `a9ee56a8`), before #586, #701–#707 and
#713. Same machines and settings. MSPT mean / P95, in ms:
Walk:
| Bots | Horizon before | Horizon now | Vanilla before | Vanilla now |
|---:|---|---|---|---|
| 1 | 5.77 / 11.04 | 4.01 / 8.19 | 18.84 / 29.55 | 18.22 / 29.34 |
| 10 | 16.23 / 29.32 | 8.79 / 17.35 | 18.02 / 37.62 | 18.67 / 38.26 |
| 25 | 23.98 / 49.18 | 15.01 / 31.12 | 19.19 / 33.84 | 18.78 / 28.16 |
| 50 | 35.07 / 73.24 | 22.72 / 60.19 | 35.23 / 60.87 | 34.3 / 55.55 |
| 100 | 104.8 / 158.8 | 63.33 / 125.04 | watchdog | watchdog |
Equal work:
| Bots | Horizon before | Horizon now | Vanilla before | Vanilla now |
|---:|---|---|---|---|
| 1 | 4.78 / 7.3 | 3.16 / 5.78 | 16.63 / 26.23 | 16.82 / 27.3 |
| 10 | 15.19 / 25.66 | 9.83 / 19.01 | 24.65 / 40.81 | 24.38 / 39.58 |
| 25 | 23.37 / 42.59 | 16.87 / 30.88 | 30.28 / 44.81 | 30.29 / 43.47 |
| 50 | 30.59 / 67.94 | 23.57 / 47.53 | 43.52 / 56.18 | 42.44 / 54.84 |
Vanilla's numbers repeat within a few percent, so the runs are comparable. In the earlier run, 25 walking bots
were faster on vanilla (19.2 against 24.0 ms). A profile then put a third of the tick in advancement updates,
which #701 fixed.
## Files
- `walk-explore.json`: the `walk` and full-speed `explore` passes. `explore-equal-rate.json`: the equal-work pass.
- `load.txt`: both machines' load averages every 30 s during the runs.
- `earlier-002f14ce/`: the same files for the earlier run.
- The server's tailnet address is replaced by `<server address>` in the JSON.
@@ -0,0 +1,341 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T15:37:03+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "a9ee56a8b143999c9bc7056850ffcd622a9214aa",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25,50",
"behaviour": "explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 180,
"seed": 12345,
"explore_speed": 0.8,
"gamerule": "",
"heap": "4g",
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/explore-equal-rate.json",
"nice": 19,
"bots_via": "ssh macmini",
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
"connect": "<server address>",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 9.21,
"mspt": {
"average": 4.78,
"p50": 4.59,
"p95": 7.3,
"p99": 13.53
},
"mspt_worst_p99": 36.5,
"tps": 20.0,
"rss_mb": {
"mean": 759.91,
"max": 771.3
},
"cpu_percent": 24.49,
"chunks_per_second": 1.64,
"unique_per_second": 1.27,
"forgotten_per_second": 1.54,
"waited_ticks_per_second": 0.0,
"chunks_total": 841,
"chunks_per_bot": {
"explore": 841.0
},
"unique_chunks_per_bot": {
"explore": 781.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "explore",
"start_seconds": 14.35,
"mspt": {
"average": 16.63,
"p50": 16.73,
"p95": 26.23,
"p99": 35.39
},
"mspt_worst_p99": 68.5,
"tps": 20.0,
"rss_mb": {
"mean": 3431.12,
"max": 3459.2
},
"cpu_percent": 69.76,
"chunks_per_second": 1.66,
"unique_per_second": 1.37,
"forgotten_per_second": 1.67,
"waited_ticks_per_second": 0.0,
"chunks_total": 840,
"chunks_per_bot": {
"explore": 840.0
},
"unique_chunks_per_bot": {
"explore": 780.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 9.31,
"mspt": {
"average": 15.19,
"p50": 14.45,
"p95": 25.66,
"p99": 38.89
},
"mspt_worst_p99": 57.6,
"tps": 20.0,
"rss_mb": {
"mean": 859.86,
"max": 920.2
},
"cpu_percent": 84.95,
"chunks_per_second": 14.22,
"unique_per_second": 12.7,
"forgotten_per_second": 14.11,
"waited_ticks_per_second": 0.0,
"chunks_total": 7962,
"chunks_per_bot": {
"explore": 796.2
},
"unique_chunks_per_bot": {
"explore": 768.2
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "explore",
"start_seconds": 14.74,
"mspt": {
"average": 24.65,
"p50": 21.82,
"p95": 40.81,
"p99": 70.23
},
"mspt_worst_p99": 119.1,
"tps": 20.0,
"rss_mb": {
"mean": 3528.77,
"max": 3643.2
},
"cpu_percent": 133.1,
"chunks_per_second": 13.62,
"unique_per_second": 12.32,
"forgotten_per_second": 13.62,
"waited_ticks_per_second": 0.0,
"chunks_total": 7746,
"chunks_per_bot": {
"explore": 774.6
},
"unique_chunks_per_bot": {
"explore": 748.7
},
"resent": 0,
"pulled_back": 1150,
"moved_too_quickly": 5,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 9.42,
"mspt": {
"average": 23.37,
"p50": 21.61,
"p95": 42.59,
"p99": 60.21
},
"mspt_worst_p99": 102.1,
"tps": 20.0,
"rss_mb": {
"mean": 889.21,
"max": 950.7
},
"cpu_percent": 117.59,
"chunks_per_second": 35.34,
"unique_per_second": 31.62,
"forgotten_per_second": 35.12,
"waited_ticks_per_second": 0.0,
"chunks_total": 20137,
"chunks_per_bot": {
"explore": 805.48
},
"unique_chunks_per_bot": {
"explore": 774.52
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "explore",
"start_seconds": 15.14,
"mspt": {
"average": 30.28,
"p50": 28.17,
"p95": 44.81,
"p99": 71.77
},
"mspt_worst_p99": 158.1,
"tps": 20.0,
"rss_mb": {
"mean": 3574.32,
"max": 3692.7
},
"cpu_percent": 173.43,
"chunks_per_second": 32.76,
"unique_per_second": 29.38,
"forgotten_per_second": 33.02,
"waited_ticks_per_second": 0.0,
"chunks_total": 19417,
"chunks_per_bot": {
"explore": 776.68
},
"unique_chunks_per_bot": {
"explore": 747.36
},
"resent": 0,
"pulled_back": 4728,
"moved_too_quickly": 15,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 50,
"behaviour": "explore",
"start_seconds": 9.11,
"mspt": {
"average": 30.59,
"p50": 25.19,
"p95": 67.94,
"p99": 90.77
},
"mspt_worst_p99": 122.1,
"tps": 20.0,
"rss_mb": {
"mean": 915.93,
"max": 978.0
},
"cpu_percent": 132.13,
"chunks_per_second": 71.31,
"unique_per_second": 63.56,
"forgotten_per_second": 71.19,
"waited_ticks_per_second": 0.0,
"chunks_total": 41365,
"chunks_per_bot": {
"explore": 827.3
},
"unique_chunks_per_bot": {
"explore": 793.14
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "explore",
"start_seconds": 15.35,
"mspt": {
"average": 43.52,
"p50": 41.69,
"p95": 56.18,
"p99": 84.55
},
"mspt_worst_p99": 155.1,
"tps": 20.0,
"rss_mb": {
"mean": 3801.29,
"max": 3920.7
},
"cpu_percent": 198.97,
"chunks_per_second": 65.86,
"unique_per_second": 58.63,
"forgotten_per_second": 66.12,
"waited_ticks_per_second": 0.0,
"chunks_total": 39790,
"chunks_per_bot": {
"explore": 795.8
},
"unique_chunks_per_bot": {
"explore": 764.48
},
"resent": 0,
"pulled_back": 8392,
"moved_too_quickly": 38,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
}
]
}
@@ -0,0 +1,278 @@
2026-10-17T13:17:13Z thinkpad: 2.76 2.26 2.04 | macmini (bots): { 73.08 70.73 75.98 }
2026-10-17T13:17:43Z thinkpad: 2.90 2.33 2.07 | macmini (bots): { 75.66 71.62 76.12 }
2026-10-17T13:18:13Z thinkpad: 3.02 2.43 2.11 | macmini (bots): { 65.77 69.61 75.24 }
2026-10-17T13:18:44Z thinkpad: 2.15 2.27 2.07 | macmini (bots): { 59.85 67.75 74.37 }
2026-10-17T13:19:14Z thinkpad: 1.45 2.09 2.02 | macmini (bots): { 63.86 67.87 74.17 }
2026-10-17T13:19:44Z thinkpad: 1.28 1.98 1.98 | macmini (bots): { 63.51 67.54 73.84 }
2026-10-17T13:20:14Z thinkpad: 1.14 1.89 1.95 | macmini (bots): { 65.82 67.85 73.71 }
2026-10-17T13:20:45Z thinkpad: 0.90 1.77 1.91 | macmini (bots): { 63.62 67.08 73.22 }
2026-10-17T13:21:15Z thinkpad: 0.55 1.60 1.85 | macmini (bots): { 62.45 66.37 72.75 }
2026-10-17T13:21:45Z thinkpad: 0.79 1.54 1.82 | macmini (bots): { 67.04 67.04 72.76 }
2026-10-17T13:22:15Z thinkpad: 1.96 1.75 1.88 | macmini (bots): { 65.53 66.60 72.40 }
2026-10-17T13:22:46Z thinkpad: 1.82 1.75 1.88 | macmini (bots): { 61.52 65.55 71.82 }
2026-10-17T13:23:16Z thinkpad: 1.29 1.63 1.83 | macmini (bots): { 62.36 65.36 71.53 }
2026-10-17T13:23:46Z thinkpad: 0.86 1.49 1.78 | macmini (bots): { 68.76 66.51 71.73 }
2026-10-17T13:24:16Z thinkpad: 1.29 1.52 1.78 | macmini (bots): { 77.68 68.77 72.36 }
2026-10-17T13:24:46Z thinkpad: 1.82 1.62 1.80 | macmini (bots): { 74.03 68.88 72.28 }
2026-10-17T13:25:17Z thinkpad: 1.50 1.56 1.78 | macmini (bots): { 71.53 68.76 72.12 }
2026-10-17T13:25:47Z thinkpad: 0.96 1.42 1.72 | macmini (bots): { 74.46 69.85 72.40 }
2026-10-17T13:26:17Z thinkpad: 0.87 1.35 1.69 | macmini (bots): { 80.85 71.79 73.01 }
2026-10-17T13:26:47Z thinkpad: 3.95 2.02 1.90 | macmini (bots): { 74.77 71.31 72.81 }
2026-10-17T13:27:18Z thinkpad: 2.98 1.97 1.89 | macmini (bots): { 70.52 70.52 72.46 }
2026-10-17T13:27:48Z thinkpad: 3.78 2.26 1.99 | macmini (bots): { 67.62 69.91 72.18 }
2026-10-17T13:28:18Z thinkpad: 3.05 2.23 1.99 | macmini (bots): { 72.51 70.83 72.43 }
2026-10-17T13:28:48Z thinkpad: 2.05 2.07 1.94 | macmini (bots): { 79.07 72.64 73.03 }
2026-10-17T13:29:19Z thinkpad: 2.09 2.11 1.96 | macmini (bots): { 90.42 75.94 74.21 }
2026-10-17T13:29:49Z thinkpad: 1.61 1.99 1.93 | macmini (bots): { 102.68 80.15 75.78 }
2026-10-17T13:30:19Z thinkpad: 2.31 2.12 1.97 | macmini (bots): { 111.15 84.19 77.39 }
2026-10-17T13:30:49Z thinkpad: 4.14 2.61 2.14 | macmini (bots): { 115.67 87.82 78.94 }
2026-10-17T13:31:20Z thinkpad: 4.84 2.92 2.26 | macmini (bots): { 122.95 92.31 80.88 }
2026-10-17T13:31:50Z thinkpad: 6.84 3.60 2.51 | macmini (bots): { 136.25 98.41 83.48 }
2026-10-17T13:32:20Z thinkpad: 4.85 3.43 2.49 | macmini (bots): { 128.59 100.39 84.73 }
2026-10-17T13:32:50Z thinkpad: 3.48 3.25 2.46 | macmini (bots): { 111.05 98.73 84.66 }
2026-10-17T13:33:20Z thinkpad: 3.09 3.15 2.45 | macmini (bots): { 102.40 98.03 84.98 }
2026-10-17T13:33:51Z thinkpad: 2.47 2.99 2.42 | macmini (bots): { 94.57 96.54 84.89 }
2026-10-17T13:34:21Z thinkpad: 2.42 2.94 2.42 | macmini (bots): { 86.26 94.36 84.50 }
2026-10-17T13:34:51Z thinkpad: 2.95 3.04 2.47 | macmini (bots): { 76.90 91.56 83.84 }
2026-10-17T13:35:21Z thinkpad: 2.66 2.96 2.46 | macmini (bots): { 65.57 87.42 82.61 }
2026-10-17T13:35:52Z thinkpad: 2.47 2.88 2.45 | macmini (bots): { 64.13 85.05 81.92 }
2026-10-17T13:36:22Z thinkpad: 3.89 3.17 2.56 | macmini (bots): { 58.65 81.74 80.83 }
2026-10-17T13:36:52Z thinkpad: 6.43 3.83 2.80 | macmini (bots): { 66.32 81.32 80.71 }
2026-10-17T13:37:22Z thinkpad: 8.73 4.60 3.09 | macmini (bots): { 67.30 80.14 80.30 }
2026-10-17T13:37:53Z thinkpad: 9.03 5.10 3.30 | macmini (bots): { 63.66 78.24 79.62 }
2026-10-17T13:38:23Z thinkpad: 9.49 5.57 3.52 | macmini (bots): { 61.25 76.23 78.84 }
2026-10-17T13:38:53Z thinkpad: 7.55 5.50 3.56 | macmini (bots): { 62.30 74.88 78.25 }
2026-10-17T13:39:23Z thinkpad: 8.62 5.99 3.79 | macmini (bots): { 57.34 72.56 77.29 }
2026-10-17T13:39:53Z thinkpad: 6.65 5.81 3.80 | macmini (bots): { 51.24 69.62 76.06 }
2026-10-17T13:40:24Z thinkpad: 5.55 5.62 3.80 | macmini (bots): { 48.87 67.26 74.98 }
2026-10-17T13:40:54Z thinkpad: 6.11 5.71 3.89 | macmini (bots): { 51.16 66.08 74.29 }
2026-10-17T13:41:24Z thinkpad: 8.93 6.43 4.19 | macmini (bots): { 48.67 64.00 73.24 }
2026-10-17T13:41:54Z thinkpad: 9.05 6.74 4.36 | macmini (bots): { 46.47 61.96 72.18 }
2026-10-17T13:42:25Z thinkpad: 7.70 6.65 4.41 | macmini (bots): { 44.24 60.05 71.14 }
2026-10-17T13:42:55Z thinkpad: 5.64 6.24 4.34 | macmini (bots): { 49.30 59.83 70.68 }
2026-10-17T13:43:25Z thinkpad: 4.83 6.02 4.33 | macmini (bots): { 47.39 58.24 69.72 }
2026-10-17T13:43:55Z thinkpad: 3.57 5.61 4.25 | macmini (bots): { 47.32 57.23 68.96 }
2026-10-17T13:44:25Z thinkpad: 3.41 5.40 4.23 | macmini (bots): { 47.86 56.49 68.28 }
2026-10-17T13:44:55Z thinkpad: 3.37 5.21 4.20 | macmini (bots): { 50.10 56.19 67.77 }
2026-10-17T13:45:26Z thinkpad: 3.78 5.16 4.22 | macmini (bots): { 51.91 55.98 67.28 }
2026-10-17T13:45:56Z thinkpad: 4.34 5.12 4.23 | macmini (bots): { 49.16 54.85 66.42 }
2026-10-17T13:46:26Z thinkpad: 10.04 6.46 4.71 | macmini (bots): { 45.01 53.33 65.47 }
2026-10-17T13:46:56Z thinkpad: 18.10 8.73 5.52 | macmini (bots): { 46.41 52.87 64.88 }
2026-10-17T13:47:27Z thinkpad: 14.99 8.91 5.68 | macmini (bots): { 51.12 53.21 64.57 }
2026-10-17T13:47:57Z thinkpad: 13.46 9.27 5.92 | macmini (bots): { 53.84 53.81 64.40 }
2026-10-17T13:48:27Z thinkpad: 12.76 9.41 6.07 | macmini (bots): { 51.94 53.27 63.84 }
2026-10-17T13:48:58Z thinkpad: 10.20 9.11 6.08 | macmini (bots): { 50.70 52.83 63.31 }
2026-10-17T13:49:28Z thinkpad: 9.95 9.25 6.23 | macmini (bots): { 51.51 52.89 62.97 }
2026-10-17T13:49:58Z thinkpad: 11.98 9.87 6.53 | macmini (bots): { 50.98 52.73 62.57 }
2026-10-17T13:50:29Z thinkpad: 14.96 10.76 6.94 | macmini (bots): { 49.68 52.31 62.08 }
2026-10-17T13:50:59Z thinkpad: 10.54 10.09 6.83 | macmini (bots): { 53.34 53.01 62.00 }
2026-10-17T13:51:29Z thinkpad: 10.21 10.01 6.91 | macmini (bots): { 51.61 52.69 61.57 }
2026-10-17T13:52:00Z thinkpad: 11.86 10.42 7.14 | macmini (bots): { 48.94 51.89 60.97 }
2026-10-17T13:52:30Z thinkpad: 10.88 10.33 7.22 | macmini (bots): { 48.46 51.44 60.49 }
2026-10-17T13:53:01Z thinkpad: 10.94 10.41 7.35 | macmini (bots): { 54.27 52.41 60.47 }
2026-10-17T13:53:31Z thinkpad: 10.77 10.42 7.45 | macmini (bots): { 57.13 53.30 60.51 }
2026-10-17T13:54:02Z thinkpad: 10.62 10.40 7.53 | macmini (bots): { 53.78 52.84 60.09 }
2026-10-17T13:54:32Z thinkpad: 10.85 10.48 7.66 | macmini (bots): { 52.01 52.51 59.73 }
2026-10-17T13:55:02Z thinkpad: 10.36 10.39 7.73 | macmini (bots): { 53.44 52.83 59.59 }
2026-10-17T13:55:32Z thinkpad: 8.58 9.95 7.67 | macmini (bots): { 58.66 54.12 59.82 }
2026-10-17T13:56:03Z thinkpad: 7.29 9.52 7.60 | macmini (bots): { 60.98 55.05 59.96 }
2026-10-17T13:56:33Z thinkpad: 5.02 8.75 7.40 | macmini (bots): { 65.14 56.71 60.39 }
2026-10-17T13:57:03Z thinkpad: 5.03 8.38 7.32 | macmini (bots): { 57.51 55.73 59.91 }
2026-10-17T13:57:34Z thinkpad: 7.39 8.66 7.45 | macmini (bots): { 52.57 54.73 59.41 }
2026-10-17T13:58:04Z thinkpad: 10.56 9.45 7.76 | macmini (bots): { 53.11 54.60 59.20 }
2026-10-17T13:58:34Z thinkpad: 28.87 14.20 9.41 | macmini (bots): { 54.53 54.93 59.16 }
2026-10-17T13:59:05Z thinkpad: 39.33 17.79 10.73 | macmini (bots): { 62.67 56.86 59.71 }
2026-10-17T13:59:35Z thinkpad: 34.07 18.33 11.12 | macmini (bots): { 61.78 57.22 59.75 }
2026-10-17T14:00:06Z thinkpad: 34.42 19.72 11.80 | macmini (bots): { 53.10 55.55 59.03 }
2026-10-17T14:00:36Z thinkpad: 33.60 20.94 12.46 | macmini (bots): { 48.70 54.23 58.43 }
2026-10-17T14:01:07Z thinkpad: 29.29 20.99 12.74 | macmini (bots): { 50.25 54.00 58.20 }
2026-10-17T14:01:37Z thinkpad: 20.83 19.79 12.61 | macmini (bots): { 50.48 53.74 57.96 }
2026-10-17T14:02:08Z thinkpad: 21.74 20.44 13.11 | macmini (bots): { 48.65 53.04 57.56 }
2026-10-17T14:02:38Z thinkpad: 17.68 19.67 13.09 | macmini (bots): { 54.17 53.85 57.69 }
2026-10-17T14:03:08Z thinkpad: 13.97 18.55 12.93 | macmini (bots): { 53.39 53.84 57.56 }
2026-10-17T14:03:39Z thinkpad: 14.10 18.17 12.98 | macmini (bots): { 50.71 53.09 57.16 }
2026-10-17T14:04:09Z thinkpad: 13.60 17.65 12.97 | macmini (bots): { 52.30 53.28 57.08 }
2026-10-17T14:04:39Z thinkpad: 12.09 16.91 12.88 | macmini (bots): { 51.52 53.03 56.87 }
2026-10-17T14:05:10Z thinkpad: 10.15 15.98 12.69 | macmini (bots): { 54.53 53.57 56.91 }
2026-10-17T14:05:42Z thinkpad: 7.77 14.86 12.42 | macmini (bots): { 63.58 55.55 57.49 }
2026-10-17T14:06:12Z thinkpad: 6.58 13.89 12.18 | macmini (bots): { 75.29 59.41 58.84 }
2026-10-17T14:06:42Z thinkpad: 5.96 13.02 11.94 | macmini (bots): { 78.06 61.64 59.67 }
2026-10-17T14:07:12Z thinkpad: 4.86 12.08 11.66 | macmini (bots): { 72.26 61.85 59.81 }
2026-10-17T14:07:42Z thinkpad: 4.49 11.18 11.38 | macmini (bots): { 67.84 61.73 59.84 }
2026-10-17T14:08:13Z thinkpad: 4.37 10.51 11.15 | macmini (bots): { 64.25 61.45 59.80 }
2026-10-17T14:08:43Z thinkpad: 4.57 9.97 10.94 | macmini (bots): { 57.86 60.14 59.38 }
2026-10-17T14:09:13Z thinkpad: 4.60 9.46 10.74 | macmini (bots): { 60.21 60.39 59.49 }
2026-10-17T14:09:43Z thinkpad: 4.20 8.89 10.51 | macmini (bots): { 57.11 59.57 59.22 }
2026-10-17T14:10:14Z thinkpad: 3.80 8.36 10.28 | macmini (bots): { 66.05 61.48 59.92 }
2026-10-17T14:10:44Z thinkpad: 4.19 8.00 10.10 | macmini (bots): { 78.51 64.91 61.22 }
2026-10-17T14:11:14Z thinkpad: 4.27 7.65 9.92 | macmini (bots): { 74.15 65.33 61.51 }
2026-10-17T14:11:44Z thinkpad: 4.35 7.35 9.74 | macmini (bots): { 62.47 63.48 60.98 }
2026-10-17T14:12:14Z thinkpad: 4.98 7.22 9.62 | macmini (bots): { 56.73 61.86 60.46 }
2026-10-17T14:12:45Z thinkpad: 4.60 6.91 9.44 | macmini (bots): { 57.78 61.49 60.37 }
2026-10-17T14:13:15Z thinkpad: 4.15 6.57 9.25 | macmini (bots): { 56.21 60.71 60.12 }
2026-10-17T14:13:45Z thinkpad: 3.87 6.25 9.05 | macmini (bots): { 54.75 60.00 59.89 }
2026-10-17T14:14:15Z thinkpad: 3.86 6.02 8.89 | macmini (bots): { 52.21 58.88 59.49 }
2026-10-17T14:14:46Z thinkpad: 3.14 5.64 8.67 | macmini (bots): { 58.81 59.59 59.71 }
2026-10-17T14:15:16Z thinkpad: 3.76 5.54 8.54 | macmini (bots): { 64.13 60.86 60.16 }
2026-10-17T14:15:46Z thinkpad: 3.91 5.41 8.40 | macmini (bots): { 59.02 59.97 59.87 }
2026-10-17T14:16:16Z thinkpad: 4.33 5.35 8.28 | macmini (bots): { 64.48 60.97 60.22 }
2026-10-17T14:16:46Z thinkpad: 5.20 5.44 8.21 | macmini (bots): { 79.56 64.98 61.70 }
2026-10-17T14:17:17Z thinkpad: 4.88 5.36 8.10 | macmini (bots): { 94.21 69.99 63.63 }
2026-10-17T14:17:47Z thinkpad: 4.80 5.30 8.00 | macmini (bots): { 85.75 70.51 64.08 }
2026-10-17T14:18:17Z thinkpad: 4.82 5.25 7.89 | macmini (bots): { 82.33 71.25 64.57 }
2026-10-17T14:18:48Z thinkpad: 4.50 5.12 7.75 | macmini (bots): { 77.67 71.11 64.75 }
2026-10-17T14:19:18Z thinkpad: 3.76 4.88 7.58 | macmini (bots): { 77.29 71.67 65.17 }
2026-10-17T14:19:48Z thinkpad: 3.68 4.75 7.45 | macmini (bots): { 71.16 70.71 65.05 }
2026-10-17T14:20:18Z thinkpad: 4.25 4.82 7.39 | macmini (bots): { 66.97 69.63 64.85 }
2026-10-17T14:20:49Z thinkpad: 4.72 4.89 7.33 | macmini (bots): { 59.23 67.50 64.24 }
2026-10-17T14:21:19Z thinkpad: 5.12 4.98 7.28 | macmini (bots): { 52.38 65.10 63.48 }
2026-10-17T14:21:49Z thinkpad: 5.69 5.13 7.26 | macmini (bots): { 59.43 65.53 63.69 }
2026-10-17T14:22:19Z thinkpad: 5.78 5.19 7.21 | macmini (bots): { 62.28 65.65 63.80 }
2026-10-17T14:22:50Z thinkpad: 5.34 5.14 7.13 | macmini (bots): { 57.40 64.12 63.30 }
2026-10-17T14:23:20Z thinkpad: 4.89 5.04 7.03 | macmini (bots): { 58.39 63.61 63.14 }
2026-10-17T14:23:50Z thinkpad: 5.00 5.07 6.98 | macmini (bots): { 64.88 64.63 63.52 }
2026-10-17T14:24:20Z thinkpad: 3.88 4.80 6.83 | macmini (bots): { 80.70 68.30 64.88 }
2026-10-17T14:24:51Z thinkpad: 2.90 4.47 6.65 | macmini (bots): { 94.09 72.75 66.61 }
2026-10-17T14:25:21Z thinkpad: 2.63 4.25 6.51 | macmini (bots): { 93.13 74.91 67.63 }
2026-10-17T14:25:51Z thinkpad: 6.66 5.07 6.71 | macmini (bots): { 72.87 71.72 66.72 }
2026-10-17T14:26:21Z thinkpad: 7.15 5.34 6.74 | macmini (bots): { 67.03 70.37 66.40 }
2026-10-17T14:26:51Z thinkpad: 6.20 5.29 6.68 | macmini (bots): { 58.68 68.08 65.71 }
2026-10-17T14:27:22Z thinkpad: 6.11 5.35 6.66 | macmini (bots): { 55.26 66.28 65.14 }
2026-10-17T14:27:52Z thinkpad: 5.45 5.26 6.59 | macmini (bots): { 56.51 65.74 64.99 }
2026-10-17T14:28:22Z thinkpad: 6.11 5.45 6.61 | macmini (bots): { 52.24 63.77 64.30 }
2026-10-17T14:28:52Z thinkpad: 6.06 5.50 6.59 | macmini (bots): { 55.56 63.38 64.13 }
2026-10-17T14:29:23Z thinkpad: 6.11 5.58 6.58 | macmini (bots): { 59.66 63.63 64.20 }
2026-10-17T14:29:53Z thinkpad: 5.52 5.54 6.53 | macmini (bots): { 63.71 64.30 64.42 }
2026-10-17T14:30:23Z thinkpad: 4.79 5.35 6.43 | macmini (bots): { 69.84 65.61 64.89 }
2026-10-17T14:30:53Z thinkpad: 6.21 5.63 6.49 | macmini (bots): { 62.76 64.46 64.50 }
2026-10-17T14:31:23Z thinkpad: 8.36 6.21 6.66 | macmini (bots): { 53.51 62.09 63.64 }
2026-10-17T14:31:54Z thinkpad: 8.49 6.45 6.72 | macmini (bots): { 52.80 60.98 63.18 }
2026-10-17T14:32:24Z thinkpad: 8.28 6.59 6.77 | macmini (bots): { 48.98 59.20 62.45 }
2026-10-17T14:32:54Z thinkpad: 8.02 6.70 6.79 | macmini (bots): { 48.95 58.30 62.02 }
2026-10-17T14:33:25Z thinkpad: 7.02 6.59 6.76 | macmini (bots): { 51.06 57.70 61.66 }
2026-10-17T14:33:55Z thinkpad: 7.09 6.62 6.76 | macmini (bots): { 70.89 61.81 63.00 }
2026-10-17T14:34:25Z thinkpad: 6.18 6.44 6.69 | macmini (bots): { 69.38 62.46 63.21 }
2026-10-17T14:34:55Z thinkpad: 6.67 6.52 6.72 | macmini (bots): { 76.87 64.84 64.04 }
2026-10-17T14:35:25Z thinkpad: 4.76 6.08 6.56 | macmini (bots): { 86.19 68.04 65.21 }
2026-10-17T14:35:56Z thinkpad: 3.47 5.64 6.40 | macmini (bots): { 101.03 73.28 67.21 }
2026-10-17T14:36:26Z thinkpad: 2.68 5.24 6.24 | macmini (bots): { 106.05 77.09 68.79 }
2026-10-17T14:36:56Z thinkpad: 2.41 4.91 6.10 | macmini (bots): { 107.57 80.20 70.20 }
2026-10-17T14:37:26Z thinkpad: 3.74 4.99 6.08 | macmini (bots): { 101.16 81.38 70.98 }
2026-10-17T14:37:57Z thinkpad: 5.96 5.39 6.18 | macmini (bots): { 86.51 79.61 70.70 }
2026-10-17T14:38:27Z thinkpad: 7.91 5.98 6.35 | macmini (bots): { 81.13 79.06 70.81 }
2026-10-17T14:38:57Z thinkpad: 8.45 6.25 6.43 | macmini (bots): { 79.56 79.01 71.08 }
2026-10-17T14:39:27Z thinkpad: 8.75 6.52 6.52 | macmini (bots): { 71.92 77.22 70.71 }
2026-10-17T14:39:58Z thinkpad: 8.86 6.76 6.60 | macmini (bots): { 71.38 76.58 70.70 }
2026-10-17T14:40:28Z thinkpad: 8.74 6.98 6.68 | macmini (bots): { 71.25 76.12 70.74 }
2026-10-17T14:40:58Z thinkpad: 7.99 6.96 6.68 | macmini (bots): { 70.49 75.51 70.71 }
2026-10-17T14:41:28Z thinkpad: 7.64 7.01 6.70 | macmini (bots): { 68.93 74.58 70.56 }
2026-10-17T14:41:59Z thinkpad: 6.57 6.84 6.66 | macmini (bots): { 68.35 73.75 70.39 }
2026-10-17T14:42:29Z thinkpad: 5.83 6.62 6.59 | macmini (bots): { 63.57 72.20 69.95 }
2026-10-17T14:42:59Z thinkpad: 7.40 6.91 6.69 | macmini (bots): { 56.77 69.69 69.12 }
2026-10-17T14:43:29Z thinkpad: 9.07 7.37 6.85 | macmini (bots): { 49.53 66.71 68.06 }
2026-10-17T14:44:00Z thinkpad: 9.39 7.63 6.96 | macmini (bots): { 43.53 63.68 66.91 }
2026-10-17T14:44:30Z thinkpad: 9.16 7.73 7.01 | macmini (bots): { 41.12 61.23 65.92 }
2026-10-17T14:45:00Z thinkpad: 7.96 7.57 6.98 | macmini (bots): { 40.62 59.23 65.03 }
2026-10-17T14:45:30Z thinkpad: 6.34 7.24 6.89 | macmini (bots): { 38.17 56.80 63.94 }
2026-10-17T14:46:01Z thinkpad: 6.39 7.18 6.88 | macmini (bots): { 40.35 55.54 63.24 }
2026-10-17T14:46:31Z thinkpad: 5.63 6.95 6.82 | macmini (bots): { 48.66 56.25 63.24 }
2026-10-17T14:47:01Z thinkpad: 4.73 6.60 6.70 | macmini (bots): { 46.68 55.20 62.62 }
2026-10-17T14:47:31Z thinkpad: 9.18 7.55 7.02 | macmini (bots): { 41.45 53.19 61.64 }
2026-10-17T14:48:01Z thinkpad: 10.40 8.02 7.19 | macmini (bots): { 40.76 51.87 60.87 }
2026-10-17T14:48:32Z thinkpad: 8.24 7.73 7.12 | macmini (bots): { 44.46 51.67 60.48 }
2026-10-17T14:49:02Z thinkpad: 9.60 8.09 7.26 | macmini (bots): { 38.75 49.66 59.45 }
2026-10-17T14:49:32Z thinkpad: 8.90 8.10 7.29 | macmini (bots): { 34.93 47.72 58.41 }
2026-10-17T14:50:02Z thinkpad: 11.17 8.66 7.50 | macmini (bots): { 35.70 46.68 57.66 }
2026-10-17T14:50:33Z thinkpad: 12.06 9.19 7.72 | macmini (bots): { 43.94 47.43 57.54 }
2026-10-17T14:51:03Z thinkpad: 12.50 9.49 7.87 | macmini (bots): { 40.76 46.36 56.80 }
2026-10-17T14:51:33Z thinkpad: 10.35 9.24 7.83 | macmini (bots): { 44.81 46.69 56.55 }
2026-10-17T14:52:03Z thinkpad: 9.32 9.09 7.83 | macmini (bots): { 54.94 48.97 57.04 }
2026-10-17T14:52:34Z thinkpad: 7.07 8.58 7.70 | macmini (bots): { 54.59 49.54 56.92 }
2026-10-17T14:53:04Z thinkpad: 5.33 8.01 7.54 | macmini (bots): { 67.48 53.27 58.02 }
2026-10-17T14:53:34Z thinkpad: 7.22 8.20 7.62 | macmini (bots): { 57.82 52.33 57.52 }
2026-10-17T14:54:04Z thinkpad: 9.69 8.73 7.81 | macmini (bots): { 46.67 50.20 56.57 }
2026-10-17T14:54:35Z thinkpad: 9.14 8.66 7.82 | macmini (bots): { 49.88 50.61 56.49 }
2026-10-17T14:55:05Z thinkpad: 9.75 8.87 7.91 | macmini (bots): { 46.88 49.95 56.06 }
2026-10-17T14:55:35Z thinkpad: 8.51 8.67 7.88 | macmini (bots): { 42.90 48.80 55.43 }
2026-10-17T14:56:06Z thinkpad: 8.46 8.62 7.89 | macmini (bots): { 49.23 49.72 55.53 }
2026-10-17T14:56:36Z thinkpad: 6.19 8.07 7.73 | macmini (bots): { 54.41 50.86 55.74 }
2026-10-17T14:57:06Z thinkpad: 4.69 7.53 7.56 | macmini (bots): { 60.26 52.69 56.24 }
2026-10-17T14:57:36Z thinkpad: 3.21 6.90 7.35 | macmini (bots): { 67.66 55.21 57.03 }
2026-10-17T14:58:06Z thinkpad: 1.94 6.24 7.11 | macmini (bots): { 75.73 58.41 58.13 }
2026-10-17T14:58:37Z thinkpad: 1.18 5.64 6.89 | macmini (bots): { 74.69 59.90 58.68 }
2026-10-17T14:59:07Z thinkpad: 0.78 5.12 6.67 | macmini (bots): { 77.10 61.82 59.42 }
2026-10-17T14:59:37Z thinkpad: 0.88 4.72 6.49 | macmini (bots): { 87.05 65.59 60.86 }
2026-10-17T15:00:07Z thinkpad: 0.53 4.27 6.28 | macmini (bots): { 78.18 65.51 60.99 }
2026-10-17T15:00:38Z thinkpad: 0.38 3.88 6.09 | macmini (bots): { 79.38 67.00 61.69 }
2026-10-17T15:01:08Z thinkpad: 1.50 3.80 5.99 | macmini (bots): { 78.36 67.86 62.18 }
2026-10-17T15:01:38Z thinkpad: 2.97 3.93 5.95 | macmini (bots): { 74.18 67.90 62.39 }
2026-10-17T15:02:08Z thinkpad: 2.61 3.77 5.83 | macmini (bots): { 68.63 67.00 62.25 }
2026-10-17T15:02:39Z thinkpad: 2.10 3.55 5.69 | macmini (bots): { 69.84 67.50 62.60 }
2026-10-17T15:03:09Z thinkpad: 1.44 3.25 5.53 | macmini (bots): { 67.10 67.06 62.61 }
2026-10-17T15:03:39Z thinkpad: 1.39 3.08 5.40 | macmini (bots): { 60.83 65.50 62.19 }
2026-10-17T15:04:09Z thinkpad: 1.28 2.88 5.26 | macmini (bots): { 75.40 68.61 63.46 }
2026-10-17T15:04:40Z thinkpad: 2.30 2.99 5.22 | macmini (bots): { 106.08 76.54 66.50 }
2026-10-17T15:05:10Z thinkpad: 2.65 3.02 5.15 | macmini (bots): { 111.96 81.09 68.52 }
2026-10-17T15:05:40Z thinkpad: 2.41 2.92 5.05 | macmini (bots): { 104.19 82.08 69.31 }
2026-10-17T15:06:10Z thinkpad: 2.71 2.95 4.99 | macmini (bots): { 116.20 87.04 71.54 }
2026-10-17T15:06:40Z thinkpad: 2.51 2.87 4.90 | macmini (bots): { 136.68 94.24 74.66 }
2026-10-17T15:07:11Z thinkpad: 3.68 3.11 4.91 | macmini (bots): { 132.76 97.43 76.50 }
2026-10-17T15:07:41Z thinkpad: 3.77 3.19 4.88 | macmini (bots): { 132.73 100.97 78.51 }
2026-10-17T15:08:11Z thinkpad: 3.00 3.06 4.79 | macmini (bots): { 116.92 100.33 79.07 }
2026-10-17T15:08:41Z thinkpad: 2.40 2.91 4.68 | macmini (bots): { 109.83 100.07 79.70 }
2026-10-17T15:09:12Z thinkpad: 2.18 2.81 4.59 | macmini (bots): { 113.21 101.71 81.00 }
2026-10-17T15:09:42Z thinkpad: 1.67 2.62 4.47 | macmini (bots): { 131.87 107.11 83.66 }
2026-10-17T15:10:12Z thinkpad: 1.90 2.58 4.39 | macmini (bots): { 133.45 109.92 85.49 }
2026-10-17T15:10:42Z thinkpad: 4.00 3.02 4.48 | macmini (bots): { 130.14 111.67 86.99 }
2026-10-17T15:11:13Z thinkpad: 4.77 3.32 4.54 | macmini (bots): { 118.78 110.28 87.32 }
2026-10-17T15:11:43Z thinkpad: 3.86 3.24 4.47 | macmini (bots): { 124.01 112.36 88.87 }
2026-10-17T15:12:13Z thinkpad: 3.61 3.26 4.44 | macmini (bots): { 122.21 112.94 89.88 }
2026-10-17T15:12:43Z thinkpad: 3.12 3.17 4.36 | macmini (bots): { 111.62 111.10 90.00 }
2026-10-17T15:13:14Z thinkpad: 3.07 3.14 4.31 | macmini (bots): { 122.97 113.95 91.77 }
2026-10-17T15:13:44Z thinkpad: 3.33 3.20 4.30 | macmini (bots): { 102.64 109.89 91.07 }
2026-10-17T15:14:14Z thinkpad: 2.85 3.10 4.23 | macmini (bots): { 94.46 107.15 90.72 }
2026-10-17T15:14:44Z thinkpad: 2.66 3.03 4.17 | macmini (bots): { 94.94 106.07 90.90 }
2026-10-17T15:15:14Z thinkpad: 2.54 2.96 4.11 | macmini (bots): { 94.52 104.93 91.01 }
2026-10-17T15:15:45Z thinkpad: 3.20 3.08 4.11 | macmini (bots): { 84.00 101.47 90.25 }
2026-10-17T15:16:15Z thinkpad: 5.56 3.61 4.25 | macmini (bots): { 83.16 99.76 90.02 }
2026-10-17T15:16:45Z thinkpad: 5.66 3.84 4.30 | macmini (bots): { 69.71 94.66 88.56 }
2026-10-17T15:17:15Z thinkpad: 5.04 3.90 4.31 | macmini (bots): { 59.31 89.78 87.00 }
2026-10-17T15:17:46Z thinkpad: 4.29 3.87 4.29 | macmini (bots): { 58.57 86.47 85.89 }
2026-10-17T15:18:16Z thinkpad: 4.16 3.91 4.29 | macmini (bots): { 56.43 83.29 84.75 }
2026-10-17T15:18:46Z thinkpad: 2.80 3.60 4.18 | macmini (bots): { 50.60 79.22 83.22 }
2026-10-17T15:19:16Z thinkpad: 1.85 3.28 4.05 | macmini (bots): { 54.97 77.50 82.46 }
2026-10-17T15:19:47Z thinkpad: 1.82 3.14 3.98 | macmini (bots): { 56.82 75.80 81.67 }
2026-10-17T15:20:17Z thinkpad: 2.19 3.10 3.94 | macmini (bots): { 59.30 74.46 80.97 }
2026-10-17T15:20:47Z thinkpad: 4.44 3.54 4.06 | macmini (bots): { 55.22 72.11 79.90 }
2026-10-17T15:21:17Z thinkpad: 5.99 4.03 4.21 | macmini (bots): { 49.41 69.01 78.50 }
2026-10-17T15:21:48Z thinkpad: 4.85 3.95 4.18 | macmini (bots): { 46.89 66.58 77.29 }
2026-10-17T15:22:18Z thinkpad: 4.31 3.92 4.16 | macmini (bots): { 45.13 64.31 76.10 }
2026-10-17T15:22:48Z thinkpad: 4.28 3.95 4.16 | macmini (bots): { 51.82 63.98 75.56 }
2026-10-17T15:23:18Z thinkpad: 4.75 4.11 4.21 | macmini (bots): { 50.35 62.51 74.63 }
2026-10-17T15:23:48Z thinkpad: 4.08 4.02 4.17 | macmini (bots): { 44.62 60.01 73.31 }
2026-10-17T15:24:19Z thinkpad: 3.93 4.00 4.16 | macmini (bots): { 46.02 58.72 72.37 }
2026-10-17T15:24:49Z thinkpad: 3.67 3.92 4.13 | macmini (bots): { 47.23 57.90 71.60 }
2026-10-17T15:25:19Z thinkpad: 2.79 3.68 4.04 | macmini (bots): { 50.80 57.78 71.09 }
2026-10-17T15:25:49Z thinkpad: 2.79 3.58 3.99 | macmini (bots): { 47.66 56.35 70.11 }
2026-10-17T15:26:19Z thinkpad: 5.04 3.97 4.11 | macmini (bots): { 45.15 54.84 69.08 }
2026-10-17T15:26:50Z thinkpad: 8.46 4.86 4.40 | macmini (bots): { 43.59 53.51 68.10 }
2026-10-17T15:27:20Z thinkpad: 6.84 4.81 4.39 | macmini (bots): { 42.38 52.39 67.19 }
2026-10-17T15:27:50Z thinkpad: 8.24 5.33 4.58 | macmini (bots): { 49.87 53.02 66.82 }
2026-10-17T15:28:21Z thinkpad: 7.11 5.34 4.61 | macmini (bots): { 61.30 55.60 67.28 }
2026-10-17T15:28:51Z thinkpad: 6.11 5.27 4.61 | macmini (bots): { 67.60 57.74 67.66 }
2026-10-17T15:29:21Z thinkpad: 5.33 5.17 4.59 | macmini (bots): { 63.72 57.79 67.33 }
2026-10-17T15:29:51Z thinkpad: 3.57 4.75 4.47 | macmini (bots): { 60.80 57.62 66.94 }
2026-10-17T15:30:22Z thinkpad: 4.74 4.88 4.52 | macmini (bots): { 59.69 57.64 66.62 }
2026-10-17T15:30:52Z thinkpad: 3.44 4.54 4.42 | macmini (bots): { 66.89 59.49 66.97 }
2026-10-17T15:31:22Z thinkpad: 4.57 4.69 4.47 | macmini (bots): { 83.43 64.16 68.40 }
2026-10-17T15:31:52Z thinkpad: 7.17 5.30 4.68 | macmini (bots): { 77.62 64.77 68.49 }
2026-10-17T15:32:23Z thinkpad: 8.55 5.82 4.88 | macmini (bots): { 74.20 65.09 68.47 }
2026-10-17T15:32:53Z thinkpad: 7.49 5.85 4.92 | macmini (bots): { 82.07 67.70 69.29 }
2026-10-17T15:33:23Z thinkpad: 5.65 5.59 4.86 | macmini (bots): { 79.44 68.47 69.51 }
2026-10-17T15:33:54Z thinkpad: 5.73 5.64 4.91 | macmini (bots): { 78.94 69.26 69.76 }
2026-10-17T15:34:24Z thinkpad: 6.84 5.92 5.02 | macmini (bots): { 76.33 69.71 69.91 }
2026-10-17T15:34:54Z thinkpad: 5.11 5.59 4.94 | macmini (bots): { 86.09 72.63 70.95 }
2026-10-17T15:35:24Z thinkpad: 6.68 5.93 5.08 | macmini (bots): { 72.77 70.70 70.31 }
2026-10-17T15:35:55Z thinkpad: 5.75 5.75 5.04 | macmini (bots): { 71.50 70.49 70.24 }
2026-10-17T15:36:25Z thinkpad: 4.31 5.41 4.95 | macmini (bots): { 80.75 72.67 71.03 }
2026-10-17T15:36:55Z thinkpad: 3.15 5.02 4.83 | macmini (bots): { 89.83 75.59 72.14 }
@@ -0,0 +1,695 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T14:56:24+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "a9ee56a8b143999c9bc7056850ffcd622a9214aa",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25,50,100",
"behaviour": "walk,explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 180,
"seed": 12345,
"explore_speed": 0,
"gamerule": "",
"heap": "4g",
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/walk-explore.json",
"nice": 19,
"bots_via": "ssh macmini",
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
"connect": "<server address>",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "walk",
"start_seconds": 9.51,
"mspt": {
"average": 5.77,
"p50": 5.15,
"p95": 11.04,
"p99": 28.99
},
"mspt_worst_p99": 45.9,
"tps": 20.0,
"rss_mb": {
"mean": 777.78,
"max": 779.8
},
"cpu_percent": 59.02,
"chunks_per_second": 9.94,
"unique_per_second": 0.45,
"forgotten_per_second": 9.99,
"waited_ticks_per_second": 0.0,
"chunks_total": 2763,
"chunks_per_bot": {
"walk": 2763.0
},
"unique_chunks_per_bot": {
"walk": 697.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "walk",
"start_seconds": 15.35,
"mspt": {
"average": 18.84,
"p50": 18.87,
"p95": 29.55,
"p99": 51.32
},
"mspt_worst_p99": 456.4,
"tps": 20.0,
"rss_mb": {
"mean": 3442.06,
"max": 3462.1
},
"cpu_percent": 70.63,
"chunks_per_second": 10.47,
"unique_per_second": 0.47,
"forgotten_per_second": 10.53,
"waited_ticks_per_second": 0.0,
"chunks_total": 2763,
"chunks_per_bot": {
"walk": 2763.0
},
"unique_chunks_per_bot": {
"walk": 697.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 10,
"behaviour": "walk",
"start_seconds": 9.31,
"mspt": {
"average": 16.23,
"p50": 15.6,
"p95": 29.32,
"p99": 44.43
},
"mspt_worst_p99": 100.6,
"tps": 20.0,
"rss_mb": {
"mean": 781.84,
"max": 782.6
},
"cpu_percent": 136.59,
"chunks_per_second": 93.82,
"unique_per_second": 3.33,
"forgotten_per_second": 93.61,
"waited_ticks_per_second": 0.0,
"chunks_total": 26481,
"chunks_per_bot": {
"walk": 2648.1
},
"unique_chunks_per_bot": {
"walk": 679.5
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "walk",
"start_seconds": 14.65,
"mspt": {
"average": 18.02,
"p50": 15.22,
"p95": 37.62,
"p99": 54.5
},
"mspt_worst_p99": 73.8,
"tps": 20.0,
"rss_mb": {
"mean": 3408.62,
"max": 3423.6
},
"cpu_percent": 100.57,
"chunks_per_second": 101.12,
"unique_per_second": 4.25,
"forgotten_per_second": 101.17,
"waited_ticks_per_second": 0.0,
"chunks_total": 26763,
"chunks_per_bot": {
"walk": 2676.3
},
"unique_chunks_per_bot": {
"walk": 687.8
},
"resent": 0,
"pulled_back": 7,
"moved_too_quickly": 7,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 25,
"behaviour": "walk",
"start_seconds": 9.31,
"mspt": {
"average": 23.98,
"p50": 20.89,
"p95": 49.18,
"p99": 65.87
},
"mspt_worst_p99": 109.8,
"tps": 20.0,
"rss_mb": {
"mean": 786.18,
"max": 786.5
},
"cpu_percent": 159.81,
"chunks_per_second": 235.75,
"unique_per_second": 7.84,
"forgotten_per_second": 235.72,
"waited_ticks_per_second": 0.0,
"chunks_total": 67251,
"chunks_per_bot": {
"walk": 2690.04
},
"unique_chunks_per_bot": {
"walk": 683.92
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "walk",
"start_seconds": 14.95,
"mspt": {
"average": 19.19,
"p50": 17.34,
"p95": 33.84,
"p99": 56.21
},
"mspt_worst_p99": 79.2,
"tps": 20.0,
"rss_mb": {
"mean": 3531.46,
"max": 3582.1
},
"cpu_percent": 153.78,
"chunks_per_second": 252.35,
"unique_per_second": 11.73,
"forgotten_per_second": 251.58,
"waited_ticks_per_second": 0.0,
"chunks_total": 66759,
"chunks_per_bot": {
"walk": 2670.36
},
"unique_chunks_per_bot": {
"walk": 689.76
},
"resent": 0,
"pulled_back": 12,
"moved_too_quickly": 12,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 50,
"behaviour": "walk",
"start_seconds": 9.21,
"mspt": {
"average": 35.07,
"p50": 30.55,
"p95": 73.24,
"p99": 99.38
},
"mspt_worst_p99": 117.2,
"tps": 20.0,
"rss_mb": {
"mean": 816.58,
"max": 818.5
},
"cpu_percent": 187.42,
"chunks_per_second": 469.11,
"unique_per_second": 15.92,
"forgotten_per_second": 468.62,
"waited_ticks_per_second": 0.0,
"chunks_total": 136962,
"chunks_per_bot": {
"walk": 2739.24
},
"unique_chunks_per_bot": {
"walk": 685.68
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "walk",
"start_seconds": 15.95,
"mspt": {
"average": 35.23,
"p50": 31.39,
"p95": 60.87,
"p99": 104.2
},
"mspt_worst_p99": 443.7,
"tps": 20.0,
"rss_mb": {
"mean": 3562.57,
"max": 3598.9
},
"cpu_percent": 263.27,
"chunks_per_second": 497.51,
"unique_per_second": 30.67,
"forgotten_per_second": 495.7,
"waited_ticks_per_second": 0.0,
"chunks_total": 131116,
"chunks_per_bot": {
"walk": 2622.32
},
"unique_chunks_per_bot": {
"walk": 687.78
},
"resent": 0,
"pulled_back": 96,
"moved_too_quickly": 96,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 100,
"behaviour": "walk",
"start_seconds": 9.21,
"mspt": {
"average": 104.8,
"p50": 100.6,
"p95": 158.8,
"p99": 198.61
},
"mspt_worst_p99": 259.8,
"tps": 9.54,
"rss_mb": {
"mean": 850.05,
"max": 851.2
},
"cpu_percent": 198.95,
"chunks_per_second": 371.72,
"unique_per_second": 26.27,
"forgotten_per_second": 365.57,
"waited_ticks_per_second": 0.0,
"chunks_total": 144465,
"chunks_per_bot": {
"walk": 1444.65
},
"unique_chunks_per_bot": {
"walk": 571.76
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 34
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "walk",
"start_seconds": 15.25,
"error": "[Errno 32] Broken pipe"
},
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 9.41,
"mspt": {
"average": 11.42,
"p50": 10.15,
"p95": 24.66,
"p99": 37.27
},
"mspt_worst_p99": 60.3,
"tps": 20.0,
"rss_mb": {
"mean": 838.61,
"max": 888.0
},
"cpu_percent": 326.85,
"chunks_per_second": 41.83,
"unique_per_second": 36.39,
"forgotten_per_second": 41.75,
"waited_ticks_per_second": 0.0,
"chunks_total": 9845,
"chunks_per_bot": {
"explore": 9845.0
},
"unique_chunks_per_bot": {
"explore": 8718.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "explore",
"start_seconds": 15.44,
"mspt": {
"average": 9.83,
"p50": 7.72,
"p95": 20.36,
"p99": 84.65
},
"mspt_worst_p99": 385.9,
"tps": 20.0,
"rss_mb": {
"mean": 3598.02,
"max": 3658.9
},
"cpu_percent": 244.86,
"chunks_per_second": 35.28,
"unique_per_second": 33.34,
"forgotten_per_second": 35.13,
"waited_ticks_per_second": 0.0,
"chunks_total": 7881,
"chunks_per_bot": {
"explore": 7881.0
},
"unique_chunks_per_bot": {
"explore": 7540.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 9.52,
"mspt": {
"average": 23.54,
"p50": 21.18,
"p95": 43.4,
"p99": 60.85
},
"mspt_worst_p99": 109.1,
"tps": 20.0,
"rss_mb": {
"mean": 1139.51,
"max": 1301.0
},
"cpu_percent": 331.74,
"chunks_per_second": 43.86,
"unique_per_second": 43.09,
"forgotten_per_second": 43.82,
"waited_ticks_per_second": 172.35,
"chunks_total": 14140,
"chunks_per_bot": {
"explore": 1414.0
},
"unique_chunks_per_bot": {
"explore": 1395.9
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "explore",
"start_seconds": 15.78,
"mspt": {
"average": 16.42,
"p50": 14.01,
"p95": 30.29,
"p99": 96.17
},
"mspt_worst_p99": 1144.3,
"tps": 20.0,
"rss_mb": {
"mean": 3964.49,
"max": 4248.3
},
"cpu_percent": 256.86,
"chunks_per_second": 25.43,
"unique_per_second": 25.33,
"forgotten_per_second": 25.96,
"waited_ticks_per_second": 76.05,
"chunks_total": 7991,
"chunks_per_bot": {
"explore": 799.1
},
"unique_chunks_per_bot": {
"explore": 797.7
},
"resent": 0,
"pulled_back": 11028,
"moved_too_quickly": 17,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 9.31,
"mspt": {
"average": 36.87,
"p50": 33.92,
"p95": 63.19,
"p99": 84.17
},
"mspt_worst_p99": 124.2,
"tps": 20.0,
"rss_mb": {
"mean": 1236.2,
"max": 1438.4
},
"cpu_percent": 345.03,
"chunks_per_second": 91.07,
"unique_per_second": 88.7,
"forgotten_per_second": 92.51,
"waited_ticks_per_second": 443.88,
"chunks_total": 30035,
"chunks_per_bot": {
"explore": 1201.4
},
"unique_chunks_per_bot": {
"explore": 1176.84
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "explore",
"start_seconds": 14.55,
"mspt": {
"average": 24.31,
"p50": 21.76,
"p95": 36.55,
"p99": 155.88
},
"mspt_worst_p99": 1535.8,
"tps": 20.0,
"rss_mb": {
"mean": 4047.76,
"max": 4459.2
},
"cpu_percent": 305.24,
"chunks_per_second": 37.94,
"unique_per_second": 37.82,
"forgotten_per_second": 38.87,
"waited_ticks_per_second": 261.79,
"chunks_total": 16604,
"chunks_per_bot": {
"explore": 664.16
},
"unique_chunks_per_bot": {
"explore": 660.92
},
"resent": 0,
"pulled_back": 33246,
"moved_too_quickly": 20,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 50,
"behaviour": "explore",
"start_seconds": 9.21,
"mspt": {
"average": 60.06,
"p50": 57.31,
"p95": 88.38,
"p99": 112.89
},
"mspt_worst_p99": 172.4,
"tps": 16.65,
"rss_mb": {
"mean": 1192.47,
"max": 1372.5
},
"cpu_percent": 362.42,
"chunks_per_second": 157.28,
"unique_per_second": 154.2,
"forgotten_per_second": 156.07,
"waited_ticks_per_second": 890.09,
"chunks_total": 54551,
"chunks_per_bot": {
"explore": 1091.02
},
"unique_chunks_per_bot": {
"explore": 1078.5
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "explore",
"start_seconds": 15.64,
"error": "[Errno 32] Broken pipe"
},
{
"server": "horizon",
"bots": 100,
"behaviour": "explore",
"start_seconds": 9.21,
"mspt": {
"average": 124.25,
"p50": 120.1,
"p95": 168.33,
"p99": 204.64
},
"mspt_worst_p99": 262.2,
"tps": 8.05,
"rss_mb": {
"mean": 1283.26,
"max": 1418.6
},
"cpu_percent": 334.14,
"chunks_per_second": 185.47,
"unique_per_second": 184.54,
"forgotten_per_second": 179.79,
"waited_ticks_per_second": 1864.45,
"chunks_total": 78556,
"chunks_per_bot": {
"explore": 785.56
},
"unique_chunks_per_bot": {
"explore": 784.88
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "explore",
"start_seconds": 15.44,
"error": "[Errno 32] Broken pipe"
}
]
}
@@ -0,0 +1,341 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T18:12:19+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "7bbbf6e97fb53683eae0a01ce2c134e53cb649a7",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25,50",
"behaviour": "explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 180,
"seed": 12345,
"explore_speed": 0.8,
"gamerule": "",
"heap": "4g",
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/explore-equal-rate.json",
"nice": 19,
"bots_via": "ssh macmini",
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
"connect": "<server address>",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 9.42,
"mspt": {
"average": 3.16,
"p50": 2.98,
"p95": 5.78,
"p99": 12.17
},
"mspt_worst_p99": 38.1,
"tps": 20.0,
"rss_mb": {
"mean": 759.26,
"max": 769.5
},
"cpu_percent": 21.74,
"chunks_per_second": 1.64,
"unique_per_second": 1.27,
"forgotten_per_second": 1.54,
"waited_ticks_per_second": 0.0,
"chunks_total": 841,
"chunks_per_bot": {
"explore": 841.0
},
"unique_chunks_per_bot": {
"explore": 781.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "explore",
"start_seconds": 17.17,
"mspt": {
"average": 16.82,
"p50": 16.67,
"p95": 27.3,
"p99": 52.16
},
"mspt_worst_p99": 453.4,
"tps": 20.0,
"rss_mb": {
"mean": 3373.16,
"max": 3405.9
},
"cpu_percent": 69.22,
"chunks_per_second": 1.66,
"unique_per_second": 1.35,
"forgotten_per_second": 1.54,
"waited_ticks_per_second": 0.0,
"chunks_total": 838,
"chunks_per_bot": {
"explore": 838.0
},
"unique_chunks_per_bot": {
"explore": 779.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 9.82,
"mspt": {
"average": 9.83,
"p50": 8.65,
"p95": 19.01,
"p99": 32.9
},
"mspt_worst_p99": 64.0,
"tps": 20.0,
"rss_mb": {
"mean": 861.49,
"max": 922.2
},
"cpu_percent": 77.43,
"chunks_per_second": 13.71,
"unique_per_second": 12.31,
"forgotten_per_second": 13.59,
"waited_ticks_per_second": 0.0,
"chunks_total": 7831,
"chunks_per_bot": {
"explore": 783.1
},
"unique_chunks_per_bot": {
"explore": 757.1
},
"resent": 0,
"pulled_back": 1051,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "explore",
"start_seconds": 15.06,
"mspt": {
"average": 24.38,
"p50": 21.55,
"p95": 39.58,
"p99": 66.64
},
"mspt_worst_p99": 107.1,
"tps": 20.0,
"rss_mb": {
"mean": 3569.41,
"max": 3685.1
},
"cpu_percent": 132.09,
"chunks_per_second": 13.54,
"unique_per_second": 12.32,
"forgotten_per_second": 13.62,
"waited_ticks_per_second": 0.0,
"chunks_total": 7753,
"chunks_per_bot": {
"explore": 775.3
},
"unique_chunks_per_bot": {
"explore": 749.2
},
"resent": 0,
"pulled_back": 1027,
"moved_too_quickly": 6,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 9.31,
"mspt": {
"average": 16.87,
"p50": 15.4,
"p95": 30.88,
"p99": 46.73
},
"mspt_worst_p99": 84.9,
"tps": 20.0,
"rss_mb": {
"mean": 887.11,
"max": 945.5
},
"cpu_percent": 111.2,
"chunks_per_second": 33.51,
"unique_per_second": 29.92,
"forgotten_per_second": 33.33,
"waited_ticks_per_second": 0.0,
"chunks_total": 19718,
"chunks_per_bot": {
"explore": 788.72
},
"unique_chunks_per_bot": {
"explore": 759.08
},
"resent": 0,
"pulled_back": 3770,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "explore",
"start_seconds": 14.94,
"mspt": {
"average": 30.29,
"p50": 28.25,
"p95": 43.47,
"p99": 68.56
},
"mspt_worst_p99": 165.2,
"tps": 20.0,
"rss_mb": {
"mean": 3608.97,
"max": 3732.0
},
"cpu_percent": 176.28,
"chunks_per_second": 32.98,
"unique_per_second": 29.29,
"forgotten_per_second": 32.84,
"waited_ticks_per_second": 0.0,
"chunks_total": 19442,
"chunks_per_bot": {
"explore": 777.68
},
"unique_chunks_per_bot": {
"explore": 748.24
},
"resent": 0,
"pulled_back": 5139,
"moved_too_quickly": 11,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 50,
"behaviour": "explore",
"start_seconds": 9.41,
"mspt": {
"average": 23.57,
"p50": 20.43,
"p95": 47.53,
"p99": 67.23
},
"mspt_worst_p99": 106.2,
"tps": 20.0,
"rss_mb": {
"mean": 924.41,
"max": 984.3
},
"cpu_percent": 127.14,
"chunks_per_second": 66.56,
"unique_per_second": 59.42,
"forgotten_per_second": 66.43,
"waited_ticks_per_second": 0.0,
"chunks_total": 39950,
"chunks_per_bot": {
"explore": 799.0
},
"unique_chunks_per_bot": {
"explore": 767.56
},
"resent": 0,
"pulled_back": 7434,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "explore",
"start_seconds": 15.04,
"mspt": {
"average": 42.44,
"p50": 40.71,
"p95": 54.84,
"p99": 94.23
},
"mspt_worst_p99": 212.3,
"tps": 20.0,
"rss_mb": {
"mean": 3667.85,
"max": 3780.8
},
"cpu_percent": 200.85,
"chunks_per_second": 65.17,
"unique_per_second": 58.11,
"forgotten_per_second": 65.22,
"waited_ticks_per_second": 0.0,
"chunks_total": 39772,
"chunks_per_bot": {
"explore": 795.44
},
"unique_chunks_per_bot": {
"explore": 764.02
},
"resent": 0,
"pulled_back": 10604,
"moved_too_quickly": 38,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
}
]
}
+279
View File
@@ -0,0 +1,279 @@
2026-10-17T15:51:54Z thinkpad: 1.30 2.47 3.85 | macmini (bots): { 67.44 76.34 77.44 }
2026-10-17T15:52:25Z thinkpad: 2.07 2.54 3.83 | macmini (bots): { 64.78 74.76 76.82 }
2026-10-17T15:52:55Z thinkpad: 2.08 2.51 3.78 | macmini (bots): { 62.50 73.29 76.22 }
2026-10-17T15:53:25Z thinkpad: 1.46 2.31 3.67 | macmini (bots): { 63.70 72.67 75.90 }
2026-10-17T15:53:55Z thinkpad: 1.12 2.15 3.58 | macmini (bots): { 69.06 73.27 76.01 }
2026-10-17T15:54:26Z thinkpad: 1.34 2.09 3.51 | macmini (bots): { 72.55 73.63 76.04 }
2026-10-17T15:54:56Z thinkpad: 1.11 1.97 3.42 | macmini (bots): { 77.53 74.77 76.37 }
2026-10-17T15:55:26Z thinkpad: 1.02 1.87 3.34 | macmini (bots): { 77.61 74.90 76.36 }
2026-10-17T15:55:56Z thinkpad: 1.09 1.81 3.27 | macmini (bots): { 71.56 73.69 75.86 }
2026-10-17T15:56:26Z thinkpad: 2.23 1.99 3.29 | macmini (bots): { 65.21 71.93 75.15 }
2026-10-17T15:56:57Z thinkpad: 2.60 2.12 3.29 | macmini (bots): { 58.16 69.40 74.10 }
2026-10-17T15:57:27Z thinkpad: 2.57 2.18 3.27 | macmini (bots): { 57.54 68.26 73.52 }
2026-10-17T15:57:57Z thinkpad: 2.37 2.17 3.23 | macmini (bots): { 56.15 66.85 72.83 }
2026-10-17T15:58:27Z thinkpad: 1.90 2.08 3.17 | macmini (bots): { 59.28 66.57 72.52 }
2026-10-17T15:58:58Z thinkpad: 1.71 2.02 3.11 | macmini (bots): { 55.64 64.94 71.72 }
2026-10-17T15:59:28Z thinkpad: 1.66 1.97 3.06 | macmini (bots): { 66.06 66.49 72.04 }
2026-10-17T15:59:58Z thinkpad: 1.18 1.83 2.98 | macmini (bots): { 78.15 69.43 72.92 }
2026-10-17T16:00:28Z thinkpad: 0.83 1.68 2.89 | macmini (bots): { 70.77 68.57 72.49 }
2026-10-17T16:00:58Z thinkpad: 1.27 1.70 2.86 | macmini (bots): { 71.95 68.97 72.49 }
2026-10-17T16:01:29Z thinkpad: 1.85 1.79 2.85 | macmini (bots): { 83.43 72.22 73.56 }
2026-10-17T16:01:59Z thinkpad: 2.51 1.95 2.86 | macmini (bots): { 70.49 70.18 72.77 }
2026-10-17T16:02:29Z thinkpad: 1.98 1.87 2.81 | macmini (bots): { 64.00 68.64 72.12 }
2026-10-17T16:02:59Z thinkpad: 1.77 1.84 2.77 | macmini (bots): { 59.65 67.09 71.44 }
2026-10-17T16:03:30Z thinkpad: 4.04 2.35 2.91 | macmini (bots): { 62.67 67.15 71.31 }
2026-10-17T16:04:00Z thinkpad: 4.94 2.72 3.01 | macmini (bots): { 67.63 67.82 71.40 }
2026-10-17T16:04:30Z thinkpad: 4.22 2.78 3.02 | macmini (bots): { 70.41 68.39 71.48 }
2026-10-17T16:05:00Z thinkpad: 3.81 2.80 3.02 | macmini (bots): { 66.59 67.83 71.18 }
2026-10-17T16:05:31Z thinkpad: 3.20 2.75 3.00 | macmini (bots): { 72.53 69.01 71.48 }
2026-10-17T16:06:01Z thinkpad: 4.22 3.04 3.08 | macmini (bots): { 71.30 69.27 71.50 }
2026-10-17T16:06:31Z thinkpad: 5.43 3.47 3.23 | macmini (bots): { 68.37 68.63 71.19 }
2026-10-17T16:07:01Z thinkpad: 4.35 3.42 3.22 | macmini (bots): { 60.20 66.68 70.39 }
2026-10-17T16:07:31Z thinkpad: 3.47 3.31 3.19 | macmini (bots): { 58.03 65.51 69.84 }
2026-10-17T16:08:02Z thinkpad: 2.30 3.04 3.11 | macmini (bots): { 59.05 65.13 69.55 }
2026-10-17T16:08:32Z thinkpad: 1.88 2.86 3.04 | macmini (bots): { 51.77 62.75 68.53 }
2026-10-17T16:09:02Z thinkpad: 1.38 2.65 2.97 | macmini (bots): { 49.05 61.01 67.70 }
2026-10-17T16:09:32Z thinkpad: 3.10 2.95 3.06 | macmini (bots): { 48.91 59.82 67.03 }
2026-10-17T16:10:02Z thinkpad: 2.91 2.91 3.04 | macmini (bots): { 47.56 58.44 66.25 }
2026-10-17T16:10:33Z thinkpad: 2.58 2.83 3.01 | macmini (bots): { 49.23 57.69 65.70 }
2026-10-17T16:11:03Z thinkpad: 8.38 4.15 3.44 | macmini (bots): { 44.16 55.72 64.71 }
2026-10-17T16:11:33Z thinkpad: 9.94 4.92 3.72 | macmini (bots): { 44.50 54.71 64.03 }
2026-10-17T16:12:03Z thinkpad: 10.08 5.42 3.92 | macmini (bots): { 42.15 53.21 63.17 }
2026-10-17T16:12:33Z thinkpad: 6.79 5.06 3.85 | macmini (bots): { 36.13 50.73 61.92 }
2026-10-17T16:13:04Z thinkpad: 4.61 4.72 3.77 | macmini (bots): { 41.28 50.64 61.51 }
2026-10-17T16:13:34Z thinkpad: 3.81 4.50 3.73 | macmini (bots): { 42.92 50.15 60.95 }
2026-10-17T16:14:04Z thinkpad: 3.39 4.35 3.71 | macmini (bots): { 41.56 49.16 60.22 }
2026-10-17T16:14:35Z thinkpad: 7.53 5.17 4.00 | macmini (bots): { 47.48 49.82 60.07 }
2026-10-17T16:15:05Z thinkpad: 9.73 6.01 4.32 | macmini (bots): { 51.43 50.41 59.92 }
2026-10-17T16:15:35Z thinkpad: 8.07 5.94 4.35 | macmini (bots): { 69.31 54.81 61.18 }
2026-10-17T16:16:05Z thinkpad: 9.27 6.45 4.57 | macmini (bots): { 71.85 56.92 61.73 }
2026-10-17T16:16:35Z thinkpad: 8.69 6.59 4.68 | macmini (bots): { 64.77 56.72 61.50 }
2026-10-17T16:17:06Z thinkpad: 6.11 6.17 4.60 | macmini (bots): { 57.92 55.86 61.02 }
2026-10-17T16:17:36Z thinkpad: 6.79 6.33 4.70 | macmini (bots): { 51.44 54.51 60.36 }
2026-10-17T16:18:06Z thinkpad: 5.16 5.99 4.64 | macmini (bots): { 51.33 54.23 60.05 }
2026-10-17T16:18:36Z thinkpad: 5.30 5.93 4.66 | macmini (bots): { 55.22 54.87 60.08 }
2026-10-17T16:19:07Z thinkpad: 4.78 5.72 4.63 | macmini (bots): { 51.62 53.94 59.56 }
2026-10-17T16:19:37Z thinkpad: 5.25 5.78 4.69 | macmini (bots): { 48.77 53.04 59.04 }
2026-10-17T16:20:07Z thinkpad: 4.63 5.60 4.67 | macmini (bots): { 54.59 53.92 59.14 }
2026-10-17T16:20:37Z thinkpad: 5.46 5.67 4.72 | macmini (bots): { 59.07 55.03 59.36 }
2026-10-17T16:21:07Z thinkpad: 8.07 6.19 4.91 | macmini (bots): { 52.53 53.83 58.77 }
2026-10-17T16:21:38Z thinkpad: 15.01 8.05 5.57 | macmini (bots): { 48.85 52.78 58.19 }
2026-10-17T16:22:08Z thinkpad: 19.08 9.69 6.20 | macmini (bots): { 54.73 53.61 58.29 }
2026-10-17T16:22:39Z thinkpad: 27.72 12.63 7.29 | macmini (bots): { 54.23 53.61 58.13 }
2026-10-17T16:23:09Z thinkpad: 23.10 12.88 7.53 | macmini (bots): { 51.63 53.06 57.77 }
2026-10-17T16:23:39Z thinkpad: 20.51 13.53 7.96 | macmini (bots): { 45.29 51.38 57.00 }
2026-10-17T16:24:10Z thinkpad: 12.70 12.30 7.72 | macmini (bots): { 45.61 50.91 56.63 }
2026-10-17T16:24:40Z thinkpad: 8.67 11.35 7.55 | macmini (bots): { 45.18 50.35 56.23 }
2026-10-17T16:25:10Z thinkpad: 6.35 10.53 7.40 | macmini (bots): { 41.10 48.88 55.50 }
2026-10-17T16:25:40Z thinkpad: 6.26 10.16 7.38 | macmini (bots): { 49.86 50.24 55.76 }
2026-10-17T16:26:11Z thinkpad: 7.71 10.11 7.45 | macmini (bots): { 53.93 51.11 55.88 }
2026-10-17T16:26:41Z thinkpad: 9.50 10.32 7.61 | macmini (bots): { 47.61 49.86 55.26 }
2026-10-17T16:27:11Z thinkpad: 10.26 10.39 7.72 | macmini (bots): { 46.43 49.36 54.89 }
2026-10-17T16:27:42Z thinkpad: 10.61 10.48 7.83 | macmini (bots): { 48.88 49.66 54.80 }
2026-10-17T16:28:12Z thinkpad: 10.39 10.45 7.91 | macmini (bots): { 58.42 51.74 55.37 }
2026-10-17T16:28:42Z thinkpad: 10.36 10.44 7.99 | macmini (bots): { 59.59 52.67 55.58 }
2026-10-17T16:29:13Z thinkpad: 9.63 10.25 8.00 | macmini (bots): { 55.30 52.38 55.38 }
2026-10-17T16:29:43Z thinkpad: 10.33 10.37 8.12 | macmini (bots): { 57.79 53.13 55.52 }
2026-10-17T16:30:13Z thinkpad: 9.68 10.18 8.12 | macmini (bots): { 57.83 53.60 55.61 }
2026-10-17T16:30:43Z thinkpad: 9.72 10.17 8.19 | macmini (bots): { 78.94 59.18 57.56 }
2026-10-17T16:31:14Z thinkpad: 6.60 9.36 7.98 | macmini (bots): { 87.29 62.98 58.99 }
2026-10-17T16:31:44Z thinkpad: 6.77 9.08 7.93 | macmini (bots): { 81.82 64.16 59.57 }
2026-10-17T16:32:14Z thinkpad: 13.15 10.41 8.41 | macmini (bots): { 81.20 65.92 60.37 }
2026-10-17T16:32:45Z thinkpad: 27.80 14.12 9.71 | macmini (bots): { 75.41 66.22 60.69 }
2026-10-17T16:33:15Z thinkpad: 44.59 19.25 11.54 | macmini (bots): { 66.92 65.08 60.47 }
2026-10-17T16:33:46Z thinkpad: 44.87 21.89 12.67 | macmini (bots): { 61.47 63.94 60.22 }
2026-10-17T16:34:16Z thinkpad: 38.98 22.67 13.23 | macmini (bots): { 62.13 63.78 60.28 }
2026-10-17T16:34:47Z thinkpad: 47.64 26.08 14.65 | macmini (bots): { 81.23 68.06 61.94 }
2026-10-17T16:35:17Z thinkpad: 47.83 28.02 15.65 | macmini (bots): { 116.14 78.43 65.98 }
2026-10-17T16:35:48Z thinkpad: 40.43 28.18 16.10 | macmini (bots): { 125.61 84.41 68.58 }
2026-10-17T16:36:19Z thinkpad: 41.34 29.71 17.00 | macmini (bots): { 143.61 92.86 72.20 }
2026-10-17T16:36:49Z thinkpad: 34.40 29.19 17.29 | macmini (bots): { 156.05 100.88 75.83 }
2026-10-17T16:37:20Z thinkpad: 25.34 27.65 17.17 | macmini (bots): { 151.90 105.26 78.29 }
2026-10-17T16:37:50Z thinkpad: 18.36 25.71 16.85 | macmini (bots): { 141.10 107.25 79.96 }
2026-10-17T16:38:20Z thinkpad: 16.19 24.47 16.72 | macmini (bots): { 131.21 107.92 81.13 }
2026-10-17T16:38:51Z thinkpad: 13.36 23.00 16.48 | macmini (bots): { 122.08 107.78 82.00 }
2026-10-17T16:39:21Z thinkpad: 11.27 21.57 16.21 | macmini (bots): { 107.07 105.50 82.07 }
2026-10-17T16:39:51Z thinkpad: 11.19 20.56 16.04 | macmini (bots): { 96.38 103.12 82.02 }
2026-10-17T16:40:22Z thinkpad: 10.06 19.37 15.79 | macmini (bots): { 89.06 100.79 81.91 }
2026-10-17T16:40:52Z thinkpad: 7.38 17.86 15.40 | macmini (bots): { 79.35 97.41 81.34 }
2026-10-17T16:41:23Z thinkpad: 6.18 16.57 15.05 | macmini (bots): { 81.70 96.20 81.46 }
2026-10-17T16:41:53Z thinkpad: 5.56 15.44 14.72 | macmini (bots): { 86.51 95.88 81.84 }
2026-10-17T16:42:23Z thinkpad: 4.90 14.33 14.37 | macmini (bots): { 99.20 97.69 83.05 }
2026-10-17T16:42:53Z thinkpad: 5.01 13.47 14.08 | macmini (bots): { 116.29 101.87 85.07 }
2026-10-17T16:43:24Z thinkpad: 4.34 12.50 13.74 | macmini (bots): { 131.32 106.77 87.42 }
2026-10-17T16:43:54Z thinkpad: 4.08 11.65 13.42 | macmini (bots): { 155.44 114.77 90.98 }
2026-10-17T16:44:24Z thinkpad: 4.00 10.90 13.11 | macmini (bots): { 167.86 121.62 94.28 }
2026-10-17T16:44:54Z thinkpad: 3.98 10.13 12.77 | macmini (bots): { 160.36 124.31 96.20 }
2026-10-17T16:45:25Z thinkpad: 4.20 9.59 12.50 | macmini (bots): { 163.23 128.59 98.74 }
2026-10-17T16:45:55Z thinkpad: 4.54 9.15 12.27 | macmini (bots): { 169.93 133.73 101.65 }
2026-10-17T16:46:25Z thinkpad: 4.44 8.69 12.01 | macmini (bots): { 162.40 135.22 103.29 }
2026-10-17T16:46:55Z thinkpad: 4.06 8.20 11.74 | macmini (bots): { 164.16 138.31 105.52 }
2026-10-17T16:47:26Z thinkpad: 3.96 7.78 11.49 | macmini (bots): { 157.92 139.31 107.02 }
2026-10-17T16:47:56Z thinkpad: 3.71 7.35 11.23 | macmini (bots): { 155.72 140.55 108.59 }
2026-10-17T16:48:26Z thinkpad: 4.93 7.32 11.09 | macmini (bots): { 153.39 141.52 110.05 }
2026-10-17T16:48:56Z thinkpad: 4.61 7.03 10.88 | macmini (bots): { 137.54 138.87 110.18 }
2026-10-17T16:49:27Z thinkpad: 4.01 6.65 10.63 | macmini (bots): { 136.53 138.36 110.98 }
2026-10-17T16:49:57Z thinkpad: 3.19 6.19 10.35 | macmini (bots): { 141.12 139.18 112.22 }
2026-10-17T16:50:27Z thinkpad: 5.31 6.43 10.30 | macmini (bots): { 122.76 135.27 111.75 }
2026-10-17T16:50:57Z thinkpad: 5.95 6.48 10.19 | macmini (bots): { 114.26 132.02 111.39 }
2026-10-17T16:51:28Z thinkpad: 5.76 6.41 10.05 | macmini (bots): { 116.04 130.87 111.69 }
2026-10-17T16:51:58Z thinkpad: 5.11 6.18 9.86 | macmini (bots): { 115.06 129.17 111.74 }
2026-10-17T16:52:28Z thinkpad: 5.09 6.07 9.70 | macmini (bots): { 109.91 126.59 111.41 }
2026-10-17T16:52:58Z thinkpad: 4.74 5.90 9.53 | macmini (bots): { 101.90 123.11 110.67 }
2026-10-17T16:53:29Z thinkpad: 4.89 5.84 9.39 | macmini (bots): { 83.29 116.38 108.74 }
2026-10-17T16:53:59Z thinkpad: 5.10 5.82 9.27 | macmini (bots): { 119.16 122.10 111.09 }
2026-10-17T16:54:29Z thinkpad: 4.57 5.67 9.10 | macmini (bots): { 114.87 120.76 110.98 }
2026-10-17T16:55:00Z thinkpad: 4.74 5.58 8.96 | macmini (bots): { 129.84 123.74 112.39 }
2026-10-17T16:55:30Z thinkpad: 6.40 5.90 8.96 | macmini (bots): { 100.10 117.29 110.46 }
2026-10-17T16:56:00Z thinkpad: 6.96 6.08 8.92 | macmini (bots): { 80.18 110.75 108.32 }
2026-10-17T16:56:30Z thinkpad: 6.07 5.95 8.79 | macmini (bots): { 74.41 106.39 106.82 }
2026-10-17T16:57:01Z thinkpad: 6.63 6.13 8.75 | macmini (bots): { 81.71 104.99 106.29 }
2026-10-17T16:57:31Z thinkpad: 6.39 6.14 8.68 | macmini (bots): { 101.10 107.55 107.18 }
2026-10-17T16:58:01Z thinkpad: 6.32 6.16 8.60 | macmini (bots): { 94.45 105.66 106.52 }
2026-10-17T16:58:31Z thinkpad: 5.19 5.90 8.44 | macmini (bots): { 78.76 100.83 104.75 }
2026-10-17T16:59:02Z thinkpad: 5.24 5.83 8.33 | macmini (bots): { 78.19 98.42 103.73 }
2026-10-17T16:59:32Z thinkpad: 3.95 5.48 8.13 | macmini (bots): { 77.57 96.42 102.82 }
2026-10-17T17:00:02Z thinkpad: 3.12 5.13 7.93 | macmini (bots): { 85.23 96.29 102.54 }
2026-10-17T17:00:32Z thinkpad: 3.17 4.93 7.77 | macmini (bots): { 99.84 99.07 103.35 }
2026-10-17T17:01:02Z thinkpad: 6.09 5.50 7.88 | macmini (bots): { 80.56 94.49 101.54 }
2026-10-17T17:01:33Z thinkpad: 9.23 6.29 8.06 | macmini (bots): { 79.70 93.03 100.77 }
2026-10-17T17:02:03Z thinkpad: 9.09 6.54 8.09 | macmini (bots): { 76.63 90.94 99.74 }
2026-10-17T17:02:33Z thinkpad: 7.86 6.52 8.04 | macmini (bots): { 93.08 93.50 100.36 }
2026-10-17T17:03:03Z thinkpad: 7.81 6.67 8.04 | macmini (bots): { 83.14 91.09 99.25 }
2026-10-17T17:03:34Z thinkpad: 6.69 6.51 7.94 | macmini (bots): { 79.37 89.64 98.45 }
2026-10-17T17:04:04Z thinkpad: 5.63 6.27 7.81 | macmini (bots): { 73.68 87.23 97.27 }
2026-10-17T17:04:34Z thinkpad: 5.75 6.21 7.75 | macmini (bots): { 75.89 86.23 96.54 }
2026-10-17T17:05:04Z thinkpad: 5.05 6.01 7.63 | macmini (bots): { 79.29 86.13 96.10 }
2026-10-17T17:05:35Z thinkpad: 3.88 5.62 7.44 | macmini (bots): { 76.07 84.79 95.28 }
2026-10-17T17:06:05Z thinkpad: 6.47 6.07 7.53 | macmini (bots): { 70.59 82.62 94.13 }
2026-10-17T17:06:35Z thinkpad: 8.20 6.53 7.63 | macmini (bots): { 69.09 81.18 93.21 }
2026-10-17T17:07:05Z thinkpad: 7.59 6.54 7.60 | macmini (bots): { 77.55 82.11 93.13 }
2026-10-17T17:07:36Z thinkpad: 6.93 6.49 7.55 | macmini (bots): { 84.23 83.41 93.22 }
2026-10-17T17:08:06Z thinkpad: 7.21 6.61 7.56 | macmini (bots): { 84.31 83.68 92.99 }
2026-10-17T17:08:36Z thinkpad: 6.73 6.57 7.51 | macmini (bots): { 81.04 82.99 92.42 }
2026-10-17T17:09:06Z thinkpad: 6.12 6.43 7.44 | macmini (bots): { 86.55 83.77 92.35 }
2026-10-17T17:09:36Z thinkpad: 6.77 6.58 7.46 | macmini (bots): { 95.19 86.31 92.99 }
2026-10-17T17:10:07Z thinkpad: 7.13 6.68 7.46 | macmini (bots): { 111.30 91.11 94.50 }
2026-10-17T17:10:37Z thinkpad: 5.09 6.23 7.29 | macmini (bots): { 118.51 95.17 95.87 }
2026-10-17T17:11:07Z thinkpad: 4.00 5.86 7.13 | macmini (bots): { 121.12 98.01 96.88 }
2026-10-17T17:11:38Z thinkpad: 3.50 5.57 6.99 | macmini (bots): { 109.57 97.67 96.81 }
2026-10-17T17:12:08Z thinkpad: 2.92 5.22 6.83 | macmini (bots): { 108.43 98.44 97.11 }
2026-10-17T17:12:38Z thinkpad: 3.42 5.12 6.74 | macmini (bots): { 101.61 97.86 96.95 }
2026-10-17T17:13:09Z thinkpad: 5.64 5.51 6.82 | macmini (bots): { 101.66 98.17 97.09 }
2026-10-17T17:13:39Z thinkpad: 7.06 5.89 6.91 | macmini (bots): { 88.43 95.36 96.11 }
2026-10-17T17:14:09Z thinkpad: 7.61 6.13 6.96 | macmini (bots): { 79.03 92.54 95.07 }
2026-10-17T17:14:39Z thinkpad: 9.06 6.68 7.12 | macmini (bots): { 92.55 94.57 95.72 }
2026-10-17T17:15:10Z thinkpad: 9.81 7.06 7.22 | macmini (bots): { 93.71 94.49 95.64 }
2026-10-17T17:15:40Z thinkpad: 9.98 7.41 7.34 | macmini (bots): { 92.26 93.86 95.35 }
2026-10-17T17:16:10Z thinkpad: 7.60 7.08 7.23 | macmini (bots): { 88.61 92.91 94.96 }
2026-10-17T17:16:40Z thinkpad: 6.81 6.93 7.17 | macmini (bots): { 96.96 94.47 95.45 }
2026-10-17T17:17:11Z thinkpad: 6.31 6.81 7.12 | macmini (bots): { 102.92 96.14 96.02 }
2026-10-17T17:17:41Z thinkpad: 4.81 6.38 6.97 | macmini (bots): { 91.20 94.16 95.32 }
2026-10-17T17:18:11Z thinkpad: 7.36 6.81 7.09 | macmini (bots): { 93.60 94.31 95.32 }
2026-10-17T17:18:41Z thinkpad: 9.42 7.38 7.27 | macmini (bots): { 90.86 93.57 95.02 }
2026-10-17T17:19:11Z thinkpad: 9.95 7.72 7.39 | macmini (bots): { 88.94 92.94 94.75 }
2026-10-17T17:19:42Z thinkpad: 9.51 7.85 7.45 | macmini (bots): { 94.97 93.93 95.03 }
2026-10-17T17:20:12Z thinkpad: 8.97 7.90 7.48 | macmini (bots): { 80.46 90.39 93.71 }
2026-10-17T17:20:42Z thinkpad: 7.56 7.65 7.41 | macmini (bots): { 74.38 87.95 92.71 }
2026-10-17T17:21:12Z thinkpad: 6.39 7.36 7.32 | macmini (bots): { 77.48 87.26 92.28 }
2026-10-17T17:21:43Z thinkpad: 5.21 6.98 7.19 | macmini (bots): { 82.05 87.48 92.19 }
2026-10-17T17:22:13Z thinkpad: 3.87 6.49 7.02 | macmini (bots): { 88.15 88.45 92.38 }
2026-10-17T17:22:43Z thinkpad: 3.86 6.22 6.91 | macmini (bots): { 100.23 91.12 93.19 }
2026-10-17T17:23:13Z thinkpad: 8.32 7.05 7.16 | macmini (bots): { 84.55 88.39 92.15 }
2026-10-17T17:23:43Z thinkpad: 13.84 8.37 7.59 | macmini (bots): { 73.74 85.38 90.92 }
2026-10-17T17:24:14Z thinkpad: 21.02 10.71 8.40 | macmini (bots): { 71.36 83.72 90.13 }
2026-10-17T17:24:44Z thinkpad: 20.73 11.82 8.85 | macmini (bots): { 82.27 85.04 90.38 }
2026-10-17T17:25:14Z thinkpad: 16.80 11.77 8.94 | macmini (bots): { 78.63 83.92 89.78 }
2026-10-17T17:25:44Z thinkpad: 14.62 11.68 8.99 | macmini (bots): { 82.33 84.06 89.61 }
2026-10-17T17:26:15Z thinkpad: 14.51 11.94 9.17 | macmini (bots): { 74.13 81.89 88.63 }
2026-10-17T17:26:45Z thinkpad: 12.48 11.74 9.20 | macmini (bots): { 75.65 81.46 88.24 }
2026-10-17T17:27:15Z thinkpad: 15.24 12.56 9.56 | macmini (bots): { 71.35 79.78 87.35 }
2026-10-17T17:27:45Z thinkpad: 15.70 12.87 9.76 | macmini (bots): { 68.21 78.17 86.51 }
2026-10-17T17:28:16Z thinkpad: 12.80 12.47 9.72 | macmini (bots): { 79.74 80.10 86.92 }
2026-10-17T17:28:46Z thinkpad: 9.05 11.58 9.51 | macmini (bots): { 101.01 85.27 88.56 }
2026-10-17T17:29:16Z thinkpad: 9.38 11.41 9.53 | macmini (bots): { 93.76 85.18 88.43 }
2026-10-17T17:29:46Z thinkpad: 11.08 11.62 9.65 | macmini (bots): { 74.91 81.56 87.01 }
2026-10-17T17:30:17Z thinkpad: 10.98 11.53 9.69 | macmini (bots): { 61.10 77.50 85.34 }
2026-10-17T17:30:47Z thinkpad: 9.83 11.21 9.64 | macmini (bots): { 51.19 73.56 83.64 }
2026-10-17T17:31:18Z thinkpad: 9.65 11.03 9.63 | macmini (bots): { 49.64 71.03 82.37 }
2026-10-17T17:31:48Z thinkpad: 7.22 10.33 9.44 | macmini (bots): { 53.62 69.90 81.56 }
2026-10-17T17:32:18Z thinkpad: 5.79 9.68 9.25 | macmini (bots): { 61.07 70.36 81.34 }
2026-10-17T17:32:48Z thinkpad: 3.70 8.80 8.97 | macmini (bots): { 60.39 69.24 80.55 }
2026-10-17T17:33:19Z thinkpad: 2.72 8.08 8.73 | macmini (bots): { 57.06 67.51 79.53 }
2026-10-17T17:33:49Z thinkpad: 1.97 7.37 8.47 | macmini (bots): { 65.72 68.41 79.42 }
2026-10-17T17:34:19Z thinkpad: 1.25 6.68 8.20 | macmini (bots): { 62.60 67.40 78.67 }
2026-10-17T17:34:49Z thinkpad: 0.94 6.09 7.96 | macmini (bots): { 71.83 69.24 78.95 }
2026-10-17T17:35:20Z thinkpad: 0.72 5.54 7.71 | macmini (bots): { 66.16 68.07 78.19 }
2026-10-17T17:35:50Z thinkpad: 0.44 5.01 7.47 | macmini (bots): { 70.96 68.91 78.13 }
2026-10-17T17:36:20Z thinkpad: 1.70 4.79 7.30 | macmini (bots): { 79.74 71.45 78.74 }
2026-10-17T17:36:50Z thinkpad: 3.03 4.80 7.22 | macmini (bots): { 79.71 72.75 78.95 }
2026-10-17T17:37:21Z thinkpad: 3.07 4.67 7.10 | macmini (bots): { 85.13 74.60 79.40 }
2026-10-17T17:37:51Z thinkpad: 2.32 4.33 6.91 | macmini (bots): { 84.85 75.70 79.64 }
2026-10-17T17:38:21Z thinkpad: 1.83 4.03 6.73 | macmini (bots): { 86.47 76.79 79.88 }
2026-10-17T17:38:51Z thinkpad: 1.11 3.64 6.51 | macmini (bots): { 106.96 82.74 81.94 }
2026-10-17T17:39:22Z thinkpad: 1.03 3.37 6.33 | macmini (bots): { 114.52 87.01 83.52 }
2026-10-17T17:39:52Z thinkpad: 1.11 3.16 6.16 | macmini (bots): { 103.99 87.05 83.65 }
2026-10-17T17:40:22Z thinkpad: 1.31 3.02 6.02 | macmini (bots): { 100.27 87.59 83.95 }
2026-10-17T17:40:52Z thinkpad: 1.36 2.87 5.87 | macmini (bots): { 98.17 88.44 84.40 }
2026-10-17T17:41:22Z thinkpad: 2.01 2.88 5.78 | macmini (bots): { 96.27 88.67 84.60 }
2026-10-17T17:41:53Z thinkpad: 1.96 2.77 5.65 | macmini (bots): { 96.31 89.44 85.03 }
2026-10-17T17:42:23Z thinkpad: 3.06 2.97 5.62 | macmini (bots): { 80.49 86.30 84.04 }
2026-10-17T17:42:53Z thinkpad: 2.38 2.81 5.49 | macmini (bots): { 66.40 82.24 82.64 }
2026-10-17T17:43:24Z thinkpad: 2.44 2.81 5.40 | macmini (bots): { 56.70 78.37 81.22 }
2026-10-17T17:43:54Z thinkpad: 1.93 2.65 5.26 | macmini (bots): { 61.56 77.39 80.77 }
2026-10-17T17:44:24Z thinkpad: 1.96 2.59 5.16 | macmini (bots): { 58.25 75.14 79.83 }
2026-10-17T17:44:54Z thinkpad: 2.25 2.60 5.08 | macmini (bots): { 56.32 73.21 78.98 }
2026-10-17T17:45:24Z thinkpad: 1.79 2.46 4.95 | macmini (bots): { 62.87 73.14 78.75 }
2026-10-17T17:45:55Z thinkpad: 2.78 2.64 4.93 | macmini (bots): { 62.58 72.22 78.23 }
2026-10-17T17:46:25Z thinkpad: 4.82 3.14 5.03 | macmini (bots): { 58.89 70.44 77.38 }
2026-10-17T17:46:55Z thinkpad: 3.10 2.90 4.88 | macmini (bots): { 61.68 70.08 77.01 }
2026-10-17T17:47:25Z thinkpad: 2.27 2.71 4.75 | macmini (bots): { 70.43 71.40 77.25 }
2026-10-17T17:47:56Z thinkpad: 1.71 2.53 4.62 | macmini (bots): { 70.51 71.56 77.12 }
2026-10-17T17:48:26Z thinkpad: 1.59 2.42 4.52 | macmini (bots): { 68.98 70.98 76.71 }
2026-10-17T17:48:56Z thinkpad: 2.09 2.47 4.47 | macmini (bots): { 67.75 70.60 76.38 }
2026-10-17T17:49:26Z thinkpad: 2.14 2.44 4.39 | macmini (bots): { 61.53 69.01 75.58 }
2026-10-17T17:49:56Z thinkpad: 2.22 2.43 4.33 | macmini (bots): { 58.49 67.45 74.78 }
2026-10-17T17:50:27Z thinkpad: 1.98 2.36 4.24 | macmini (bots): { 66.15 68.33 74.84 }
2026-10-17T17:50:57Z thinkpad: 2.38 2.42 4.20 | macmini (bots): { 61.65 67.22 74.22 }
2026-10-17T17:51:27Z thinkpad: 3.62 2.72 4.24 | macmini (bots): { 54.05 64.88 73.13 }
2026-10-17T17:51:57Z thinkpad: 3.25 2.70 4.19 | macmini (bots): { 49.14 62.62 72.02 }
2026-10-17T17:52:28Z thinkpad: 2.45 2.55 4.09 | macmini (bots): { 43.72 60.06 70.77 }
2026-10-17T17:52:58Z thinkpad: 2.85 2.62 4.06 | macmini (bots): { 42.53 58.22 69.73 }
2026-10-17T17:53:28Z thinkpad: 8.44 3.97 4.46 | macmini (bots): { 45.21 57.34 69.01 }
2026-10-17T17:53:58Z thinkpad: 10.75 4.91 4.76 | macmini (bots): { 44.86 56.01 68.11 }
2026-10-17T17:54:29Z thinkpad: 8.36 4.90 4.76 | macmini (bots): { 51.73 56.60 67.91 }
2026-10-17T17:54:59Z thinkpad: 7.41 4.98 4.79 | macmini (bots): { 55.80 57.05 67.67 }
2026-10-17T17:55:29Z thinkpad: 5.45 4.73 4.71 | macmini (bots): { 50.40 55.67 66.81 }
2026-10-17T17:55:59Z thinkpad: 6.26 4.97 4.79 | macmini (bots): { 57.22 56.88 66.87 }
2026-10-17T17:56:30Z thinkpad: 8.22 5.52 4.98 | macmini (bots): { 48.94 54.86 65.78 }
2026-10-17T17:57:00Z thinkpad: 6.71 5.42 4.96 | macmini (bots): { 50.54 54.70 65.35 }
2026-10-17T17:57:30Z thinkpad: 4.94 5.14 4.89 | macmini (bots): { 58.02 56.25 65.55 }
2026-10-17T17:58:01Z thinkpad: 4.54 5.04 4.86 | macmini (bots): { 53.98 55.38 64.90 }
2026-10-17T17:58:31Z thinkpad: 4.65 5.04 4.87 | macmini (bots): { 49.96 54.23 64.16 }
2026-10-17T17:59:01Z thinkpad: 5.29 5.16 4.92 | macmini (bots): { 49.63 53.68 63.61 }
2026-10-17T17:59:31Z thinkpad: 4.23 4.94 4.85 | macmini (bots): { 57.15 55.10 63.78 }
2026-10-17T18:00:01Z thinkpad: 3.57 4.72 4.78 | macmini (bots): { 61.70 56.26 63.89 }
2026-10-17T18:00:32Z thinkpad: 3.16 4.52 4.71 | macmini (bots): { 62.10 56.86 63.79 }
2026-10-17T18:01:02Z thinkpad: 2.81 4.29 4.63 | macmini (bots): { 76.29 60.79 64.98 }
2026-10-17T18:01:32Z thinkpad: 7.77 5.26 4.93 | macmini (bots): { 75.36 62.16 65.34 }
2026-10-17T18:02:02Z thinkpad: 11.60 6.45 5.34 | macmini (bots): { 70.83 62.28 65.27 }
2026-10-17T18:02:33Z thinkpad: 11.54 6.97 5.56 | macmini (bots): { 65.66 61.87 65.01 }
2026-10-17T18:03:03Z thinkpad: 10.80 7.33 5.73 | macmini (bots): { 63.09 61.54 64.78 }
2026-10-17T18:03:33Z thinkpad: 10.52 7.56 5.85 | macmini (bots): { 60.31 61.12 64.52 }
2026-10-17T18:04:04Z thinkpad: 10.27 7.76 5.97 | macmini (bots): { 60.99 61.21 64.44 }
2026-10-17T18:04:34Z thinkpad: 8.77 7.69 6.01 | macmini (bots): { 56.72 60.19 63.96 }
2026-10-17T18:05:05Z thinkpad: 5.77 7.06 5.85 | macmini (bots): { 56.14 59.70 63.65 }
2026-10-17T18:05:35Z thinkpad: 5.16 6.79 5.80 | macmini (bots): { 51.25 58.17 62.95 }
2026-10-17T18:06:05Z thinkpad: 4.16 6.36 5.69 | macmini (bots): { 52.03 57.64 62.59 }
2026-10-17T18:06:35Z thinkpad: 4.28 6.18 5.65 | macmini (bots): { 64.66 60.00 63.27 }
2026-10-17T18:07:06Z thinkpad: 7.47 6.76 5.86 | macmini (bots): { 56.20 58.45 62.59 }
2026-10-17T18:07:36Z thinkpad: 6.95 6.74 5.89 | macmini (bots): { 51.92 57.20 62.00 }
2026-10-17T18:08:06Z thinkpad: 7.70 6.99 6.00 | macmini (bots): { 51.14 56.65 61.64 }
2026-10-17T18:08:36Z thinkpad: 5.83 6.60 5.90 | macmini (bots): { 48.08 55.33 60.98 }
2026-10-17T18:09:07Z thinkpad: 4.89 6.30 5.82 | macmini (bots): { 53.80 56.00 61.00 }
2026-10-17T18:09:37Z thinkpad: 4.93 6.19 5.80 | macmini (bots): { 60.49 57.24 61.27 }
2026-10-17T18:10:07Z thinkpad: 4.61 5.98 5.74 | macmini (bots): { 58.95 57.21 61.12 }
2026-10-17T18:10:37Z thinkpad: 4.74 5.91 5.73 | macmini (bots): { 60.84 57.65 61.13 }
2026-10-17T18:11:07Z thinkpad: 3.96 5.61 5.63 | macmini (bots): { 64.23 58.82 61.43 }
2026-10-17T18:11:38Z thinkpad: 4.16 5.53 5.61 | macmini (bots): { 75.59 62.26 62.60 }
2026-10-17T18:12:08Z thinkpad: 2.97 5.11 5.46 | macmini (bots): { 75.25 63.65 63.10 }
@@ -0,0 +1,695 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T17:31:40+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "7bbbf6e97fb53683eae0a01ce2c134e53cb649a7",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25,50,100",
"behaviour": "walk,explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 180,
"seed": 12345,
"explore_speed": 0,
"gamerule": "",
"heap": "4g",
"out": "/home/ludovic/code/horizon/.claude/worktrees/server-host-4/.local/bench-out/walk-explore.json",
"nice": 19,
"bots_via": "ssh macmini",
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/server-host-4/target/release/horizon-bots",
"connect": "<server address>",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "walk",
"start_seconds": 9.62,
"mspt": {
"average": 4.01,
"p50": 3.54,
"p95": 8.19,
"p99": 25.59
},
"mspt_worst_p99": 47.5,
"tps": 20.0,
"rss_mb": {
"mean": 782.52,
"max": 785.3
},
"cpu_percent": 58.87,
"chunks_per_second": 9.6,
"unique_per_second": 0.43,
"forgotten_per_second": 9.65,
"waited_ticks_per_second": 0.0,
"chunks_total": 2697,
"chunks_per_bot": {
"walk": 2697.0
},
"unique_chunks_per_bot": {
"walk": 693.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "walk",
"start_seconds": 14.85,
"mspt": {
"average": 18.22,
"p50": 17.99,
"p95": 29.34,
"p99": 50.06
},
"mspt_worst_p99": 395.5,
"tps": 20.0,
"rss_mb": {
"mean": 3429.06,
"max": 3446.3
},
"cpu_percent": 68.81,
"chunks_per_second": 10.47,
"unique_per_second": 0.47,
"forgotten_per_second": 10.53,
"waited_ticks_per_second": 0.0,
"chunks_total": 2755,
"chunks_per_bot": {
"walk": 2755.0
},
"unique_chunks_per_bot": {
"walk": 697.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 10,
"behaviour": "walk",
"start_seconds": 9.21,
"mspt": {
"average": 8.79,
"p50": 8.05,
"p95": 17.35,
"p99": 30.47
},
"mspt_worst_p99": 48.8,
"tps": 20.0,
"rss_mb": {
"mean": 781.88,
"max": 782.8
},
"cpu_percent": 119.16,
"chunks_per_second": 94.63,
"unique_per_second": 3.58,
"forgotten_per_second": 94.31,
"waited_ticks_per_second": 0.0,
"chunks_total": 26003,
"chunks_per_bot": {
"walk": 2600.3
},
"unique_chunks_per_bot": {
"walk": 679.9
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "walk",
"start_seconds": 15.14,
"mspt": {
"average": 18.67,
"p50": 15.8,
"p95": 38.26,
"p99": 55.93
},
"mspt_worst_p99": 101.8,
"tps": 20.0,
"rss_mb": {
"mean": 3497.35,
"max": 3499.4
},
"cpu_percent": 100.8,
"chunks_per_second": 101.1,
"unique_per_second": 4.38,
"forgotten_per_second": 101.15,
"waited_ticks_per_second": 0.0,
"chunks_total": 26537,
"chunks_per_bot": {
"walk": 2653.7
},
"unique_chunks_per_bot": {
"walk": 687.5
},
"resent": 0,
"pulled_back": 5,
"moved_too_quickly": 5,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 25,
"behaviour": "walk",
"start_seconds": 9.82,
"mspt": {
"average": 15.01,
"p50": 13.12,
"p95": 31.12,
"p99": 50.62
},
"mspt_worst_p99": 101.6,
"tps": 20.0,
"rss_mb": {
"mean": 799.52,
"max": 799.7
},
"cpu_percent": 161.02,
"chunks_per_second": 234.3,
"unique_per_second": 8.49,
"forgotten_per_second": 234.35,
"waited_ticks_per_second": 0.0,
"chunks_total": 65926,
"chunks_per_bot": {
"walk": 2637.04
},
"unique_chunks_per_bot": {
"walk": 683.96
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "walk",
"start_seconds": 14.95,
"mspt": {
"average": 18.78,
"p50": 17.47,
"p95": 28.16,
"p99": 51.03
},
"mspt_worst_p99": 107.6,
"tps": 20.0,
"rss_mb": {
"mean": 3520.17,
"max": 3573.8
},
"cpu_percent": 154.9,
"chunks_per_second": 252.39,
"unique_per_second": 11.89,
"forgotten_per_second": 251.75,
"waited_ticks_per_second": 0.0,
"chunks_total": 66469,
"chunks_per_bot": {
"walk": 2658.76
},
"unique_chunks_per_bot": {
"walk": 689.68
},
"resent": 0,
"pulled_back": 10,
"moved_too_quickly": 10,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 50,
"behaviour": "walk",
"start_seconds": 9.52,
"mspt": {
"average": 22.72,
"p50": 16.98,
"p95": 60.19,
"p99": 84.16
},
"mspt_worst_p99": 105.4,
"tps": 20.0,
"rss_mb": {
"mean": 813.5,
"max": 815.8
},
"cpu_percent": 179.85,
"chunks_per_second": 464.75,
"unique_per_second": 16.94,
"forgotten_per_second": 464.69,
"waited_ticks_per_second": 0.0,
"chunks_total": 134839,
"chunks_per_bot": {
"walk": 2696.78
},
"unique_chunks_per_bot": {
"walk": 685.48
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "walk",
"start_seconds": 16.36,
"mspt": {
"average": 34.3,
"p50": 31.07,
"p95": 55.55,
"p99": 92.32
},
"mspt_worst_p99": 452.1,
"tps": 20.0,
"rss_mb": {
"mean": 3577.02,
"max": 3611.2
},
"cpu_percent": 267.05,
"chunks_per_second": 496.77,
"unique_per_second": 26.74,
"forgotten_per_second": 496.2,
"waited_ticks_per_second": 0.0,
"chunks_total": 133505,
"chunks_per_bot": {
"walk": 2670.1
},
"unique_chunks_per_bot": {
"walk": 688.8
},
"resent": 0,
"pulled_back": 39,
"moved_too_quickly": 39,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 100,
"behaviour": "walk",
"start_seconds": 9.51,
"mspt": {
"average": 63.33,
"p50": 58.89,
"p95": 125.04,
"p99": 175.12
},
"mspt_worst_p99": 261.5,
"tps": 15.79,
"rss_mb": {
"mean": 868.12,
"max": 869.9
},
"cpu_percent": 245.64,
"chunks_per_second": 678.18,
"unique_per_second": 31.8,
"forgotten_per_second": 676.4,
"waited_ticks_per_second": 0.0,
"chunks_total": 219787,
"chunks_per_bot": {
"walk": 2197.87
},
"unique_chunks_per_bot": {
"walk": 637.91
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 34
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "walk",
"start_seconds": 15.14,
"error": "[Errno 32] Broken pipe"
},
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 9.72,
"mspt": {
"average": 9.73,
"p50": 8.75,
"p95": 20.99,
"p99": 34.17
},
"mspt_worst_p99": 73.9,
"tps": 20.0,
"rss_mb": {
"mean": 836.18,
"max": 891.0
},
"cpu_percent": 329.64,
"chunks_per_second": 41.11,
"unique_per_second": 35.96,
"forgotten_per_second": 41.08,
"waited_ticks_per_second": 0.16,
"chunks_total": 9460,
"chunks_per_bot": {
"explore": 9460.0
},
"unique_chunks_per_bot": {
"explore": 8479.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "explore",
"start_seconds": 15.54,
"mspt": {
"average": 9.71,
"p50": 7.86,
"p95": 20.37,
"p99": 76.15
},
"mspt_worst_p99": 388.6,
"tps": 20.0,
"rss_mb": {
"mean": 3614.97,
"max": 3688.5
},
"cpu_percent": 247.15,
"chunks_per_second": 35.47,
"unique_per_second": 33.61,
"forgotten_per_second": 35.45,
"waited_ticks_per_second": 0.0,
"chunks_total": 7912,
"chunks_per_bot": {
"explore": 7912.0
},
"unique_chunks_per_bot": {
"explore": 7580.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 9.31,
"mspt": {
"average": 21.27,
"p50": 19.5,
"p95": 39.15,
"p99": 54.23
},
"mspt_worst_p99": 105.5,
"tps": 20.0,
"rss_mb": {
"mean": 1177.66,
"max": 1392.9
},
"cpu_percent": 321.97,
"chunks_per_second": 46.47,
"unique_per_second": 46.11,
"forgotten_per_second": 43.12,
"waited_ticks_per_second": 111.25,
"chunks_total": 14103,
"chunks_per_bot": {
"explore": 1410.3
},
"unique_chunks_per_bot": {
"explore": 1402.4
},
"resent": 0,
"pulled_back": 9856,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "explore",
"start_seconds": 15.24,
"mspt": {
"average": 16.94,
"p50": 14.24,
"p95": 30.28,
"p99": 109.7
},
"mspt_worst_p99": 1861.2,
"tps": 20.0,
"rss_mb": {
"mean": 3953.29,
"max": 4232.0
},
"cpu_percent": 254.11,
"chunks_per_second": 24.99,
"unique_per_second": 25.01,
"forgotten_per_second": 25.33,
"waited_ticks_per_second": 77.93,
"chunks_total": 7707,
"chunks_per_bot": {
"explore": 770.7
},
"unique_chunks_per_bot": {
"explore": 769.5
},
"resent": 0,
"pulled_back": 11123,
"moved_too_quickly": 20,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 9.92,
"mspt": {
"average": 25.76,
"p50": 23.58,
"p95": 45.01,
"p99": 61.43
},
"mspt_worst_p99": 108.8,
"tps": 20.0,
"rss_mb": {
"mean": 1172.1,
"max": 1482.3
},
"cpu_percent": 314.47,
"chunks_per_second": 61.38,
"unique_per_second": 60.7,
"forgotten_per_second": 55.11,
"waited_ticks_per_second": 304.54,
"chunks_total": 24085,
"chunks_per_bot": {
"explore": 963.4
},
"unique_chunks_per_bot": {
"explore": 954.76
},
"resent": 0,
"pulled_back": 31264,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "explore",
"start_seconds": 15.76,
"mspt": {
"average": 24.93,
"p50": 22.04,
"p95": 37.09,
"p99": 168.46
},
"mspt_worst_p99": 1724.8,
"tps": 20.0,
"rss_mb": {
"mean": 4040.42,
"max": 4447.6
},
"cpu_percent": 305.47,
"chunks_per_second": 37.91,
"unique_per_second": 37.84,
"forgotten_per_second": 38.39,
"waited_ticks_per_second": 258.66,
"chunks_total": 16865,
"chunks_per_bot": {
"explore": 674.6
},
"unique_chunks_per_bot": {
"explore": 671.28
},
"resent": 0,
"pulled_back": 33467,
"moved_too_quickly": 23,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "horizon",
"bots": 50,
"behaviour": "explore",
"start_seconds": 9.41,
"mspt": {
"average": 39.31,
"p50": 36.98,
"p95": 65.33,
"p99": 88.36
},
"mspt_worst_p99": 157.6,
"tps": 20.0,
"rss_mb": {
"mean": 1250.32,
"max": 1517.1
},
"cpu_percent": 324.77,
"chunks_per_second": 114.99,
"unique_per_second": 112.25,
"forgotten_per_second": 104.41,
"waited_ticks_per_second": 543.77,
"chunks_total": 50402,
"chunks_per_bot": {
"explore": 1008.04
},
"unique_chunks_per_bot": {
"explore": 997.42
},
"resent": 0,
"pulled_back": 64163,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "explore",
"start_seconds": 14.55,
"error": "[Errno 32] Broken pipe"
},
{
"server": "horizon",
"bots": 100,
"behaviour": "explore",
"start_seconds": 9.31,
"mspt": {
"average": 52.7,
"p50": 49.49,
"p95": 85.73,
"p99": 115.34
},
"mspt_worst_p99": 219.0,
"tps": 18.97,
"rss_mb": {
"mean": 1358.32,
"max": 3047.2
},
"cpu_percent": 327.95,
"chunks_per_second": 154.2,
"unique_per_second": 151.83,
"forgotten_per_second": 141.65,
"waited_ticks_per_second": 1132.9,
"chunks_total": 84433,
"chunks_per_bot": {
"explore": 844.33
},
"unique_chunks_per_bot": {
"explore": 839.47
},
"resent": 0,
"pulled_back": 116161,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 35
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "explore",
"start_seconds": 16.47,
"error": "[Errno 32] Broken pipe"
}
]
}
@@ -0,0 +1,137 @@
# Horizon's server against vanilla 26.3: mixed bots, equal work (2026-10-18)
This reruns `../server-mixed-2026-10-18/` with the bots' load made the same on both servers. In that run the
`explore` bots flew as fast as each server sent them chunks. Horizon kept up and vanilla didn't, so Horizon
sent 2.9 to 5.9 times as many chunks a second, and vanilla's walking bots got a fifth of the chunks Horizon's
got. The comparison of tick times wasn't fair.
## Summary
- **The work was equal** at 1 and 10 bots. At 25 bots it was equal in chunks sent a second (158.6 against 157.6);
vanilla was still catching up on chunks its bots hadn't had yet, see "Equal work" below. Every bot joined, none
was dropped, no chunk was sent twice, and no bot ever waited for the chunk it stood in.
- **Tick time (MSPT), same work:**
- 1 bot: 1.8 ms on Horizon against 11.3 on vanilla;
- 10 bots: 4.7 against 34.3 ms;
- 25 bots: 11.7 against 50.5 ms. Vanilla dropped just under 20 TPS (19.8) and its worst 5-second P99 was
1388 ms, against Horizon's 130;
- 50 bots: Horizon ran at 23.8 ms (20 TPS). Vanilla's watchdog stopped its server 30 s after the last bot
joined ("A single server tick took 60.01 seconds").
- **Memory:** Horizon's RSS was 4.1 to 4.9 times smaller: 0.69 against 3.40 GB with one bot, 0.95 against
3.87 GB with 25, and 1.03 GB with 50. Vanilla's heap is fixed at 4 GB, so its RSS is mostly that heap.
- **CPU** (100 % is one of the machine's 4 threads): 30 against 128 % at 10 bots, 58 against 192 % at 25.
- **What changed since the first run:** the first run's vanilla held 50 bots at 29.7 ms because its walking
bots got 640 to 1100 chunks each. With the explore bots paced, vanilla serves the walking bots in full
(5400 to 6100 chunks each, against Horizon's 6100 to 6600), and that is the load it can't carry at 50.
## Results
MSPT is milliseconds per tick from each server's own `/tick query` (50 is the limit for 20 TPS). P95 and P99
are over the 300 measured seconds. "Worst P99" is the highest P99 of a single 5-second query. RSS and CPU
cover the server's process tree. "First-time chunks/s" counts chunks a bot hadn't received before (summed
over the bots). "Waited" is ticks a second that bots stood still waiting for their own chunk. "Pulled back"
counts position packets that moved a bot back.
| Bots | Server | MSPT | P95 | P99 | Worst P99 | TPS | RSS (mean / max) | CPU | Start | Chunks/s | First-time chunks/s | Waited | Pulled back |
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | **1.77** | 4.21 | 13.04 | 47.5 | 20 | **0.69 / 0.69 GB** | 28 % | 19.7 s | 9.94 | 0.01 | 0 | 0 |
| 1 | vanilla | 11.34 | 17.12 | 33.22 | 119.1 | 20 | 3.40 / 3.42 GB | 36 % | 20.4 s | 9.98 | 0.01 | 0 | 0 |
| 10 | Horizon | **4.74** | 10.48 | 19.69 | 120.1 | 20 | **0.86 / 0.88 GB** | 30 % | 19.5 s | 61.35 | 1.79 | 0 | 4159 |
| 10 | vanilla | 34.30 | 53.72 | 89.08 | 375.8 | 20 | 3.71 / 3.75 GB | 128 % | 19.9 s | 61.66 | 1.93 | 0 | 3976 |
| 25 | Horizon | **11.72** | 24.45 | 43.79 | 129.8 | 20 | **0.95 / 1.09 GB** | 58 % | 9.6 s | 158.57 | 5.78 | 0 | 3496 |
| 25 | vanilla | 50.45 | 74.09 | 191.21 | 1388.1 | 19.82 | 3.87 / 4.75 GB | 192 % | 37.8 s | 157.63 | 8.25 | 0 | 3629 |
| 50 | Horizon | **23.82** | 50.98 | 74.64 | 195.9 | 20 | **1.03 / 1.20 GB** | 98 % | 14.9 s | 302.81 | 10.96 | 0 | 7565 |
| 50 | vanilla | stopped by its watchdog | | | | | | | 11.2 s | | | | |
Chunks each bot received over the whole run, by behaviour (mean):
| Bots | Server | walk | explore | idle |
|---:|---|---:|---:|---:|
| 1 | Horizon | 6092 | | |
| 1 | vanilla | 6084 | | |
| 10 | Horizon | 6248 | 1165 | 473 |
| 10 | vanilla | 5729 | 1124 | 473 |
| 25 | Horizon | 6629 | 1267 | 473 |
| 25 | vanilla | 5387 | 1119 | 473 |
| 50 | Horizon | 6497 | 1256 | 473 |
Both servers pulled bots back about as often (`moved too quickly` 20 and 296 times on vanilla at 10 and 25
bots).
## Equal work
`tools/server_bench.py` now prints a work table after the runs. At each bot count it compares every server's
chunks sent a second and first-time chunks a second with the busiest server's. Work counts as equal when
both are within 10 % (or within a chunk a second when there are few). The report keeps the table as `work`.
| Bots | Chunks/s (Horizon, vanilla) | First-time chunks/s (Horizon, vanilla) | Equal |
|---:|---|---|---|
| 1 | 9.94, 9.98 | 0.01, 0.01 | yes |
| 10 | 61.35, 61.66 | 1.79, 1.93 | yes |
| 25 | 158.57, 157.63 | 5.78, 8.25 | no: vanilla sent 2.5 more first-time chunks a second |
Two settings made the load the same:
- **Explore bots paced at 0.8 blocks a second** (`--explore-speed 0.8`), the rate the 2026-10-17 run showed
both servers keep up with. Every bot then follows the same path on both servers. The path is deterministic
per bot, and a bot only stops while the chunk it stands in hasn't arrived, which never happened here
(`Waited` 0). So both servers had the same chunks to send.
- **A 300-second warm-up** before the 300 measured seconds. A first pass with the old 60-second warm-up
(aborted after 10 bots) sent the same chunks a second on both (63.1 against 61.4). But vanilla was still
delivering the bots' first views during the measurement (12.9 first-time chunks a second against Horizon's
4.1), so its window held more work than Horizon's. With 300 seconds both were in steady state at 1 and 10
bots. At 25, vanilla still sent 2.5 more first-time chunks a second than Horizon: 1.6 % of all it sent, and
its walking bots ended the run with 19 % fewer chunks. Read the 25-bot tick times with that in mind: the
extra first-time chunks are work vanilla had still to do because it fell behind.
## Method
`tools/server_bench.py` ran each configuration on a fresh server:
- the same `server.properties` on both: seed 12345, view and simulation distance 10, offline, creative,
difficulty easy, `sync-chunk-writes=true`, white-list off;
- `gamerule respawn_radius 0`, and otherwise vanilla's default game rules (the locator bar on for both);
- the server waits for `Done`, the bots join one a second, warm up for 300 seconds and are measured for 300,
with `/tick query` every 5 seconds and RSS and CPU every second.
The bots are `horizon-bots --behaviour mixed`: 60 % walk, 20 % explore and 20 % idle, each choice deterministic
per bot. All are creative and fly 24 blocks above spawn. `walk` flies to random points within 48 blocks at
5.6 blocks a second, `explore` flies straight out at 0.8 blocks a second, and `idle` hovers.
**Machine.** The ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS. The desktop (24 threads)
was meant to run it, but it didn't wake (`~/fleet/power wake desktop` timed out, as it did for the first run).
No other session ran on the ThinkPad during the runs (`load.txt`, a sample a minute). The servers ran under
`nice -n 19`. The bots ran on the Mac mini (M4 Pro, shared with other work at 70 to 90 % CPU) and reached the
ThinkPad over the tailnet.
**What ran.**
- Horizon: the server build from #916 (`horizon-client --no-default-features`, no sound or controllers) at
519ec121, packaged with `xtask package-server` and installed with `install.sh --archive`, as Linux owners
will get it. `horizon-server` ran it as `horizon-engine`. The game files were prepared from the owner's own
Minecraft. `horizon-bots` and `server_bench.py` came from this PR.
- Vanilla: the pinned 26.3 server jar (the owner's own copy) on OpenJDK 25.0.4.1, `-Xms4g -Xmx4g`.
`server_bench.py` stopped after vanilla's 50-bot server crashed and before it wrote its report.
`mixed-equal.json` holds the run lines it printed. The vanilla 50 entry and its error come from that server's
log, and the `work` table was computed with `server_bench.work`. `load.txt` is the fleet monitor's view of the
machines during the runs.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
HORIZON_BENCH_BIN=~/.local/share/horizon/current \
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25,50 --behaviour mixed \
--explore-speed 0.8 --warmup 300 --nice 19 \
--bots-via 'ssh <bots machine>' --bots-bin /path/to/horizon-bots --connect <this machine's address> \
--out mixed-equal.json
## Next
- One pass per configuration ran. Vanilla at 50 bots, in particular, needs a second pass, ideally on the
desktop (24 threads), where neither server is CPU-bound.
- 100 bots, and Paper and Fabric + Lithium (docs/server.md section 9).
- A longer warm-up, or measuring from when the first-time chunk rate settles, so the 25-bot work is equal too.
@@ -0,0 +1,702 @@
2026-10-18T19:32:39Z
ludothinkpad ok: 27.2 GB free, 4 threads 17% busy, 0 sessions
desktop offline
macmini ok: 25.9 GB free, 14 threads 73% busy, 1 sessions
macbook offline
RAM 27.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 42% busy (agents 0.0, rest 1.6 cores) · 80°C · load 5.9
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:33:44Z
ludothinkpad ok: 26.7 GB free, 4 threads 19% busy, 0 sessions
desktop offline
macmini busy: 26.7 GB free, 14 threads 93% busy, 1 sessions
macbook offline
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 34% busy (agents 0.0, rest 1.7 cores) · 81°C · load 4.0
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:34:48Z
ludothinkpad ok: 26.4 GB free, 4 threads 56% busy, 0 sessions
desktop offline
macmini busy: 26.9 GB free, 14 threads 90% busy, 1 sessions
macbook offline
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 54% busy (agents 0.0, rest 2.2 cores) · 79°C · load 2.8
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:35:52Z
ludothinkpad ok: 26.7 GB free, 4 threads 27% busy, 0 sessions
desktop offline
macmini ok: 25.4 GB free, 14 threads 85% busy, 1 sessions
macbook offline
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 30% busy (agents 0.0, rest 1.1 cores) · 82°C · load 2.2
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:36:57Z
ludothinkpad ok: 26.7 GB free, 4 threads 25% busy, 0 sessions
desktop offline
macmini busy: 25.4 GB free, 14 threads 93% busy, 1 sessions
macbook offline
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 37% busy (agents 0.0, rest 1.2 cores) · 80°C · load 1.5
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:38:01Z
ludothinkpad ok: 26.6 GB free, 4 threads 54% busy, 0 sessions
desktop offline
macmini ok: 26.7 GB free, 14 threads 64% busy, 1 sessions
macbook offline
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 21% busy (agents 0.0, rest 1.2 cores) · 81°C · load 1.9
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:39:05Z
ludothinkpad ok: 26.7 GB free, 4 threads 20% busy, 0 sessions
desktop offline
macmini ok: 26.1 GB free, 14 threads 70% busy, 1 sessions
macbook offline
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 28% busy (agents 0.0, rest 1.0 cores) · 81°C · load 1.3
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:40:10Z
ludothinkpad ok: 26.7 GB free, 4 threads 39% busy, 0 sessions
desktop offline
macmini ok: 29.2 GB free, 14 threads 73% busy, 1 sessions
macbook offline
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 32% busy (agents 0.0, rest 1.7 cores) · 80°C · load 0.9
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:41:14Z
ludothinkpad ok: 26.7 GB free, 4 threads 31% busy, 0 sessions
desktop offline
macmini ok: 28.8 GB free, 14 threads 77% busy, 1 sessions
macbook offline
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 15% busy (agents 0.0, rest 1.1 cores) · 82°C · load 1.3
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:42:19Z
ludothinkpad ok: 26.5 GB free, 4 threads 26% busy, 0 sessions
desktop offline
macmini busy: 26.1 GB free, 14 threads 94% busy, 1 sessions
macbook offline
RAM 26.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 30% busy (agents 0.0, rest 1.3 cores) · 78°C · load 1.1
· disk 289.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:43:23Z
ludothinkpad busy: 26.7 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 28.2 GB free, 14 threads 83% busy, 1 sessions
macbook offline
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 99% busy (agents 0.0, rest 3.8 cores) · 86°C · load 2.4
· disk 289.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:44:27Z
ludothinkpad busy: 25.2 GB free, 4 threads 98% busy, 0 sessions
desktop offline
macmini ok: 29.0 GB free, 14 threads 73% busy, 1 sessions
macbook offline
RAM 24.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 83% busy (agents 0.0, rest 3.7 cores) · 83°C · load 5.5
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:45:31Z
ludothinkpad ok: 24.5 GB free, 4 threads 39% busy, 0 sessions
desktop offline
macmini busy: 27.0 GB free, 14 threads 93% busy, 1 sessions
macbook offline
RAM 24.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 34% busy (agents 0.0, rest 1.5 cores) · 84°C · load 3.5
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:46:35Z
ludothinkpad ok: 23.7 GB free, 4 threads 69% busy, 0 sessions
desktop offline
macmini busy: 26.2 GB free, 14 threads 90% busy, 1 sessions
macbook offline
RAM 23.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 75% busy (agents 0.0, rest 2.8 cores) · 87°C · load 2.7
· disk 289.5 GB free · battery 96% · Ludovic: away
2026-10-18T19:47:40Z
ludothinkpad ok: 23.8 GB free, 4 threads 66% busy, 0 sessions
desktop offline
macmini busy: 25.9 GB free, 14 threads 85% busy, 1 sessions
macbook offline
RAM 23.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 59% busy (agents 0.0, rest 2.5 cores) · 83°C · load 2.9
· disk 291.7 GB free · battery 96% · Ludovic: away
2026-10-18T19:48:44Z
ludothinkpad ok: 24.2 GB free, 4 threads 61% busy, 0 sessions
desktop offline
macmini busy: 29.0 GB free, 14 threads 90% busy, 1 sessions
macbook offline
RAM 24.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 62% busy (agents 0.0, rest 2.5 cores) · 84°C · load 3.2
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:49:48Z
ludothinkpad ok: 25.1 GB free, 4 threads 59% busy, 0 sessions
desktop offline
macmini ok: 31.1 GB free, 14 threads 81% busy, 1 sessions
macbook offline
RAM 25.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 57% busy (agents 0.0, rest 2.4 cores) · 85°C · load 2.9
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:50:52Z
ludothinkpad ok: 25.7 GB free, 4 threads 39% busy, 0 sessions
desktop offline
macmini busy: 28.4 GB free, 14 threads 88% busy, 2 sessions
macbook offline
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 18% busy (agents 0.0, rest 1.4 cores) · 67°C · load 2.4
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:51:57Z
ludothinkpad ok: 23.7 GB free, 4 threads 40% busy, 0 sessions
desktop offline
macmini ok: 27.9 GB free, 14 threads 83% busy, 1 sessions
macbook offline
RAM 23.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 23% busy (agents 0.0, rest 1.4 cores) · 81°C · load 1.8
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:53:01Z
ludothinkpad ok: 25.3 GB free, 4 threads 25% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 84% busy, 1 sessions
macbook offline
RAM 25.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 31% busy (agents 0.0, rest 1.2 cores) · 80°C · load 1.8
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:54:05Z
ludothinkpad ok: 26.5 GB free, 4 threads 75% busy, 0 sessions
desktop offline
macmini busy: 27.1 GB free, 14 threads 91% busy, 1 sessions
macbook offline
RAM 26.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 69% busy (agents 0.0, rest 2.5 cores) · 78°C · load 2.3
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:55:09Z
ludothinkpad busy: 26.4 GB free, 4 threads 89% busy, 0 sessions
desktop offline
macmini busy: 26.3 GB free, 14 threads 86% busy, 1 sessions
macbook offline
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 32% busy (agents 0.0, rest 2.4 cores) · 79°C · load 2.9
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:56:14Z
ludothinkpad ok: 26.4 GB free, 4 threads 39% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 68% busy, 1 sessions
macbook offline
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 63% busy (agents 0.0, rest 1.8 cores) · 82°C · load 2.3
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:57:18Z
ludothinkpad ok: 26.3 GB free, 4 threads 20% busy, 0 sessions
desktop offline
macmini busy: 29.2 GB free, 14 threads 85% busy, 1 sessions
macbook offline
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 19% busy (agents 0.0, rest 1.0 cores) · 80°C · load 1.8
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:58:22Z
ludothinkpad ok: 26.3 GB free, 4 threads 28% busy, 0 sessions
desktop offline
macmini busy: 27.3 GB free, 14 threads 88% busy, 1 sessions
macbook offline
RAM 26.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 93% busy (agents 0.0, rest 2.5 cores) · 80°C · load 2.0
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T19:59:27Z
ludothinkpad ok: 26.2 GB free, 4 threads 52% busy, 0 sessions
desktop offline
macmini ok: 35.0 GB free, 14 threads 85% busy, 1 sessions
macbook offline
RAM 26.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 22% busy (agents 0.0, rest 1.8 cores) · 80°C · load 3.6
· disk 291.4 GB free · battery 96% · Ludovic: away
2026-10-18T20:00:31Z
ludothinkpad busy: 25.8 GB free, 4 threads 87% busy, 0 sessions
desktop offline
macmini busy: 33.0 GB free, 14 threads 87% busy, 1 sessions
macbook offline
RAM 27.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 51% busy (agents 0.0, rest 2.9 cores) · 79°C · load 3.6
· disk 291.5 GB free · battery 96% · Ludovic: away
2026-10-18T20:01:35Z
ludothinkpad ok: 25.6 GB free, 4 threads 35% busy, 0 sessions
desktop offline
macmini ok: 32.4 GB free, 14 threads 81% busy, 1 sessions
macbook ok: 7.2 GB free, 12 threads 32% busy, 0 sessions
RAM 25.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 34% busy (agents 0.0, rest 1.3 cores) · 81°C · load 2.5
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T20:02:40Z
ludothinkpad ok: 26.6 GB free, 4 threads 35% busy, 0 sessions
desktop offline
macmini ok: 29.2 GB free, 14 threads 79% busy, 1 sessions
macbook ok: 6.5 GB free, 12 threads 39% busy, 0 sessions
RAM 26.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 33% busy (agents 0.0, rest 1.3 cores) · 81°C · load 2.7
· disk 291.6 GB free · battery 96% · Ludovic: away
2026-10-18T20:03:44Z
ludothinkpad ok: 25.5 GB free, 4 threads 56% busy, 0 sessions
desktop offline
macmini busy: 31.1 GB free, 14 threads 89% busy, 1 sessions
macbook ok: 6.8 GB free, 12 threads 6% busy, 0 sessions
RAM 25.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 66% busy (agents 0.0, rest 2.5 cores) · 87°C · load 2.5
· disk 291.3 GB free · battery 96% · Ludovic: away
2026-10-18T20:04:48Z
ludothinkpad busy: 26.2 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini busy: 30.0 GB free, 14 threads 89% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 31% busy, 0 sessions
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 84% busy (agents 0.0, rest 3.7 cores) · 85°C · load 3.4
· disk 291.3 GB free · battery 96% · Ludovic: away
2026-10-18T20:05:52Z
ludothinkpad busy: 24.5 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 34.1 GB free, 14 threads 89% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 10% busy, 0 sessions
RAM 24.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 100% busy (agents 0.0, rest 4.0 cores) · 83°C · load 9.0
· disk 291.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:06:57Z
ludothinkpad busy: 23.5 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 28.3 GB free, 14 threads 88% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 5% busy, 0 sessions
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 82°C · load 10.1
· disk 309.2 GB free · battery 96% · Ludovic: away
2026-10-18T20:08:01Z
ludothinkpad busy: 23.4 GB free, 4 threads 88% busy, 0 sessions
desktop offline
macmini ok: 29.6 GB free, 14 threads 83% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 2% busy, 0 sessions
RAM 23.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 77% busy (agents 0.0, rest 3.4 cores) · 84°C · load 7.5
· disk 309.2 GB free · battery 96% · Ludovic: away
2026-10-18T20:09:05Z
ludothinkpad busy: 24.0 GB free, 4 threads 89% busy, 0 sessions
desktop offline
macmini ok: 25.9 GB free, 14 threads 83% busy, 1 sessions
macbook ok: 7.1 GB free, 12 threads 19% busy, 0 sessions
RAM 23.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 91% busy (agents 0.0, rest 3.6 cores) · 82°C · load 5.9
· disk 309.1 GB free · battery 96% · Ludovic: away
2026-10-18T20:10:09Z
ludothinkpad busy: 24.0 GB free, 4 threads 98% busy, 0 sessions
desktop offline
macmini busy: 27.1 GB free, 14 threads 88% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 19% busy, 0 sessions
RAM 24.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 92% busy (agents 0.0, rest 3.7 cores) · 84°C · load 6.6
· disk 309.1 GB free · battery 96% · Ludovic: away
2026-10-18T20:11:14Z
ludothinkpad busy: 24.3 GB free, 4 threads 92% busy, 0 sessions
desktop offline
macmini ok: 26.3 GB free, 14 threads 84% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 18% busy, 0 sessions
RAM 24.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 99% busy (agents 0.0, rest 3.7 cores) · 85°C · load 6.5
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:12:18Z
ludothinkpad busy: 24.7 GB free, 4 threads 89% busy, 0 sessions
desktop offline
macmini ok: 28.6 GB free, 14 threads 78% busy, 1 sessions
macbook ok: 7.3 GB free, 12 threads 20% busy, 0 sessions
RAM 24.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 82% busy (agents 0.0, rest 3.4 cores) · 84°C · load 6.1
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:13:22Z
ludothinkpad ok: 23.4 GB free, 4 threads 53% busy, 0 sessions
desktop offline
macmini ok: 28.1 GB free, 14 threads 77% busy, 1 sessions
macbook ok: 7.3 GB free, 12 threads 20% busy, 0 sessions
RAM 23.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 48% busy (agents 0.0, rest 2.4 cores) · 80°C · load 4.2
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:14:27Z
ludothinkpad ok: 24.0 GB free, 4 threads 44% busy, 0 sessions
desktop offline
macmini ok: 28.0 GB free, 14 threads 71% busy, 1 sessions
macbook ok: 6.9 GB free, 12 threads 2% busy, 0 sessions
RAM 24.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 23% busy (agents 0.0, rest 2.0 cores) · 77°C · load 4.3
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:15:31Z
ludothinkpad ok: 22.7 GB free, 4 threads 31% busy, 0 sessions
desktop offline
macmini busy: 27.2 GB free, 14 threads 86% busy, 1 sessions
macbook ok: 6.9 GB free, 12 threads 19% busy, 0 sessions
RAM 22.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 30% busy (agents 0.0, rest 1.3 cores) · 77°C · load 2.6
· disk 309.1 GB free · battery 96% · Ludovic: away
2026-10-18T20:16:35Z
ludothinkpad ok: 26.8 GB free, 4 threads 37% busy, 0 sessions
desktop offline
macmini ok: 28.6 GB free, 14 threads 82% busy, 1 sessions
macbook ok: 6.8 GB free, 12 threads 9% busy, 0 sessions
RAM 26.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 42% busy (agents 0.0, rest 1.7 cores) · 81°C · load 2.3
· disk 309.1 GB free · battery 96% · Ludovic: away
2026-10-18T20:17:39Z
ludothinkpad ok: 26.3 GB free, 4 threads 63% busy, 0 sessions
desktop offline
macmini ok: 26.8 GB free, 14 threads 78% busy, 1 sessions
macbook ok: 7.0 GB free, 12 threads 19% busy, 0 sessions
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 64% busy (agents 0.0, rest 2.6 cores) · 87°C · load 1.9
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:18:43Z
ludothinkpad ok: 26.9 GB free, 4 threads 37% busy, 0 sessions
desktop offline
macmini busy: 32.2 GB free, 14 threads 87% busy, 1 sessions
macbook ok: 7.0 GB free, 12 threads 10% busy, 0 sessions
RAM 26.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 56% busy (agents 0.0, rest 1.9 cores) · 84°C · load 3.9
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:19:48Z
ludothinkpad ok: 26.0 GB free, 4 threads 77% busy, 0 sessions
desktop offline
macmini ok: 29.2 GB free, 14 threads 83% busy, 1 sessions
macbook ok: 7.0 GB free, 12 threads 10% busy, 0 sessions
RAM 26.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 66% busy (agents 0.0, rest 2.9 cores) · 86°C · load 3.2
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:20:52Z
ludothinkpad ok: 25.9 GB free, 4 threads 80% busy, 0 sessions
desktop offline
macmini ok: 26.8 GB free, 14 threads 84% busy, 1 sessions
macbook ok: 7.0 GB free, 12 threads 17% busy, 0 sessions
RAM 25.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 68% busy (agents 0.0, rest 3.2 cores) · 86°C · load 4.4
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:21:56Z
ludothinkpad ok: 26.3 GB free, 4 threads 63% busy, 0 sessions
desktop offline
macmini ok: 26.3 GB free, 14 threads 77% busy, 1 sessions
macbook ok: 6.8 GB free, 12 threads 14% busy, 0 sessions
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 66% busy (agents 0.0, rest 2.8 cores) · 84°C · load 3.1
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:23:00Z
ludothinkpad ok: 26.8 GB free, 4 threads 74% busy, 0 sessions
desktop offline
macmini ok: 26.1 GB free, 14 threads 82% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 11% busy, 0 sessions
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 65% busy (agents 0.0, rest 2.8 cores) · 84°C · load 5.0
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:24:05Z
ludothinkpad ok: 26.9 GB free, 4 threads 64% busy, 0 sessions
desktop offline
macmini busy: 28.3 GB free, 14 threads 90% busy, 1 sessions
macbook ok: 6.8 GB free, 12 threads 8% busy, 0 sessions
RAM 26.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 64% busy (agents 0.0, rest 2.7 cores) · 86°C · load 4.9
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:25:09Z
ludothinkpad ok: 26.0 GB free, 4 threads 56% busy, 0 sessions
desktop offline
macmini busy: 26.2 GB free, 14 threads 88% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 12% busy, 0 sessions
RAM 26.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 49% busy (agents 0.0, rest 2.1 cores) · 72°C · load 5.5
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:26:13Z
ludothinkpad ok: 25.8 GB free, 4 threads 47% busy, 0 sessions
desktop offline
macmini ok: 27.8 GB free, 14 threads 78% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 56% busy (agents 0.0, rest 2.1 cores) · 78°C · load 3.1
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:27:18Z
ludothinkpad busy: 25.5 GB free, 4 threads 94% busy, 0 sessions
desktop offline
macmini ok: 26.6 GB free, 14 threads 82% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
RAM 25.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 100% busy (agents 0.0, rest 3.9 cores) · 78°C · load 4.7
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:28:22Z
ludothinkpad busy: 24.6 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 26.4 GB free, 14 threads 80% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
RAM 24.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 77°C · load 10.5
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:29:27Z
ludothinkpad busy: 24.3 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 29.8 GB free, 14 threads 80% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
RAM 24.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 82°C · load 10.0
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:30:31Z
ludothinkpad busy: 23.7 GB free, 4 threads 91% busy, 0 sessions
desktop offline
macmini ok: 30.1 GB free, 14 threads 71% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
RAM 23.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 94% busy (agents 0.0, rest 3.8 cores) · 80°C · load 10.3
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:31:36Z
ludothinkpad busy: 23.6 GB free, 4 threads 93% busy, 0 sessions
desktop offline
macmini ok: 28.0 GB free, 14 threads 81% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 14% busy, 0 sessions
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 95% busy (agents 0.0, rest 3.7 cores) · 80°C · load 10.7
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:32:40Z
ludothinkpad busy: 23.6 GB free, 4 threads 94% busy, 0 sessions
desktop offline
macmini ok: 26.9 GB free, 14 threads 76% busy, 1 sessions
macbook ok: 6.8 GB free, 12 threads 22% busy, 0 sessions
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 84% busy (agents 0.0, rest 3.5 cores) · 80°C · load 9.7
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:33:45Z
ludothinkpad ok: 25.2 GB free, 4 threads 51% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 82% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
RAM 25.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 48% busy (agents 0.0, rest 2.1 cores) · 87°C · load 6.5
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:34:49Z
ludothinkpad ok: 25.1 GB free, 4 threads 56% busy, 0 sessions
desktop offline
macmini busy: 28.2 GB free, 14 threads 87% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
RAM 24.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 78% busy (agents 0.0, rest 2.7 cores) · 88°C · load 4.0
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:35:53Z
ludothinkpad busy: 23.6 GB free, 4 threads 86% busy, 0 sessions
desktop offline
macmini busy: 27.9 GB free, 14 threads 86% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 17% busy, 0 sessions
RAM 23.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 88% busy (agents 0.0, rest 3.3 cores) · 85°C · load 4.9
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:36:58Z
ludothinkpad ok: 23.6 GB free, 4 threads 75% busy, 0 sessions
desktop offline
macmini ok: 29.4 GB free, 14 threads 84% busy, 1 sessions
macbook ok: 6.5 GB free, 12 threads 18% busy, 0 sessions
RAM 23.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 85% busy (agents 0.0, rest 3.2 cores) · 85°C · load 5.1
· disk 309.0 GB free · battery 96% · Ludovic: away
2026-10-18T20:38:02Z
ludothinkpad ok: 22.6 GB free, 4 threads 55% busy, 0 sessions
desktop offline
macmini ok: 26.5 GB free, 14 threads 80% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
RAM 22.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 54% busy (agents 0.0, rest 2.2 cores) · 85°C · load 4.5
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:39:06Z
ludothinkpad ok: 27.2 GB free, 4 threads 55% busy, 0 sessions
desktop offline
macmini busy: 29.5 GB free, 14 threads 85% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
RAM 27.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 48% busy (agents 0.0, rest 2.2 cores) · 87°C · load 4.0
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:40:10Z
ludothinkpad ok: 26.2 GB free, 4 threads 72% busy, 0 sessions
desktop offline
macmini ok: 27.7 GB free, 14 threads 84% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 19% busy, 0 sessions
RAM 26.3 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 80% busy (agents 0.0, rest 3.1 cores) · 86°C · load 10.8
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:41:15Z
ludothinkpad busy: 26.4 GB free, 4 threads 86% busy, 0 sessions
desktop offline
macmini ok: 28.5 GB free, 14 threads 79% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 10% busy, 0 sessions
RAM 26.4 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 90% busy (agents 0.0, rest 3.4 cores) · 86°C · load 12.8
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:42:19Z
ludothinkpad ok: 26.2 GB free, 4 threads 82% busy, 0 sessions
desktop offline
macmini ok: 26.9 GB free, 14 threads 78% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 11% busy, 0 sessions
RAM 26.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 74% busy (agents 0.0, rest 3.3 cores) · 85°C · load 8.8
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:43:23Z
ludothinkpad busy: 26.7 GB free, 4 threads 93% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 81% busy, 1 sessions
macbook ok: 6.6 GB free, 12 threads 10% busy, 0 sessions
RAM 26.7 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 88% busy (agents 0.0, rest 3.4 cores) · 85°C · load 5.4
· disk 308.9 GB free · battery 96% · Ludovic: away
2026-10-18T20:44:27Z
ludothinkpad ok: 27.0 GB free, 4 threads 82% busy, 0 sessions
desktop offline
macmini busy: 26.9 GB free, 14 threads 87% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 13% busy, 0 sessions
RAM 27.0 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 75% busy (agents 0.0, rest 3.3 cores) · 82°C · load 9.8
· disk 308.8 GB free · battery 96% · Ludovic: away
2026-10-18T20:45:32Z
ludothinkpad ok: 27.2 GB free, 4 threads 76% busy, 0 sessions
desktop offline
macmini ok: 26.8 GB free, 14 threads 80% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
RAM 27.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 84% busy (agents 0.0, rest 3.2 cores) · 85°C · load 6.9
· disk 308.8 GB free · battery 96% · Ludovic: away
2026-10-18T20:46:36Z
ludothinkpad busy: 27.1 GB free, 4 threads 87% busy, 0 sessions
desktop offline
macmini busy: 26.8 GB free, 14 threads 92% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
RAM 26.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 85% busy (agents 0.0, rest 3.2 cores) · 82°C · load 4.4
· disk 308.8 GB free · battery 96% · Ludovic: away
2026-10-18T20:47:40Z
ludothinkpad busy: 25.3 GB free, 4 threads 86% busy, 0 sessions
desktop offline
macmini ok: 26.5 GB free, 14 threads 72% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
RAM 25.5 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 85% busy (agents 0.0, rest 3.4 cores) · 86°C · load 3.7
· disk 308.8 GB free · battery 96% · Ludovic: away
2026-10-18T20:48:45Z
ludothinkpad busy: 26.1 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 28.5 GB free, 14 threads 84% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 15% busy, 0 sessions
RAM 26.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 98% busy (agents 0.0, rest 3.9 cores) · 80°C · load 6.5
· disk 308.8 GB free · battery 96% · Ludovic: away
2026-10-18T20:49:50Z
ludothinkpad ok: 26.8 GB free, 4 threads 71% busy, 0 sessions
desktop offline
macmini busy: 27.7 GB free, 14 threads 93% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
RAM 26.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 92% busy (agents 0.0, rest 3.4 cores) · 81°C · load 5.2
· disk 308.7 GB free · battery 96% · Ludovic: away
2026-10-18T20:50:54Z
ludothinkpad busy: 25.8 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 31.3 GB free, 14 threads 95% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 11% busy, 0 sessions
RAM 25.8 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 99% busy (agents 0.0, rest 4.0 cores) · 82°C · load 8.1
· disk 308.7 GB free · battery 96% · Ludovic: away
2026-10-18T20:51:59Z
ludothinkpad ok: 27.2 GB free, 4 threads 12% busy, 0 sessions
desktop offline
macmini ok: 31.2 GB free, 14 threads 76% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 8% busy, 0 sessions
RAM 27.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 15% busy (agents 0.0, rest 1.3 cores) · 81°C · load 7.9
· disk 308.7 GB free · battery 96% · Ludovic: away
2026-10-18T20:53:03Z
ludothinkpad ok: 27.3 GB free, 4 threads 42% busy, 0 sessions
desktop offline
macmini busy: 27.8 GB free, 14 threads 87% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
RAM 27.2 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 14% busy (agents 0.0, rest 0.8 cores) · 82°C · load 3.1
· disk 308.7 GB free · battery 96% · Ludovic: away
2026-10-18T20:54:08Z
ludothinkpad ok: 28.9 GB free, 4 threads 4% busy, 0 sessions
desktop offline
macmini ok: 29.2 GB free, 14 threads 80% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 9% busy, 0 sessions
RAM 28.9 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 2% busy (agents 0.0, rest 0.3 cores) · 82°C · load 1.3
· disk 308.7 GB free · battery 96% · Ludovic: away
2026-10-18T20:55:12Z
ludothinkpad ok: 27.7 GB free, 4 threads 27% busy, 0 sessions
desktop offline
macmini ok: 31.2 GB free, 14 threads 70% busy, 1 sessions
macbook ok: 6.7 GB free, 12 threads 10% busy, 0 sessions
RAM 27.6 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 26% busy (agents 0.0, rest 1.2 cores) · 69°C · load 1.0
· disk 308.8 GB free · battery 96% · Ludovic: away
@@ -0,0 +1,421 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-18T19:32:00+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4,
"name": "ThinkPad, Intel Core i7-7500U, 32 GB, NixOS"
},
"commit": "f1d5bf520986b00a5b968a84d85809fca1a039fa",
"engine": "horizon-engine: the server build of #916 (horizon-client --no-default-features) at 519ec121, as install.sh --archive installed it",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25,50",
"behaviour": "mixed",
"join_every": 1.0,
"warmup": 300,
"seconds": 300,
"seed": 12345,
"explore_speed": 0.8,
"gamerule": "",
"heap": "4g",
"nice": 19,
"bots_via": "ssh macmini",
"connect": "the ThinkPad's tailnet address",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"note": "server_bench.py was stopped after vanilla's 50-bot server crashed, before it wrote this file; the runs are the lines it printed, and the vanilla 50 entry is taken from that server's log.",
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "mixed",
"start_seconds": 19.73,
"mspt": {
"average": 1.77,
"p50": 1.34,
"p95": 4.21,
"p99": 13.04
},
"mspt_worst_p99": 47.5,
"tps": 20.0,
"rss_mb": {
"mean": 686.54,
"max": 686.7
},
"cpu_percent": 27.92,
"chunks_per_second": 9.94,
"unique_per_second": 0.01,
"forgotten_per_second": 9.9,
"waited_ticks_per_second": 0.0,
"chunks_total": 6092,
"chunks_per_bot": {
"walk": 6092.0
},
"unique_chunks_per_bot": {
"walk": 699.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "mixed",
"start_seconds": 20.45,
"mspt": {
"average": 11.34,
"p50": 10.6,
"p95": 17.12,
"p99": 33.22
},
"mspt_worst_p99": 119.1,
"tps": 20.0,
"rss_mb": {
"mean": 3404.16,
"max": 3416.3
},
"cpu_percent": 35.93,
"chunks_per_second": 9.98,
"unique_per_second": 0.01,
"forgotten_per_second": 9.93,
"waited_ticks_per_second": 0.0,
"chunks_total": 6084,
"chunks_per_bot": {
"walk": 6084.0
},
"unique_chunks_per_bot": {
"walk": 699.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "horizon",
"bots": 10,
"behaviour": "mixed",
"start_seconds": 19.54,
"mspt": {
"average": 4.74,
"p50": 3.83,
"p95": 10.48,
"p99": 19.69
},
"mspt_worst_p99": 120.1,
"tps": 20.0,
"rss_mb": {
"mean": 855.26,
"max": 882.7
},
"cpu_percent": 29.78,
"chunks_per_second": 61.35,
"unique_per_second": 1.79,
"forgotten_per_second": 61.47,
"waited_ticks_per_second": 0.0,
"chunks_total": 40762,
"chunks_per_bot": {
"walk": 6247.67,
"explore": 1165.0,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 701.83,
"explore": 1086.0,
"idle": 473.0
},
"resent": 0,
"pulled_back": 4159,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "mixed",
"start_seconds": 19.95,
"mspt": {
"average": 34.3,
"p50": 32.2,
"p95": 53.72,
"p99": 89.08
},
"mspt_worst_p99": 375.8,
"tps": 20.0,
"rss_mb": {
"mean": 3713.5,
"max": 3754.9
},
"cpu_percent": 128.07,
"chunks_per_second": 61.66,
"unique_per_second": 1.93,
"forgotten_per_second": 61.71,
"waited_ticks_per_second": 0.0,
"chunks_total": 37568,
"chunks_per_bot": {
"walk": 5729.0,
"explore": 1124.0,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 701.17,
"explore": 1055.0,
"idle": 473.0
},
"resent": 0,
"pulled_back": 3976,
"moved_too_quickly": 20,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 9.61,
"mspt": {
"average": 11.72,
"p50": 9.88,
"p95": 24.45,
"p99": 43.79
},
"mspt_worst_p99": 129.8,
"tps": 20.0,
"rss_mb": {
"mean": 947.35,
"max": 1086.1
},
"cpu_percent": 57.83,
"chunks_per_second": 158.57,
"unique_per_second": 5.78,
"forgotten_per_second": 158.45,
"waited_ticks_per_second": 0.0,
"chunks_total": 108126,
"chunks_per_bot": {
"walk": 6628.53,
"explore": 1266.6,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 704.67,
"explore": 1189.2,
"idle": 473.0
},
"resent": 0,
"pulled_back": 3496,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 37.78,
"mspt": {
"average": 50.45,
"p50": 46.39,
"p95": 74.09,
"p99": 191.21
},
"mspt_worst_p99": 1388.1,
"tps": 19.82,
"rss_mb": {
"mean": 3866.18,
"max": 4745.0
},
"cpu_percent": 192.42,
"chunks_per_second": 157.63,
"unique_per_second": 8.25,
"forgotten_per_second": 156.12,
"waited_ticks_per_second": 0.0,
"chunks_total": 88763,
"chunks_per_bot": {
"walk": 5387.0,
"explore": 1118.6,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 699.0,
"explore": 1075.0,
"idle": 473.0
},
"resent": 2,
"pulled_back": 3629,
"moved_too_quickly": 296,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 14.93,
"mspt": {
"average": 23.82,
"p50": 20.11,
"p95": 50.98,
"p99": 74.64
},
"mspt_worst_p99": 195.9,
"tps": 20.0,
"rss_mb": {
"mean": 1031.41,
"max": 1201.2
},
"cpu_percent": 98.21,
"chunks_per_second": 302.81,
"unique_per_second": 10.96,
"forgotten_per_second": 303.47,
"waited_ticks_per_second": 0.0,
"chunks_total": 212181,
"chunks_per_bot": {
"walk": 6496.53,
"explore": 1255.5,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 701.9,
"explore": 1184.7,
"idle": 473.0
},
"resent": 0,
"pulled_back": 7565,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 11.16,
"error": "stopped by its watchdog 30 s after the last bot joined: \"A single server tick took 60.01 seconds (should be max 0.05)\" (Player.tick)"
}
],
"work": [
{
"behaviour": "mixed",
"bots": 1,
"servers": {
"horizon": {
"chunks_per_second": {
"value": 9.94,
"share": 0.996
},
"unique_per_second": {
"value": 0.01,
"share": 1.0
},
"waited_ticks_per_second": 0.0
},
"vanilla": {
"chunks_per_second": {
"value": 9.98,
"share": 1.0
},
"unique_per_second": {
"value": 0.01,
"share": 1.0
},
"waited_ticks_per_second": 0.0
}
},
"equal": true
},
{
"behaviour": "mixed",
"bots": 10,
"servers": {
"horizon": {
"chunks_per_second": {
"value": 61.35,
"share": 0.995
},
"unique_per_second": {
"value": 1.79,
"share": 0.927
},
"waited_ticks_per_second": 0.0
},
"vanilla": {
"chunks_per_second": {
"value": 61.66,
"share": 1.0
},
"unique_per_second": {
"value": 1.93,
"share": 1.0
},
"waited_ticks_per_second": 0.0
}
},
"equal": true
},
{
"behaviour": "mixed",
"bots": 25,
"servers": {
"horizon": {
"chunks_per_second": {
"value": 158.57,
"share": 1.0
},
"unique_per_second": {
"value": 5.78,
"share": 0.701
},
"waited_ticks_per_second": 0.0
},
"vanilla": {
"chunks_per_second": {
"value": 157.63,
"share": 0.994
},
"unique_per_second": {
"value": 8.25,
"share": 1.0
},
"waited_ticks_per_second": 0.0
}
},
"equal": false
}
]
}
@@ -0,0 +1,168 @@
# Horizon's server against vanilla 26.3, equal work (2026-10-17)
The same bots on Horizon's dedicated server and on vanilla 26.3's, one server at a time, on the same
machine, with the work each server does made the same before the numbers are compared. This replaces the
first run in docs/server.md section 9, where the work wasn't equal.
## What was made equal
The first run (PR #556) had three differences that made the comparison unfair. Each is fixed in the code
that ran here:
- **Refused moves.** Vanilla pulls a player back when a move is too fast ("moved too quickly") or ends
inside a block ("moved wrongly"). Horizon now runs both checks the way vanilla does (PR #586), so a bot
that flies too fast is pulled back on both servers.
- **Chunk sending.** Vanilla paces chunks by the client's acknowledgements (PlayerChunkSender: one
unacknowledged batch at first, then ten, each at most the client's desired chunks per tick). Horizon used
to send twice as many per batch; it now paces them the same way (PR #586), and the bots acknowledge
batches and ask for a rate the way vanilla's client does, so both servers send at the pace the client
asks for. Chunks sent again while the bot still held
them (`resent`) are counted: 0 on both servers in every run.
- **Movement gated on chunks.** A bot doesn't move while the chunk it stands in hasn't arrived (vanilla's
client waits the same way), so a slower server isn't asked to teleport bots into terrain it hasn't sent.
One difference isn't in Horizon yet: vanilla 26.3's locator bar (waypoints), which costs vanilla O(players²)
work per move. Every vanilla run here sets `gamerule locator_bar false`, so neither server does it.
## Method
`tools/server_bench.py` runs each configuration on a fresh server:
- the same `server.properties` on both (seed 12345, view and simulation distance 10, offline, creative,
difficulty easy, `sync-chunk-writes=true`), and `gamerule respawn_radius 0`;
- the server waits for `Done`, the bots join one a second, a 60-second warm-up, then 300 seconds of
measurement;
- MSPT (average, P50, P95, P99) comes from each server's own `/tick query`, asked every 5 seconds on the
console, so both measure themselves the same way (vanilla's ServerTickRateManager numbers, which Horizon
reproduces); TPS is 20 while the average fits in 50 ms, else 1000 / MSPT;
- RSS and CPU are the server's whole process tree, once a second (100 % is one core);
- chunk counts come from the bots: chunks received per second, chunks no bot had before (new terrain), and
pull-backs (position packets that move a bot back).
The bots are `horizon-bots` (creative, flying 24 blocks above spawn, 20 moves a second):
- `walk`: flies to random points within 48 blocks of spawn at sprint speed (5.6 blocks a second);
- `explore`: flies straight outwards, by default at about 22 blocks a second, as fast as the server sends
chunks.
Machine: the ThinkPad (Intel Core i7-7500U, 2 cores / 4 threads, 32 GB, NixOS), servers under `nice -n 19`
because it also runs other services; vanilla on OpenJDK 25.0.4.1 with a 4 GB heap (`-Xms4g -Xmx4g`). The
bots ran on the Mac mini and reached the ThinkPad over the tailnet, so they took none of the server's CPU.
What ran: Horizon from `origin/main` at bf851bfe with #586 (move checks and chunk pacing), the bots and
`server_bench.py` from this PR, #623 (sections encoded once, its first commit) and #649 (a tick's packets
handed to each connection together). The ThinkPad copy wasn't a git checkout, so the JSON reports have an
empty `commit`. Vanilla: the pinned 26.3 server jar.
## Results
MSPT is the average milliseconds per tick (50 is the limit for 20 TPS); P95 is the 95th percentile. CPU is
the whole process tree. "New chunks/s" are chunks no bot had before.
### Walk (the same work on both servers)
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s |
|---:|---|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | 5.5 | 12.1 | 20.0 | 1.37 GB | 57 % | 9 | 0.3 |
| 1 | Vanilla | 18.3 | 27.3 | 20.0 | 3.35 GB | 66 % | 10 | 0.3 |
| 10 | Horizon | 11.5 | 22.1 | 20.0 | 1.41 GB | 124 % | 94 | 2.4 |
| 10 | Vanilla | 16.8 | 30.5 | 20.0 | 3.41 GB | 84 % | 100 | 2.8 |
| 25 | Horizon | 20.7 | 40.4 | 20.0 | 1.45 GB | 170 % | 236 | 5.7 |
| 25 | Vanilla | 16.0 | 26.4 | 20.0 | 3.47 GB | 115 % | 252 | 6.7 |
| 50 | Horizon | 31.4 | 79.0 | 20.0 | 1.52 GB | 173 % | 451 | 10.3 |
| 50 | Vanilla | 20.5 | 29.7 | 20.0 | 3.55 GB | 194 % | 502 | 12.2 |
| 100 | Horizon | 114.5 | 256.2 | 8.7 | 1.33 GB | 181 % | 484 | 30.8 |
| 100 | Vanilla | stopped by its watchdog | | | | | | |
Both servers sent the same chunks (about 10 a second per bot, the churn of flying around) and sent none
twice. Vanilla pulled back a few bots for moving too quickly when a tick ran long (5 to 33 per run);
Horizon none. At 100 bots vanilla's watchdog stopped the server (a tick ran over 60 s, in
`ChunkMap$TrackedEntity.updatePlayer`); Horizon kept running at 8.7 TPS.
### Explore at a rate both servers keep up with (equal work)
The bots fly outwards at 0.8 blocks a second (`--explore-speed 0.8`), slow enough that neither server makes
them wait for terrain, so both generate and send the same new chunks.
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s |
|---:|---|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | 4.0 | 6.6 | 20.0 | 1.36 GB | 25 % | 2 | 1.3 |
| 1 | Vanilla | 18.1 | 27.1 | 20.0 | 3.32 GB | 73 % | 2 | 1.4 |
| 10 | Horizon | 13.5 | 24.8 | 20.0 | 1.64 GB | 89 % | 13 | 11.8 |
| 10 | Vanilla | 29.2 | 43.0 | 20.0 | 3.52 GB | 143 % | 13 | 11.9 |
| 25 | Horizon | 20.2 | 36.8 | 20.0 | 1.74 GB | 121 % | 32 | 28.9 |
| 25 | Vanilla | 37.0 | 49.4 | 20.0 | 3.73 GB | 169 % | 32 | 28.9 |
### Explore as fast as each server sends chunks (not equal work)
The default 22 blocks a second is faster than either server can generate, so each server sets the pace and
a faster server is given more work: from 10 bots Horizon generated 1.5 to 2 times vanilla's new chunks a
second. Read it as throughput, not as a like-for-like tick time.
| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s |
|---:|---|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | 10.6 | 24.6 | 20.0 | 1.34 GB | 322 % | 39 | 34.6 |
| 1 | Vanilla | 9.9 | 21.3 | 20.0 | 3.48 GB | 245 % | 36 | 33.4 |
| 10 | Horizon | 30.4 | 52.3 | 20.0 | 2.18 GB | 345 % | 47 | 46.7 |
| 10 | Vanilla | 17.0 | 29.6 | 20.0 | 4.04 GB | 242 % | 22 | 22.4 |
| 25 | Horizon | 50.7 | 89.3 | 19.7 | 2.30 GB | 286 % | 50 | 49.7 |
| 25 | Vanilla | 24.2 | 36.5 | 20.0 | 4.40 GB | 320 % | 34 | 33.7 |
| 50 | Horizon | 50.7 | 102.7 | 19.7 | 1.72 GB | 213 % | 97 | 96.5 |
| 50 | Vanilla | stopped by its watchdog | | | | | | |
| 100 | Horizon | 92.9 | 139.6 | 10.8 | 1.70 GB | 322 % | 114 | 113.6 |
| 100 | Vanilla | stopped by its watchdog | | | | | | |
Vanilla's watchdog stopped it at 50 bots (in `ClientboundLightUpdatePacketData`, copying light for a chunk
packet) and at 100 (in `Entity.move` from a move packet).
## Findings
- Horizon's RSS is 1.9 to 2.6 times smaller in every run, and it starts in 11 s against vanilla's 15 s.
Vanilla's heap is fixed at 4 GB (`-Xms4g`), so part of its RSS is that setting rather than need.
- With 1 bot Horizon's tick is 3.3 times shorter walking and 4.5 times shorter exploring at equal work;
at 10 bots it is still shorter in both.
- At equal exploring work Horizon's tick is about half of vanilla's at 10 and 25 bots.
- When the bots only fly around spawn (`walk`), vanilla's tick hardly grows with players (16 to 20 ms from
1 to 50 bots) while Horizon's grows by about 0.5 ms per bot, so vanilla is ahead from about 20 bots: 16.0
against 20.7 ms at 25 and 20.5 against 31.4 ms at 50. Both keep 20 TPS up to 50.
- With 100 bots vanilla's watchdog stops it in every pass, and Horizon keeps running at 9 to 11 TPS.
### Where Horizon's walk tick goes at 25 bots
Profiled on the ThinkPad with `perf` (the server thread, 20 s, self time):
| Share | What | Whose |
|---:|---|---|
| 18 % | `Streaming::update`: every tick it walks all loaded columns and proto chunks and tests each against every player's area (unloading, dropping protos, finishing and decoration candidates), so the cost grows with players times chunks | streaming / engine tick |
| 20 % | `PalettedContainer::get` and `World::block`, mostly from block entity ticks: on the Mac profile `tick_hoppers` looks up the block under every block entity in the loaded world each tick and compares names as strings, and spawners rebuild their display id | engine tick |
| 8 % | `light_data` when a chunk is sent: the light layers were built twice per chunk; a follow-up commit on #623 builds them once and keeps each section's packed layers | server (this work) |
| 7 % | encoding chunk sections and heightmaps for the chunks each bot newly sees | server |
| 4 % | `Hub::tick` (moves, tracking) | server |
| 2 % | the moved-wrongly collision check | server |
The first two grow with the number of players but aren't specific to the server: they are the
engine's tick and streaming, reported to perf-server-2. Before #623 and #649, encoding every chunk section
once per player and waking each connection's writer thread once per packet cost more than everything above
(at 50 walking bots the tick was 281 ms; see the earlier runs).
## Earlier runs (same machine and method)
- `run-1-before-chunk-cache.json`: before #623, with vanilla's locator bar on. Horizon's walk tick was
27.5, 40.7, 281 and 467 ms at 10, 25, 50 and 100 bots, and vanilla's 33.2 ms at 50 (the locator bar).
- `run-2-chunk-cache.json`: Horizon with #623 only: 13.2, 23.5, 31.4 and 84.4 ms walking at 10 to 100
bots.
## Files
- `horizon.json`, `vanilla.json`: the walk and explore passes (vanilla with `locator_bar=false`).
- `explore-equal-rate.json`: the explore pass at 0.8 blocks a second, both servers.
- `run-1-before-chunk-cache.json`, `run-2-chunk-cache.json`: the earlier runs.
To run it again (bots on another machine reached at `--connect`):
cargo build --release -p horizon-client --bin horizon-client -p horizon-server -p horizon-bots
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25,50,100 --behaviour walk,explore \
--gamerule locator_bar=false --nice 19 --bots-via 'ssh macmini' --bots-bin /path/to/horizon-bots \
--connect <this machine's address> --out results.json
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25 --behaviour explore \
--explore-speed 0.8 --gamerule locator_bar=false --nice 19 ... --out explore-equal-rate.json
@@ -0,0 +1,267 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T05:47:12+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25",
"behaviour": "explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 300,
"seed": 12345,
"explore_speed": 0.8,
"gamerule": "locator_bar=false",
"heap": "4g",
"out": "/home/ludovic/scratch/sh2-bench5.json",
"nice": 19,
"bots_via": "ssh -o BatchMode=yes macmini",
"bots_bin": "/tmp/sh2-horizon-bots",
"connect": "100.72.51.8",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 10.91,
"mspt": {
"average": 4.02,
"p50": 3.75,
"p95": 6.63,
"p99": 14.55
},
"mspt_worst_p99": 63.3,
"tps": 20.0,
"rss_mb": {
"mean": 1392.42,
"max": 1746.1
},
"cpu_percent": 24.52,
"chunks_per_second": 1.69,
"unique_per_second": 1.34,
"forgotten_per_second": 1.62,
"waited_ticks_per_second": 0.0,
"chunks_total": 1048,
"chunks_per_bot": {
"explore": 1048.0
},
"unique_chunks_per_bot": {
"explore": 952.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "explore",
"start_seconds": 15.13,
"mspt": {
"average": 18.12,
"p50": 18.78,
"p95": 27.07,
"p99": 60.81
},
"mspt_worst_p99": 1397.6,
"tps": 20.0,
"rss_mb": {
"mean": 3397.31,
"max": 3430.2
},
"cpu_percent": 72.99,
"chunks_per_second": 1.69,
"unique_per_second": 1.38,
"forgotten_per_second": 1.62,
"waited_ticks_per_second": 0.0,
"chunks_total": 1048,
"chunks_per_bot": {
"explore": 1048.0
},
"unique_chunks_per_bot": {
"explore": 952.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 11.12,
"mspt": {
"average": 13.49,
"p50": 12.09,
"p95": 24.81,
"p99": 40.9
},
"mspt_worst_p99": 101.4,
"tps": 20.0,
"rss_mb": {
"mean": 1684.04,
"max": 2660.6
},
"cpu_percent": 89.04,
"chunks_per_second": 13.08,
"unique_per_second": 11.84,
"forgotten_per_second": 13.02,
"waited_ticks_per_second": 0.0,
"chunks_total": 9277,
"chunks_per_bot": {
"explore": 927.7
},
"unique_chunks_per_bot": {
"explore": 889.9
},
"resent": 0,
"pulled_back": 3381,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "explore",
"start_seconds": 14.84,
"mspt": {
"average": 29.18,
"p50": 26.76,
"p95": 43.02,
"p99": 80.16
},
"mspt_worst_p99": 425.5,
"tps": 20.0,
"rss_mb": {
"mean": 3609.51,
"max": 3791.6
},
"cpu_percent": 142.79,
"chunks_per_second": 13.1,
"unique_per_second": 11.88,
"forgotten_per_second": 13.12,
"waited_ticks_per_second": 0.0,
"chunks_total": 9245,
"chunks_per_bot": {
"explore": 924.5
},
"unique_chunks_per_bot": {
"explore": 885.3
},
"resent": 0,
"pulled_back": 3388,
"moved_too_quickly": 6,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 11.02,
"mspt": {
"average": 20.25,
"p50": 18.73,
"p95": 36.78,
"p99": 55.76
},
"mspt_worst_p99": 92.3,
"tps": 20.0,
"rss_mb": {
"mean": 1782.26,
"max": 2741.9
},
"cpu_percent": 120.8,
"chunks_per_second": 32.22,
"unique_per_second": 28.89,
"forgotten_per_second": 32.19,
"waited_ticks_per_second": 0.0,
"chunks_total": 23357,
"chunks_per_bot": {
"explore": 934.28
},
"unique_chunks_per_bot": {
"explore": 890.32
},
"resent": 0,
"pulled_back": 11580,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "explore",
"start_seconds": 14.54,
"mspt": {
"average": 37.04,
"p50": 34.77,
"p95": 49.42,
"p99": 101.67
},
"mspt_worst_p99": 735.2,
"tps": 20.0,
"rss_mb": {
"mean": 3815.46,
"max": 4202.0
},
"cpu_percent": 169.15,
"chunks_per_second": 32.34,
"unique_per_second": 28.93,
"forgotten_per_second": 32.31,
"waited_ticks_per_second": 0.0,
"chunks_total": 23306,
"chunks_per_bot": {
"explore": 932.24
},
"unique_chunks_per_bot": {
"explore": 886.96
},
"resent": 0,
"pulled_back": 11539,
"moved_too_quickly": 6,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 59
}
]
}
@@ -0,0 +1,415 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T05:04:57+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon",
"bots": "1,10,25,50,100",
"behaviour": "walk,explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 300,
"seed": 12345,
"explore_speed": 0,
"gamerule": "locator_bar=false",
"heap": "4g",
"out": "/home/ludovic/scratch/sh2-bench4.json",
"nice": 19,
"bots_via": "ssh -o BatchMode=yes macmini",
"bots_bin": "/tmp/sh2-horizon-bots",
"connect": "100.72.51.8",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "walk",
"start_seconds": 11.02,
"mspt": {
"average": 5.45,
"p50": 4.63,
"p95": 12.14,
"p99": 30.63
},
"mspt_worst_p99": 91.5,
"tps": 20.0,
"rss_mb": {
"mean": 1398.5,
"max": 1708.4
},
"cpu_percent": 57.03,
"chunks_per_second": 9.17,
"unique_per_second": 0.26,
"forgotten_per_second": 9.2,
"waited_ticks_per_second": 0.0,
"chunks_total": 3708,
"chunks_per_bot": {
"walk": 3708.0
},
"unique_chunks_per_bot": {
"walk": 694.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 10,
"behaviour": "walk",
"start_seconds": 11.02,
"mspt": {
"average": 11.51,
"p50": 10.6,
"p95": 22.13,
"p99": 36.92
},
"mspt_worst_p99": 71.9,
"tps": 20.0,
"rss_mb": {
"mean": 1440.2,
"max": 1832.4
},
"cpu_percent": 124.19,
"chunks_per_second": 94.04,
"unique_per_second": 2.38,
"forgotten_per_second": 93.95,
"waited_ticks_per_second": 0.0,
"chunks_total": 37269,
"chunks_per_bot": {
"walk": 3726.9
},
"unique_chunks_per_bot": {
"walk": 688.9
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 25,
"behaviour": "walk",
"start_seconds": 10.91,
"mspt": {
"average": 20.74,
"p50": 19.27,
"p95": 40.42,
"p99": 57.12
},
"mspt_worst_p99": 102.0,
"tps": 20.0,
"rss_mb": {
"mean": 1485.4,
"max": 1871.9
},
"cpu_percent": 169.52,
"chunks_per_second": 235.81,
"unique_per_second": 5.68,
"forgotten_per_second": 235.83,
"waited_ticks_per_second": 0.0,
"chunks_total": 94456,
"chunks_per_bot": {
"walk": 3778.24
},
"unique_chunks_per_bot": {
"walk": 692.96
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 50,
"behaviour": "walk",
"start_seconds": 11.12,
"mspt": {
"average": 31.41,
"p50": 25.39,
"p95": 78.97,
"p99": 110.73
},
"mspt_worst_p99": 192.9,
"tps": 20.0,
"rss_mb": {
"mean": 1558.34,
"max": 1915.3
},
"cpu_percent": 173.27,
"chunks_per_second": 450.85,
"unique_per_second": 10.3,
"forgotten_per_second": 451.86,
"waited_ticks_per_second": 0.0,
"chunks_total": 186885,
"chunks_per_bot": {
"walk": 3737.7
},
"unique_chunks_per_bot": {
"walk": 688.9
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 100,
"behaviour": "walk",
"start_seconds": 18.16,
"mspt": {
"average": 114.55,
"p50": 101.37,
"p95": 256.19,
"p99": 338.64
},
"mspt_worst_p99": 442.6,
"tps": 8.73,
"rss_mb": {
"mean": 1357.65,
"max": 1358.4
},
"cpu_percent": 181.21,
"chunks_per_second": 484.16,
"unique_per_second": 30.76,
"forgotten_per_second": 478.77,
"waited_ticks_per_second": 0.0,
"chunks_total": 212947,
"chunks_per_bot": {
"walk": 2129.47
},
"unique_chunks_per_bot": {
"walk": 583.02
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 57
},
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 17.45,
"mspt": {
"average": 10.62,
"p50": 9.2,
"p95": 24.61,
"p99": 38.18
},
"mspt_worst_p99": 81.1,
"tps": 20.0,
"rss_mb": {
"mean": 1372.73,
"max": 1568.7
},
"cpu_percent": 322.38,
"chunks_per_second": 39.21,
"unique_per_second": 34.6,
"forgotten_per_second": 38.55,
"waited_ticks_per_second": 0.51,
"chunks_total": 12549,
"chunks_per_bot": {
"explore": 12549.0
},
"unique_chunks_per_bot": {
"explore": 11168.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 11.32,
"mspt": {
"average": 30.38,
"p50": 27.84,
"p95": 52.31,
"p99": 77.89
},
"mspt_worst_p99": 158.3,
"tps": 20.0,
"rss_mb": {
"mean": 2231.56,
"max": 4022.6
},
"cpu_percent": 344.99,
"chunks_per_second": 47.34,
"unique_per_second": 46.73,
"forgotten_per_second": 44.57,
"waited_ticks_per_second": 112.27,
"chunks_total": 19870,
"chunks_per_bot": {
"explore": 1987.0
},
"unique_chunks_per_bot": {
"explore": 1966.5
},
"resent": 0,
"pulled_back": 16130,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 11.42,
"mspt": {
"average": 50.67,
"p50": 47.61,
"p95": 89.3,
"p99": 119.15
},
"mspt_worst_p99": 222.3,
"tps": 19.73,
"rss_mb": {
"mean": 2358.4,
"max": 4273.7
},
"cpu_percent": 286.32,
"chunks_per_second": 50.39,
"unique_per_second": 49.67,
"forgotten_per_second": 47.45,
"waited_ticks_per_second": 311.85,
"chunks_total": 28954,
"chunks_per_bot": {
"explore": 1158.16
},
"unique_chunks_per_bot": {
"explore": 1147.56
},
"resent": 0,
"pulled_back": 36319,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 50,
"behaviour": "explore",
"start_seconds": 20.06,
"mspt": {
"average": 50.69,
"p50": 47.48,
"p95": 102.67,
"p99": 133.27
},
"mspt_worst_p99": 274.4,
"tps": 19.73,
"rss_mb": {
"mean": 1764.01,
"max": 2542.0
},
"cpu_percent": 212.83,
"chunks_per_second": 97.33,
"unique_per_second": 96.47,
"forgotten_per_second": 76.74,
"waited_ticks_per_second": 750.49,
"chunks_total": 38538,
"chunks_per_bot": {
"explore": 770.76
},
"unique_chunks_per_bot": {
"explore": 763.84
},
"resent": 0,
"pulled_back": 39880,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 100,
"behaviour": "explore",
"start_seconds": 11.82,
"mspt": {
"average": 92.93,
"p50": 87.84,
"p95": 139.6,
"p99": 178.38
},
"mspt_worst_p99": 272.9,
"tps": 10.76,
"rss_mb": {
"mean": 1736.38,
"max": 1855.7
},
"cpu_percent": 321.53,
"chunks_per_second": 113.89,
"unique_per_second": 113.56,
"forgotten_per_second": 101.68,
"waited_ticks_per_second": 1179.4,
"chunks_total": 84617,
"chunks_per_bot": {
"explore": 846.17
},
"unique_chunks_per_bot": {
"explore": 845.01
},
"resent": 0,
"pulled_back": 128104,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 58
}
]
}
@@ -0,0 +1,693 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T00:37:35+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25,50,100",
"behaviour": "walk,explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 300,
"seed": 12345,
"heap": "4g",
"out": "/home/ludovic/scratch/sh2-bench.json",
"nice": 19,
"bots_via": "ssh -o BatchMode=yes macmini",
"bots_bin": "/tmp/sh2-horizon-bots",
"connect": "100.72.51.8",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "walk",
"start_seconds": 11.12,
"mspt": {
"average": 7.63,
"p50": 5.51,
"p95": 17.46,
"p99": 36.79
},
"mspt_worst_p99": 79.6,
"tps": 20.0,
"rss_mb": {
"mean": 1391.94,
"max": 1700.7
},
"cpu_percent": 60.18,
"chunks_per_second": 9.15,
"unique_per_second": 0.29,
"forgotten_per_second": 9.18,
"waited_ticks_per_second": 0.0,
"chunks_total": 3682,
"chunks_per_bot": {
"walk": 3682.0
},
"unique_chunks_per_bot": {
"walk": 692.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "walk",
"start_seconds": 15.35,
"mspt": {
"average": 18.08,
"p50": 18.66,
"p95": 28.05,
"p99": 46.94
},
"mspt_worst_p99": 456.4,
"tps": 20.0,
"rss_mb": {
"mean": 3455.72,
"max": 3471.7
},
"cpu_percent": 65.67,
"chunks_per_second": 9.89,
"unique_per_second": 0.29,
"forgotten_per_second": 9.92,
"waited_ticks_per_second": 0.0,
"chunks_total": 3827,
"chunks_per_bot": {
"walk": 3827.0
},
"unique_chunks_per_bot": {
"walk": 699.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 10,
"behaviour": "walk",
"start_seconds": 11.22,
"mspt": {
"average": 27.53,
"p50": 24.93,
"p95": 53.93,
"p99": 77.03
},
"mspt_worst_p99": 111.4,
"tps": 20.0,
"rss_mb": {
"mean": 1433.85,
"max": 1831.9
},
"cpu_percent": 138.65,
"chunks_per_second": 95.98,
"unique_per_second": 2.38,
"forgotten_per_second": 95.78,
"waited_ticks_per_second": 0.0,
"chunks_total": 38041,
"chunks_per_bot": {
"walk": 3804.1
},
"unique_chunks_per_bot": {
"walk": 690.8
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "walk",
"start_seconds": 14.83,
"mspt": {
"average": 18.92,
"p50": 16.02,
"p95": 39.0,
"p99": 56.13
},
"mspt_worst_p99": 180.3,
"tps": 20.0,
"rss_mb": {
"mean": 3479.45,
"max": 3535.7
},
"cpu_percent": 99.39,
"chunks_per_second": 99.93,
"unique_per_second": 2.94,
"forgotten_per_second": 100.04,
"waited_ticks_per_second": 0.0,
"chunks_total": 38411,
"chunks_per_bot": {
"walk": 3841.1
},
"unique_chunks_per_bot": {
"walk": 697.1
},
"resent": 0,
"pulled_back": 7,
"moved_too_quickly": 7,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "horizon",
"bots": 25,
"behaviour": "walk",
"start_seconds": 11.52,
"mspt": {
"average": 40.69,
"p50": 37.28,
"p95": 81.16,
"p99": 108.42
},
"mspt_worst_p99": 149.9,
"tps": 20.0,
"rss_mb": {
"mean": 1444.12,
"max": 1882.6
},
"cpu_percent": 176.94,
"chunks_per_second": 244.11,
"unique_per_second": 6.51,
"forgotten_per_second": 243.96,
"waited_ticks_per_second": 0.0,
"chunks_total": 95219,
"chunks_per_bot": {
"walk": 3808.76
},
"unique_chunks_per_bot": {
"walk": 695.64
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "walk",
"start_seconds": 15.23,
"mspt": {
"average": 18.81,
"p50": 17.26,
"p95": 30.16,
"p99": 53.9
},
"mspt_worst_p99": 390.0,
"tps": 20.0,
"rss_mb": {
"mean": 3517.36,
"max": 3555.7
},
"cpu_percent": 148.52,
"chunks_per_second": 252.34,
"unique_per_second": 7.75,
"forgotten_per_second": 251.95,
"waited_ticks_per_second": 0.0,
"chunks_total": 96645,
"chunks_per_bot": {
"walk": 3865.8
},
"unique_chunks_per_bot": {
"walk": 698.4
},
"resent": 0,
"pulled_back": 11,
"moved_too_quickly": 11,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "horizon",
"bots": 50,
"behaviour": "walk",
"start_seconds": 12.72,
"mspt": {
"average": 281.42,
"p50": 278.28,
"p95": 343.64,
"p99": 384.23
},
"mspt_worst_p99": 475.1,
"tps": 3.55,
"rss_mb": {
"mean": 1334.32,
"max": 1335.4
},
"cpu_percent": 197.11,
"chunks_per_second": 391.5,
"unique_per_second": 21.24,
"forgotten_per_second": 388.33,
"waited_ticks_per_second": 0.0,
"chunks_total": 153136,
"chunks_per_bot": {
"walk": 3062.72
},
"unique_chunks_per_bot": {
"walk": 661.82
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 57
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "walk",
"start_seconds": 15.74,
"mspt": {
"average": 33.21,
"p50": 29.9,
"p95": 53.37,
"p99": 109.14
},
"mspt_worst_p99": 833.3,
"tps": 20.0,
"rss_mb": {
"mean": 3568.07,
"max": 3594.9
},
"cpu_percent": 266.59,
"chunks_per_second": 496.3,
"unique_per_second": 18.64,
"forgotten_per_second": 495.77,
"waited_ticks_per_second": 0.0,
"chunks_total": 191965,
"chunks_per_bot": {
"walk": 3839.3
},
"unique_chunks_per_bot": {
"walk": 696.22
},
"resent": 0,
"pulled_back": 184,
"moved_too_quickly": 183,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 100,
"behaviour": "walk",
"start_seconds": 11.22,
"mspt": {
"average": 467.37,
"p50": 464.62,
"p95": 524.07,
"p99": 548.77
},
"mspt_worst_p99": 581.1,
"tps": 2.14,
"rss_mb": {
"mean": 1353.68,
"max": 1354.9
},
"cpu_percent": 191.96,
"chunks_per_second": 377.62,
"unique_per_second": 55.8,
"forgotten_per_second": 348.88,
"waited_ticks_per_second": 0.0,
"chunks_total": 165719,
"chunks_per_bot": {
"walk": 1657.19
},
"unique_chunks_per_bot": {
"walk": 549.8
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 56
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "walk",
"start_seconds": 14.84,
"error": "[Errno 32] Broken pipe"
},
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 11.12,
"mspt": {
"average": 17.07,
"p50": 16.6,
"p95": 32.82,
"p99": 50.3
},
"mspt_worst_p99": 113.9,
"tps": 20.0,
"rss_mb": {
"mean": 1391.72,
"max": 1566.6
},
"cpu_percent": 333.8,
"chunks_per_second": 39.42,
"unique_per_second": 34.77,
"forgotten_per_second": 39.34,
"waited_ticks_per_second": 0.0,
"chunks_total": 13860,
"chunks_per_bot": {
"explore": 13860.0
},
"unique_chunks_per_bot": {
"explore": 12379.0
},
"resent": 0,
"pulled_back": 192,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "explore",
"start_seconds": 16.03,
"mspt": {
"average": 9.9,
"p50": 8.04,
"p95": 20.41,
"p99": 63.62
},
"mspt_worst_p99": 351.1,
"tps": 20.0,
"rss_mb": {
"mean": 3709.92,
"max": 3776.2
},
"cpu_percent": 246.64,
"chunks_per_second": 36.06,
"unique_per_second": 33.33,
"forgotten_per_second": 35.73,
"waited_ticks_per_second": 0.0,
"chunks_total": 12369,
"chunks_per_bot": {
"explore": 12369.0
},
"unique_chunks_per_bot": {
"explore": 11559.0
},
"resent": 0,
"pulled_back": 222,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 11.42,
"mspt": {
"average": 33.72,
"p50": 31.08,
"p95": 61.07,
"p99": 89.09
},
"mspt_worst_p99": 142.6,
"tps": 20.0,
"rss_mb": {
"mean": 2239.97,
"max": 4108.8
},
"cpu_percent": 350.83,
"chunks_per_second": 46.49,
"unique_per_second": 46.08,
"forgotten_per_second": 44.33,
"waited_ticks_per_second": 111.28,
"chunks_total": 19915,
"chunks_per_bot": {
"explore": 1991.5
},
"unique_chunks_per_bot": {
"explore": 1974.4
},
"resent": 0,
"pulled_back": 17516,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "explore",
"start_seconds": 15.15,
"mspt": {
"average": 16.25,
"p50": 13.67,
"p95": 28.92,
"p99": 115.9
},
"mspt_worst_p99": 1202.1,
"tps": 20.0,
"rss_mb": {
"mean": 4202.72,
"max": 4502.7
},
"cpu_percent": 247.47,
"chunks_per_second": 22.3,
"unique_per_second": 22.25,
"forgotten_per_second": 22.64,
"waited_ticks_per_second": 78.89,
"chunks_total": 10092,
"chunks_per_bot": {
"explore": 1009.2
},
"unique_chunks_per_bot": {
"explore": 1007.4
},
"resent": 0,
"pulled_back": 16286,
"moved_too_quickly": 12,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 11.82,
"mspt": {
"average": 48.03,
"p50": 44.99,
"p95": 83.06,
"p99": 109.63
},
"mspt_worst_p99": 191.8,
"tps": 20.0,
"rss_mb": {
"mean": 2214.16,
"max": 4598.6
},
"cpu_percent": 356.03,
"chunks_per_second": 64.2,
"unique_per_second": 63.5,
"forgotten_per_second": 60.9,
"waited_ticks_per_second": 301.65,
"chunks_total": 32247,
"chunks_per_bot": {
"explore": 1289.88
},
"unique_chunks_per_bot": {
"explore": 1279.4
},
"resent": 0,
"pulled_back": 42159,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "explore",
"start_seconds": 14.73,
"mspt": {
"average": 25.73,
"p50": 22.75,
"p95": 37.72,
"p99": 201.98
},
"mspt_worst_p99": 4051.5,
"tps": 20.0,
"rss_mb": {
"mean": 4355.9,
"max": 4983.2
},
"cpu_percent": 309.3,
"chunks_per_second": 33.33,
"unique_per_second": 33.21,
"forgotten_per_second": 34.22,
"waited_ticks_per_second": 258.68,
"chunks_total": 19426,
"chunks_per_bot": {
"explore": 777.04
},
"unique_chunks_per_bot": {
"explore": 772.92
},
"resent": 0,
"pulled_back": 53918,
"moved_too_quickly": 93,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 50,
"behaviour": "explore",
"start_seconds": 11.01,
"mspt": {
"average": 108.61,
"p50": 107.37,
"p95": 151.96,
"p99": 177.41
},
"mspt_worst_p99": 271.6,
"tps": 9.21,
"rss_mb": {
"mean": 1775.98,
"max": 1956.9
},
"cpu_percent": 359.7,
"chunks_per_second": 100.32,
"unique_per_second": 98.91,
"forgotten_per_second": 95.42,
"waited_ticks_per_second": 518.15,
"chunks_total": 55956,
"chunks_per_bot": {
"explore": 1119.12
},
"unique_chunks_per_bot": {
"explore": 1111.42
},
"resent": 0,
"pulled_back": 69967,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "explore",
"start_seconds": 14.74,
"error": "[Errno 32] Broken pipe"
},
{
"server": "horizon",
"bots": 100,
"behaviour": "explore",
"start_seconds": 11.12,
"mspt": {
"average": 244.43,
"p50": 243.58,
"p95": 308.39,
"p99": 360.12
},
"mspt_worst_p99": 470.2,
"tps": 4.09,
"rss_mb": {
"mean": 1806.85,
"max": 2084.5
},
"cpu_percent": 310.46,
"chunks_per_second": 186.87,
"unique_per_second": 182.99,
"forgotten_per_second": 161.75,
"waited_ticks_per_second": 1131.65,
"chunks_total": 100389,
"chunks_per_bot": {
"explore": 1003.89
},
"unique_chunks_per_bot": {
"explore": 991.9
},
"resent": 0,
"pulled_back": 56791,
"moved_too_quickly": 344,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 57
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "explore",
"start_seconds": 14.83,
"error": "[Errno 32] Broken pipe"
}
]
}
@@ -0,0 +1,413 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T02:22:59+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon",
"bots": "1,10,25,50,100",
"behaviour": "walk,explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 300,
"seed": 12345,
"heap": "4g",
"out": "/home/ludovic/scratch/sh2-bench2.json",
"nice": 19,
"bots_via": "ssh -o BatchMode=yes macmini",
"bots_bin": "/tmp/sh2-horizon-bots",
"connect": "100.72.51.8",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "walk",
"start_seconds": 11.12,
"mspt": {
"average": 5.41,
"p50": 4.63,
"p95": 11.61,
"p99": 30.5
},
"mspt_worst_p99": 64.6,
"tps": 20.0,
"rss_mb": {
"mean": 1398.84,
"max": 1708.5
},
"cpu_percent": 56.84,
"chunks_per_second": 9.19,
"unique_per_second": 0.27,
"forgotten_per_second": 9.22,
"waited_ticks_per_second": 0.0,
"chunks_total": 3713,
"chunks_per_bot": {
"walk": 3713.0
},
"unique_chunks_per_bot": {
"walk": 694.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 10,
"behaviour": "walk",
"start_seconds": 10.92,
"mspt": {
"average": 13.18,
"p50": 11.74,
"p95": 26.5,
"p99": 44.82
},
"mspt_worst_p99": 87.6,
"tps": 20.0,
"rss_mb": {
"mean": 1439.83,
"max": 1831.6
},
"cpu_percent": 132.94,
"chunks_per_second": 93.87,
"unique_per_second": 2.38,
"forgotten_per_second": 93.77,
"waited_ticks_per_second": 0.0,
"chunks_total": 37240,
"chunks_per_bot": {
"walk": 3724.0
},
"unique_chunks_per_bot": {
"walk": 688.7
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 25,
"behaviour": "walk",
"start_seconds": 10.92,
"mspt": {
"average": 23.52,
"p50": 21.92,
"p95": 44.7,
"p99": 63.12
},
"mspt_worst_p99": 119.1,
"tps": 20.0,
"rss_mb": {
"mean": 1485.82,
"max": 1872.0
},
"cpu_percent": 173.82,
"chunks_per_second": 235.63,
"unique_per_second": 5.64,
"forgotten_per_second": 235.72,
"waited_ticks_per_second": 0.0,
"chunks_total": 94549,
"chunks_per_bot": {
"walk": 3781.96
},
"unique_chunks_per_bot": {
"walk": 692.8
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 50,
"behaviour": "walk",
"start_seconds": 10.91,
"mspt": {
"average": 31.36,
"p50": 26.08,
"p95": 70.6,
"p99": 92.83
},
"mspt_worst_p99": 125.5,
"tps": 20.0,
"rss_mb": {
"mean": 1556.81,
"max": 1913.0
},
"cpu_percent": 192.16,
"chunks_per_second": 468.69,
"unique_per_second": 10.43,
"forgotten_per_second": 468.78,
"waited_ticks_per_second": 0.0,
"chunks_total": 193547,
"chunks_per_bot": {
"walk": 3870.94
},
"unique_chunks_per_bot": {
"walk": 692.84
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 100,
"behaviour": "walk",
"start_seconds": 11.02,
"mspt": {
"average": 84.38,
"p50": 81.5,
"p95": 132.92,
"p99": 168.38
},
"mspt_worst_p99": 245.5,
"tps": 11.85,
"rss_mb": {
"mean": 1395.64,
"max": 1941.3
},
"cpu_percent": 271.28,
"chunks_per_second": 850.05,
"unique_per_second": 21.79,
"forgotten_per_second": 851.1,
"waited_ticks_per_second": 0.0,
"chunks_total": 368464,
"chunks_per_bot": {
"walk": 3684.64
},
"unique_chunks_per_bot": {
"walk": 680.58
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 1,
"behaviour": "explore",
"start_seconds": 11.02,
"mspt": {
"average": 11.63,
"p50": 10.27,
"p95": 24.52,
"p99": 40.53
},
"mspt_worst_p99": 99.9,
"tps": 20.0,
"rss_mb": {
"mean": 1413.81,
"max": 1596.1
},
"cpu_percent": 329.84,
"chunks_per_second": 39.47,
"unique_per_second": 34.81,
"forgotten_per_second": 39.42,
"waited_ticks_per_second": 0.0,
"chunks_total": 13798,
"chunks_per_bot": {
"explore": 13798.0
},
"unique_chunks_per_bot": {
"explore": 12330.0
},
"resent": 0,
"pulled_back": 185,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 10,
"behaviour": "explore",
"start_seconds": 11.02,
"mspt": {
"average": 28.49,
"p50": 26.2,
"p95": 48.58,
"p99": 72.04
},
"mspt_worst_p99": 125.7,
"tps": 20.0,
"rss_mb": {
"mean": 2231.06,
"max": 4077.8
},
"cpu_percent": 344.95,
"chunks_per_second": 46.31,
"unique_per_second": 45.88,
"forgotten_per_second": 44.14,
"waited_ticks_per_second": 111.95,
"chunks_total": 19722,
"chunks_per_bot": {
"explore": 1972.2
},
"unique_chunks_per_bot": {
"explore": 1957.0
},
"resent": 0,
"pulled_back": 18533,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 25,
"behaviour": "explore",
"start_seconds": 11.12,
"mspt": {
"average": 40.04,
"p50": 37.61,
"p95": 62.39,
"p99": 83.64
},
"mspt_worst_p99": 147.0,
"tps": 20.0,
"rss_mb": {
"mean": 2466.85,
"max": 4504.5
},
"cpu_percent": 340.13,
"chunks_per_second": 60.61,
"unique_per_second": 59.62,
"forgotten_per_second": 56.28,
"waited_ticks_per_second": 299.57,
"chunks_total": 32877,
"chunks_per_bot": {
"explore": 1315.08
},
"unique_chunks_per_bot": {
"explore": 1300.72
},
"resent": 0,
"pulled_back": 46272,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 50,
"behaviour": "explore",
"start_seconds": 11.32,
"mspt": {
"average": 61.82,
"p50": 59.21,
"p95": 90.46,
"p99": 115.58
},
"mspt_worst_p99": 320.9,
"tps": 16.18,
"rss_mb": {
"mean": 2634.54,
"max": 4883.1
},
"cpu_percent": 356.96,
"chunks_per_second": 95.26,
"unique_per_second": 94.31,
"forgotten_per_second": 85.86,
"waited_ticks_per_second": 528.08,
"chunks_total": 59251,
"chunks_per_bot": {
"explore": 1185.02
},
"unique_chunks_per_bot": {
"explore": 1173.96
},
"resent": 0,
"pulled_back": 99493,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 100,
"behaviour": "explore",
"start_seconds": 10.81,
"mspt": {
"average": 103.24,
"p50": 100.03,
"p95": 144.77,
"p99": 178.8
},
"mspt_worst_p99": 258.6,
"tps": 9.69,
"rss_mb": {
"mean": 1784.48,
"max": 1922.0
},
"cpu_percent": 336.94,
"chunks_per_second": 115.06,
"unique_per_second": 114.86,
"forgotten_per_second": 109.43,
"waited_ticks_per_second": 1127.57,
"chunks_total": 93839,
"chunks_per_bot": {
"explore": 938.39
},
"unique_chunks_per_bot": {
"explore": 935.28
},
"resent": 0,
"pulled_back": 133404,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 100,
"bots_failed": [],
"tick_queries": 58
}
]
}
@@ -0,0 +1,324 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-17T03:29:30+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "vanilla",
"bots": "1,10,25,50,100",
"behaviour": "walk,explore",
"join_every": 1.0,
"warmup": 60,
"seconds": 300,
"seed": 12345,
"gamerule": "locator_bar=false",
"heap": "4g",
"out": "/home/ludovic/scratch/sh2-bench3.json",
"nice": 19,
"bots_via": "ssh -o BatchMode=yes macmini",
"bots_bin": "/tmp/sh2-horizon-bots",
"connect": "100.72.51.8",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "vanilla",
"bots": 1,
"behaviour": "walk",
"start_seconds": 15.43,
"mspt": {
"average": 18.32,
"p50": 19.26,
"p95": 27.34,
"p99": 45.48
},
"mspt_worst_p99": 393.3,
"tps": 20.0,
"rss_mb": {
"mean": 3428.43,
"max": 3437.7
},
"cpu_percent": 66.47,
"chunks_per_second": 9.92,
"unique_per_second": 0.29,
"forgotten_per_second": 9.95,
"waited_ticks_per_second": 0.0,
"chunks_total": 3850,
"chunks_per_bot": {
"walk": 3850.0
},
"unique_chunks_per_bot": {
"walk": 699.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "walk",
"start_seconds": 14.76,
"mspt": {
"average": 16.8,
"p50": 14.55,
"p95": 30.52,
"p99": 50.01
},
"mspt_worst_p99": 205.2,
"tps": 20.0,
"rss_mb": {
"mean": 3490.15,
"max": 3580.7
},
"cpu_percent": 83.7,
"chunks_per_second": 100.01,
"unique_per_second": 2.83,
"forgotten_per_second": 100.04,
"waited_ticks_per_second": 0.0,
"chunks_total": 38623,
"chunks_per_bot": {
"walk": 3862.3
},
"unique_chunks_per_bot": {
"walk": 697.2
},
"resent": 0,
"pulled_back": 5,
"moved_too_quickly": 5,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "walk",
"start_seconds": 14.84,
"mspt": {
"average": 16.01,
"p50": 14.55,
"p95": 26.39,
"p99": 51.26
},
"mspt_worst_p99": 397.8,
"tps": 20.0,
"rss_mb": {
"mean": 3550.02,
"max": 3593.5
},
"cpu_percent": 114.96,
"chunks_per_second": 251.62,
"unique_per_second": 6.72,
"forgotten_per_second": 251.16,
"waited_ticks_per_second": 0.0,
"chunks_total": 98701,
"chunks_per_bot": {
"walk": 3948.04
},
"unique_chunks_per_bot": {
"walk": 698.84
},
"resent": 0,
"pulled_back": 10,
"moved_too_quickly": 10,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "walk",
"start_seconds": 14.43,
"mspt": {
"average": 20.52,
"p50": 19.55,
"p95": 29.72,
"p99": 49.04
},
"mspt_worst_p99": 600.4,
"tps": 20.0,
"rss_mb": {
"mean": 3633.12,
"max": 3658.7
},
"cpu_percent": 193.84,
"chunks_per_second": 501.86,
"unique_per_second": 12.15,
"forgotten_per_second": 501.8,
"waited_ticks_per_second": 0.0,
"chunks_total": 203429,
"chunks_per_bot": {
"walk": 4068.58
},
"unique_chunks_per_bot": {
"walk": 698.66
},
"resent": 0,
"pulled_back": 33,
"moved_too_quickly": 32,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "walk",
"start_seconds": 15.34,
"error": "[Errno 32] Broken pipe"
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "explore",
"start_seconds": 15.55,
"mspt": {
"average": 9.93,
"p50": 8.12,
"p95": 21.33,
"p99": 60.19
},
"mspt_worst_p99": 373.0,
"tps": 20.0,
"rss_mb": {
"mean": 3564.11,
"max": 3655.0
},
"cpu_percent": 245.18,
"chunks_per_second": 35.93,
"unique_per_second": 33.35,
"forgotten_per_second": 35.64,
"waited_ticks_per_second": 0.0,
"chunks_total": 12340,
"chunks_per_bot": {
"explore": 12340.0
},
"unique_chunks_per_bot": {
"explore": 11569.0
},
"resent": 0,
"pulled_back": 261,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "explore",
"start_seconds": 15.03,
"mspt": {
"average": 16.99,
"p50": 14.46,
"p95": 29.61,
"p99": 132.18
},
"mspt_worst_p99": 1244.0,
"tps": 20.0,
"rss_mb": {
"mean": 4135.2,
"max": 4453.1
},
"cpu_percent": 242.32,
"chunks_per_second": 22.39,
"unique_per_second": 22.41,
"forgotten_per_second": 22.78,
"waited_ticks_per_second": 78.78,
"chunks_total": 10177,
"chunks_per_bot": {
"explore": 1017.7
},
"unique_chunks_per_bot": {
"explore": 1016.2
},
"resent": 0,
"pulled_back": 17314,
"moved_too_quickly": 19,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "explore",
"start_seconds": 14.84,
"mspt": {
"average": 24.19,
"p50": 21.22,
"p95": 36.51,
"p99": 174.82
},
"mspt_worst_p99": 1532.8,
"tps": 20.0,
"rss_mb": {
"mean": 4504.55,
"max": 4988.9
},
"cpu_percent": 319.55,
"chunks_per_second": 33.78,
"unique_per_second": 33.66,
"forgotten_per_second": 34.64,
"waited_ticks_per_second": 269.22,
"chunks_total": 20522,
"chunks_per_bot": {
"explore": 820.88
},
"unique_chunks_per_bot": {
"explore": 815.76
},
"resent": 0,
"pulled_back": 46246,
"moved_too_quickly": 28,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "explore",
"start_seconds": 14.73,
"error": "[Errno 32] Broken pipe"
},
{
"server": "vanilla",
"bots": 100,
"behaviour": "explore",
"start_seconds": 14.54,
"error": "[Errno 32] Broken pipe"
}
]
}
@@ -0,0 +1,167 @@
# Horizon's server against vanilla 26.3, Paper and Fabric + Lithium: mixed bots, equal work (2026-10-19)
This is the same load as `../server-equal-mixed-2026-10-18/`, now with two more servers: Paper, and vanilla on
Fabric with Lithium. It also uses a newer Horizon release (v2026.10.19). That release spawns mobs around every
player (#950) and ticks blocks around every player (#931), as vanilla does. The 2026-10-18 build did neither for
players away from spawn, so it had less work to do than vanilla, and its tick times there were lower than
they would have been with equal work. These numbers replace them.
## Summary
- **The work was equal** at every count, on all four servers. Chunks sent a second and first-time chunks a
second were within 4 % of each other, and the walking bots got about the same chunks each (6050 to 6510).
Every bot joined and none was dropped. Vanilla's 50-bot server is the exception: its watchdog stopped it.
- **Memory:** Horizon used 0.72 GB with one player and 1.11 GB with 50. The three Java servers used 3.4 to
4.5 GB (a 4 GB heap each), so Horizon was **3.8 to 4.8 times smaller** at every count.
- **Start:** **8.2 to 8.5 s** to `Done` for Horizon, against 14.7 to 15.5 s for vanilla, 16.6 to 17.6 s for
Fabric + Lithium and 19.0 to 22.4 s for Paper.
- **CPU** (100 % is one of the machine's 4 threads): from 10 players up, Horizon used the least: 143 % at 50,
against Paper's 210 % and Fabric's 236 %. With one player Paper used less (32 % against 46 %).
- **Average tick (MSPT), where Horizon loses:**
- With 1 player, Paper (10.3 ms) and Fabric + Lithium (12.5) were faster than Horizon (13.7). Vanilla was
slowest (17.3).
- With 10, Fabric (20.2) and Paper (20.6) were ahead of Horizon (22.9), which tied vanilla (22.9).
- With 25, Paper (28.3) was ahead of Horizon (30.9), and Horizon was ahead of Fabric (31.4) and vanilla (37.4).
- With 50, Paper (46.9) and Horizon (47.8) were close. Fabric + Lithium fell to 17.2 TPS (58.3 ms), and
vanilla's watchdog stopped its server.
- **Slow ticks, where Horizon wins:** Horizon's P99 was the lowest from 10 players up (72.0 ms at 25 and 73.2
at 50, against Paper's 90.4 and 109.7). Its worst 5-second P99 was the lowest at every count (133 ms at
50, against 213 for Paper and 888 for Fabric).
So on this 4-thread laptop CPU, Horizon is not yet the fastest on average tick time. Paper is ahead at every
count: Horizon's average tick is a third longer than Paper's with one player and 2 % longer with 50. Fabric +
Lithium is ahead with 1 and 10 players.
Horizon is ahead on memory, start time, CPU from 10 players up, and the steadiness of its ticks (P99). Its
average tick is what perf-server-11 is working on.
## Results
MSPT is milliseconds per tick from each server's own `/tick query` (50 is the limit for 20 TPS). P95 and P99
are means over the 5-second queries of the 300 measured seconds. "Worst P99" is the highest P99 of a single
query. RSS and CPU cover the server's process tree. "First-time chunks/s" counts chunks a bot hadn't
received before (summed over the bots). "Pulled back" counts position packets that moved a bot back.
| Bots | Server | MSPT | P95 | P99 | Worst P99 | TPS | RSS (mean / max) | CPU | Start | Chunks/s | First-time chunks/s | Pulled back |
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | 13.70 | 19.06 | 27.38 | **43.4** | 20 | **0.72 / 0.72 GB** | 46 % | **8.3 s** | 9.94 | 0.01 | 0 |
| 1 | vanilla | 17.31 | 24.68 | 36.43 | 71.0 | 20 | 3.44 / 3.44 GB | 55 % | 14.9 s | 9.99 | 0.00 | 0 |
| 1 | Paper | **10.31** | 13.90 | 22.50 | 81.2 | 20 | 4.24 / 4.25 GB | **32 %** | 22.3 s | 9.81 | 0.02 | 0 |
| 1 | Fabric + Lithium | 12.46 | 17.69 | 32.74 | 70.1 | 20 | 3.43 / 3.43 GB | 46 % | 17.6 s | 9.98 | 0.00 | 0 |
| 10 | Horizon | 22.93 | 39.83 | 53.33 | **89.5** | 20 | **0.92 / 0.97 GB** | **67 %** | **8.2 s** | 61.21 | 1.78 | 3702 |
| 10 | vanilla | 22.94 | 36.92 | 56.48 | 196.6 | 20 | 3.71 / 3.76 GB | 101 % | 14.7 s | 61.63 | 1.84 | 3370 |
| 10 | Paper | 20.64 | 35.94 | 56.29 | 166.9 | 20 | 4.07 / 4.08 GB | 74 % | 20.5 s | 60.76 | 1.85 | 3974 |
| 10 | Fabric + Lithium | **20.18** | 37.04 | 56.08 | 217.9 | 20 | 3.73 / 3.78 GB | 94 % | 16.6 s | 61.65 | 1.84 | 4002 |
| 25 | Horizon | 30.92 | 48.25 | **72.00** | **121.1** | 20 | **1.01 / 1.12 GB** | **99 %** | **8.5 s** | 155.81 | 5.78 | 3008 |
| 25 | vanilla | 37.35 | 48.20 | 82.66 | 579.8 | 20 | 3.94 / 4.16 GB | 191 % | 15.5 s | 158.52 | 5.71 | 3762 |
| 25 | Paper | **28.30** | 61.68 | 90.40 | 124.3 | 20 | 4.16 / 4.25 GB | 124 % | 19.0 s | 156.35 | 5.72 | 4337 |
| 25 | Fabric + Lithium | 31.44 | 52.45 | 93.35 | 409.3 | 20 | 3.88 / 4.19 GB | 174 % | 17.5 s | 158.89 | 5.75 | 4194 |
| 50 | Horizon | 47.83 | 62.42 | **73.21** | **133.0** | 20 | **1.11 / 1.30 GB** | **143 %** | **8.3 s** | 302.68 | 11.02 | 8562 |
| 50 | vanilla | stopped by its watchdog | | | | | | | | | | |
| 50 | Paper | **46.86** | 61.16 | 109.72 | 213.4 | 20 | 4.35 / 4.52 GB | 210 % | 19.4 s | 310.51 | 11.16 | 7344 |
| 50 | Fabric + Lithium | 58.30 | 83.06 | 174.37 | 887.6 | 17.15 | 4.52 / 4.74 GB | 236 % | 17.5 s | 311.52 | 11.28 | 6350 |
Vanilla at 50: all 50 bots joined by 05:22:28 UTC. The server fell further and further behind ("Can't keep up!
… Running 16229ms or 324 ticks behind"), and at 05:28:17, during the warm-up, its watchdog stopped it: "A
single server tick took 60.00 seconds". That is the same as on 2026-10-18.
Chunks each bot received over the whole run, by behaviour (mean):
| Bots | Server | walk | explore | idle |
|---:|---|---:|---:|---:|
| 1 | Horizon | 6280 | | |
| 1 | vanilla | 6210 | | |
| 1 | Paper | 6049 | | |
| 1 | Fabric + Lithium | 6188 | | |
| 10 | Horizon | 6460 | 1195 | 473 |
| 10 | vanilla | 6272 | 1180 | 473 |
| 10 | Paper | 6177 | 1171 | 473 |
| 10 | Fabric + Lithium | 6291 | 1181 | 473 |
| 25 | Horizon | 6506 | 1262 | 473 |
| 25 | vanilla | 6307 | 1236 | 473 |
| 25 | Paper | 6280 | 1229 | 473 |
| 25 | Fabric + Lithium | 6357 | 1239 | 473 |
| 50 | Horizon | 6468 | 1251 | 473 |
| 50 | Paper | 6411 | 1224 | 473 |
| 50 | Fabric + Lithium | 6332 | 1228 | 473 |
## Equal work
`server_bench.py`'s work table compares, at each bot count, every server's chunks sent a second and
first-time chunks a second with the busiest server's. The work is equal when both are within 10 % (or within
a chunk a second when there are few).
| Bots | Chunks/s sent, first-time (Horizon; vanilla; Paper; Fabric + Lithium) | Equal |
|---:|---|---|
| 1 | 9.94, 0.01; 9.99, 0.00; 9.81, 0.02; 9.98, 0.00 | yes |
| 10 | 61.21, 1.78; 61.63, 1.84; 60.76, 1.85; 61.65, 1.84 | yes |
| 25 | 155.81, 5.78; 158.52, 5.71; 156.35, 5.72; 158.89, 5.75 | yes |
| 50 | 302.68, 11.02; (stopped); 310.51, 11.16; 311.52, 11.28 | yes |
Horizon pulled no bot back for moving too quickly (`moved too quickly`: 0 at every count). The Java servers
logged 0 to 46 such lines a run. All four sent position corrections ("Pulled back") at similar rates.
## Method
`tools/server_bench.py` ran each configuration on a fresh server:
- the same `server.properties` on all four: seed 12345, view and simulation distance 10, offline, creative,
difficulty easy, `sync-chunk-writes=true`, white-list off;
- `gamerule respawn_radius 0`, and otherwise each server's default game rules;
- the server waits for `Done`, the bots join one a second, warm up for 300 seconds and are measured for 300,
with `/tick query` every 5 seconds and RSS and CPU every second.
The bots are `horizon-bots --behaviour mixed`: 60 % walk, 20 % explore and 20 % idle, each choice deterministic
per bot. All are creative and fly 24 blocks above spawn. `walk` flies to random points within 48 blocks at
5.6 blocks a second, `explore` flies straight out at 0.8 blocks a second (`--explore-speed 0.8`), and `idle`
hovers.
**Machine.** The ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS. The desktop was meant to
run it but didn't wake (`~/fleet/power wake desktop` timed out twice). No other session ran on the ThinkPad
(`load.txt`, a sample a minute: 0 other sessions throughout). The benchmark ran under `nice -n 19` and
`ionice -c3`. The bots ran on the Mac mini (M4 Pro, shared with other work at 51 to 98 % CPU, 82 % on
average) and reached the ThinkPad over the tailnet.
**What ran.**
- Horizon: release v2026.10.19 (07f2913d), the server archive `horizon-server-linux-x86_64.tar.gz` installed
with `install.sh`. `horizon-server` ran its `horizon-engine` with game files prepared from the owner's own
Minecraft.
- Vanilla: the pinned 26.3 server jar (the owner's own copy) on OpenJDK 25.0.4.1, `-Xms4g -Xmx4g`.
- Paper: 26.3 build 205 (`26.3-205-main@a90108f`), from PaperMC's download API, checked against its SHA-256.
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
from its cache. Same Java and heap.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
Same Java and heap.
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
shipped.
**How it ran.** The first pass ran all four servers at 1 and 10 bots, but its Paper 10-bot run hit the
connection throttle (1 of 10 bots joined), and it was stopped after that. The 1-bot and 10-bot lines for
Horizon, vanilla and Fabric come from that pass, along with Paper's 1-bot line. A second pass with the
throttle off ran all four at 25 and 50 bots, then Paper at 10. All runs were on the same machine on the
same morning (02:57 to 06:02 UTC). `runs.jsonl` holds the 16 run lines used here.
`report-25-50.json` and `report-paper-10.json` are the second pass's reports. The first pass, stopped
early, wrote no report. Its lines come from its output.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
HORIZON_BENCH_BIN=~/.local/share/horizon/current \
python3 tools/server_bench.py --servers horizon,vanilla,paper,fabric --bots 1,10,25,50 --behaviour mixed \
--explore-speed 0.8 --warmup 300 --nice 19 \
--bots-via 'ssh <bots machine>' --bots-bin /path/to/horizon-bots --connect <this machine's address> \
--out four-way.json
## Next
- One pass per configuration ran, on a 4-thread laptop CPU. A rerun on a bigger machine (the desktop's
24 threads) would show which server uses more cores best, and give vanilla at 50 a second chance.
- Horizon's average tick: where its 1-player tick goes (13.7 ms against Paper's 10.3). That work is
perf-server-11's.
- 100 bots.
@@ -0,0 +1,870 @@
2026-10-19T02:57:31Z
ludothinkpad ok: 29.0 GB free, 4 threads 1% busy, 0 sessions
desktop offline
macmini busy: 28.7 GB free, 14 threads 89% busy, 1 sessions
macbook offline
2026-10-19T02:58:36Z
ludothinkpad ok: 28.3 GB free, 4 threads 20% busy, 0 sessions
desktop offline
macmini busy: 26.0 GB free, 14 threads 92% busy, 1 sessions
macbook offline
2026-10-19T02:59:40Z
ludothinkpad ok: 28.3 GB free, 4 threads 20% busy, 0 sessions
desktop offline
macmini busy: 26.8 GB free, 14 threads 96% busy, 1 sessions
macbook offline
2026-10-19T03:00:44Z
ludothinkpad ok: 28.3 GB free, 4 threads 15% busy, 0 sessions
desktop offline
macmini busy: 25.8 GB free, 14 threads 97% busy, 1 sessions
macbook offline
2026-10-19T03:01:48Z
ludothinkpad ok: 27.8 GB free, 4 threads 27% busy, 0 sessions
desktop offline
macmini ok: 26.8 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T03:02:52Z
ludothinkpad ok: 28.4 GB free, 4 threads 18% busy, 0 sessions
desktop offline
macmini busy: 25.7 GB free, 14 threads 93% busy, 1 sessions
macbook offline
2026-10-19T03:03:56Z
ludothinkpad ok: 28.4 GB free, 4 threads 14% busy, 0 sessions
desktop offline
macmini busy: 29.6 GB free, 14 threads 96% busy, 1 sessions
macbook offline
2026-10-19T03:05:00Z
ludothinkpad ok: 28.4 GB free, 4 threads 11% busy, 0 sessions
desktop offline
macmini ok: 29.4 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T03:06:04Z
ludothinkpad ok: 28.3 GB free, 4 threads 22% busy, 0 sessions
desktop offline
macmini ok: 26.8 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T03:07:09Z
ludothinkpad ok: 28.4 GB free, 4 threads 11% busy, 0 sessions
desktop offline
macmini busy: 26.1 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T03:08:13Z
ludothinkpad busy: 26.9 GB free, 4 threads 92% busy, 0 sessions
desktop offline
macmini ok: 26.4 GB free, 14 threads 76% busy, 1 sessions
macbook offline
2026-10-19T03:09:17Z
ludothinkpad ok: 25.8 GB free, 4 threads 19% busy, 0 sessions
desktop offline
macmini busy: 29.5 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T03:10:21Z
ludothinkpad ok: 25.8 GB free, 4 threads 49% busy, 0 sessions
desktop offline
macmini ok: 25.8 GB free, 14 threads 79% busy, 1 sessions
macbook offline
2026-10-19T03:11:25Z
ludothinkpad ok: 25.8 GB free, 4 threads 19% busy, 0 sessions
desktop offline
macmini busy: 26.7 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T03:12:29Z
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
desktop offline
macmini busy: 26.2 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T03:13:33Z
ludothinkpad ok: 25.8 GB free, 4 threads 17% busy, 0 sessions
desktop offline
macmini ok: 28.0 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T03:14:37Z
ludothinkpad ok: 25.8 GB free, 4 threads 17% busy, 0 sessions
desktop offline
macmini ok: 28.7 GB free, 14 threads 76% busy, 1 sessions
macbook offline
2026-10-19T03:15:41Z
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
desktop offline
macmini busy: 26.2 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T03:16:46Z
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
desktop offline
macmini busy: 25.0 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T03:17:50Z
ludothinkpad ok: 25.8 GB free, 4 threads 19% busy, 0 sessions
desktop offline
macmini ok: 25.6 GB free, 14 threads 78% busy, 1 sessions
macbook offline
2026-10-19T03:18:54Z
ludothinkpad ok: 26.7 GB free, 4 threads 71% busy, 0 sessions
desktop offline
macmini busy: 26.2 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T03:19:58Z
ludothinkpad ok: 25.1 GB free, 4 threads 16% busy, 0 sessions
desktop offline
macmini busy: 25.6 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T03:21:02Z
ludothinkpad ok: 25.1 GB free, 4 threads 15% busy, 0 sessions
desktop offline
macmini busy: 25.7 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T03:22:06Z
ludothinkpad ok: 25.1 GB free, 4 threads 12% busy, 0 sessions
desktop offline
macmini busy: 26.8 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T03:23:10Z
ludothinkpad ok: 25.1 GB free, 4 threads 12% busy, 0 sessions
desktop offline
macmini ok: 25.6 GB free, 14 threads 81% busy, 1 sessions
macbook offline
2026-10-19T03:24:14Z
ludothinkpad ok: 25.1 GB free, 4 threads 19% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T03:25:19Z
ludothinkpad ok: 25.1 GB free, 4 threads 9% busy, 0 sessions
desktop offline
macmini busy: 28.6 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T03:26:23Z
ludothinkpad ok: 25.1 GB free, 4 threads 13% busy, 0 sessions
desktop offline
macmini ok: 25.9 GB free, 14 threads 71% busy, 1 sessions
macbook offline
2026-10-19T03:27:27Z
ludothinkpad ok: 25.1 GB free, 4 threads 14% busy, 0 sessions
desktop offline
macmini busy: 26.0 GB free, 14 threads 94% busy, 1 sessions
macbook offline
2026-10-19T03:28:31Z
ludothinkpad ok: 25.1 GB free, 4 threads 13% busy, 0 sessions
desktop offline
macmini ok: 26.1 GB free, 14 threads 79% busy, 1 sessions
macbook offline
2026-10-19T03:29:35Z
ludothinkpad ok: 27.5 GB free, 4 threads 36% busy, 0 sessions
desktop offline
macmini ok: 25.5 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T03:30:39Z
ludothinkpad ok: 25.8 GB free, 4 threads 22% busy, 0 sessions
desktop offline
macmini ok: 26.2 GB free, 14 threads 73% busy, 1 sessions
macbook offline
2026-10-19T03:31:43Z
ludothinkpad ok: 25.8 GB free, 4 threads 20% busy, 0 sessions
desktop offline
macmini busy: 26.1 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T03:32:47Z
ludothinkpad ok: 25.8 GB free, 4 threads 21% busy, 0 sessions
desktop offline
macmini busy: 28.4 GB free, 14 threads 90% busy, 1 sessions
macbook offline
2026-10-19T03:33:52Z
ludothinkpad ok: 25.8 GB free, 4 threads 18% busy, 0 sessions
desktop offline
macmini ok: 26.9 GB free, 14 threads 84% busy, 2 sessions
macbook offline
2026-10-19T03:34:56Z
ludothinkpad ok: 25.8 GB free, 4 threads 14% busy, 0 sessions
desktop offline
macmini ok: 26.1 GB free, 14 threads 76% busy, 1 sessions
macbook offline
2026-10-19T03:36:00Z
ludothinkpad ok: 25.8 GB free, 4 threads 16% busy, 0 sessions
desktop offline
macmini ok: 27.4 GB free, 14 threads 78% busy, 1 sessions
macbook offline
2026-10-19T03:37:04Z
ludothinkpad ok: 25.8 GB free, 4 threads 15% busy, 0 sessions
desktop offline
macmini ok: 26.6 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T03:38:08Z
ludothinkpad ok: 25.8 GB free, 4 threads 14% busy, 0 sessions
desktop offline
macmini busy: 25.8 GB free, 14 threads 94% busy, 1 sessions
macbook offline
2026-10-19T03:39:12Z
ludothinkpad ok: 25.8 GB free, 4 threads 17% busy, 0 sessions
desktop offline
macmini busy: 27.1 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T03:40:16Z
ludothinkpad ok: 28.4 GB free, 4 threads 73% busy, 0 sessions
desktop offline
macmini ok: 25.5 GB free, 14 threads 78% busy, 1 sessions
macbook offline
2026-10-19T03:41:20Z
ludothinkpad ok: 28.3 GB free, 4 threads 41% busy, 0 sessions
desktop offline
macmini busy: 25.8 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T03:42:24Z
ludothinkpad ok: 28.3 GB free, 4 threads 24% busy, 0 sessions
desktop offline
macmini busy: 26.9 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T03:43:29Z
ludothinkpad ok: 28.3 GB free, 4 threads 26% busy, 0 sessions
desktop offline
macmini busy: 25.0 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T03:44:33Z
ludothinkpad ok: 28.2 GB free, 4 threads 22% busy, 0 sessions
desktop offline
macmini ok: 25.6 GB free, 14 threads 81% busy, 1 sessions
macbook offline
2026-10-19T03:45:37Z
ludothinkpad ok: 28.2 GB free, 4 threads 27% busy, 0 sessions
desktop offline
macmini ok: 27.8 GB free, 14 threads 76% busy, 1 sessions
macbook offline
2026-10-19T03:46:41Z
ludothinkpad ok: 28.1 GB free, 4 threads 37% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 81% busy, 1 sessions
macbook offline
2026-10-19T03:47:45Z
ludothinkpad ok: 28.1 GB free, 4 threads 21% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T03:48:49Z
ludothinkpad ok: 28.1 GB free, 4 threads 32% busy, 0 sessions
desktop offline
macmini ok: 26.2 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T03:49:53Z
ludothinkpad ok: 28.1 GB free, 4 threads 25% busy, 0 sessions
desktop offline
macmini busy: 25.7 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T03:50:57Z
ludothinkpad busy: 26.6 GB free, 4 threads 94% busy, 0 sessions
desktop offline
macmini ok: 25.1 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T03:52:01Z
ludothinkpad ok: 25.8 GB free, 4 threads 28% busy, 0 sessions
desktop offline
macmini busy: 25.1 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T03:53:06Z
ludothinkpad ok: 25.8 GB free, 4 threads 50% busy, 0 sessions
desktop offline
macmini busy: 26.4 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T03:54:10Z
ludothinkpad ok: 25.7 GB free, 4 threads 41% busy, 0 sessions
desktop offline
macmini busy: 25.8 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T03:55:14Z
ludothinkpad ok: 25.6 GB free, 4 threads 27% busy, 0 sessions
desktop offline
macmini busy: 31.4 GB free, 14 threads 90% busy, 1 sessions
macbook offline
2026-10-19T03:56:18Z
ludothinkpad ok: 25.6 GB free, 4 threads 26% busy, 0 sessions
desktop offline
macmini busy: 31.0 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T03:57:22Z
ludothinkpad ok: 25.6 GB free, 4 threads 33% busy, 0 sessions
desktop offline
macmini busy: 26.5 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T03:58:26Z
ludothinkpad ok: 25.5 GB free, 4 threads 45% busy, 0 sessions
desktop offline
macmini busy: 26.7 GB free, 14 threads 89% busy, 1 sessions
macbook offline
2026-10-19T03:59:30Z
ludothinkpad ok: 25.5 GB free, 4 threads 24% busy, 0 sessions
desktop offline
macmini ok: 25.6 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T04:00:34Z
ludothinkpad ok: 25.5 GB free, 4 threads 31% busy, 0 sessions
desktop offline
macmini ok: 25.0 GB free, 14 threads 81% busy, 1 sessions
macbook offline
2026-10-19T04:01:38Z
ludothinkpad busy: 27.0 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 28.3 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T04:02:42Z
ludothinkpad ok: 26.2 GB free, 4 threads 19% busy, 0 sessions
desktop offline
macmini busy: 26.5 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T04:03:47Z
ludothinkpad ok: 25.9 GB free, 4 threads 14% busy, 0 sessions
desktop offline
macmini ok: 27.9 GB free, 14 threads 78% busy, 1 sessions
macbook offline
2026-10-19T04:04:51Z
ludothinkpad ok: 25.7 GB free, 4 threads 11% busy, 0 sessions
desktop offline
macmini busy: 30.0 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T04:05:55Z
ludothinkpad ok: 25.3 GB free, 4 threads 10% busy, 0 sessions
desktop offline
macmini busy: 27.5 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T04:06:59Z
ludothinkpad ok: 25.3 GB free, 4 threads 13% busy, 0 sessions
desktop offline
macmini ok: 26.9 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T04:08:03Z
ludothinkpad ok: 25.3 GB free, 4 threads 12% busy, 0 sessions
desktop offline
macmini ok: 25.8 GB free, 14 threads 81% busy, 1 sessions
macbook offline
2026-10-19T04:09:07Z
ludothinkpad ok: 25.3 GB free, 4 threads 10% busy, 0 sessions
desktop offline
macmini busy: 26.9 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T04:10:11Z
ludothinkpad ok: 25.3 GB free, 4 threads 8% busy, 0 sessions
desktop offline
macmini busy: 26.6 GB free, 14 threads 92% busy, 1 sessions
macbook offline
2026-10-19T04:11:15Z
ludothinkpad ok: 25.3 GB free, 4 threads 9% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T04:12:20Z
ludothinkpad busy: 27.5 GB free, 4 threads 88% busy, 0 sessions
desktop offline
macmini busy: 26.5 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T04:13:24Z
ludothinkpad ok: 25.8 GB free, 4 threads 44% busy, 0 sessions
desktop offline
macmini busy: 27.6 GB free, 14 threads 90% busy, 1 sessions
macbook offline
2026-10-19T04:14:28Z
ludothinkpad ok: 25.7 GB free, 4 threads 28% busy, 0 sessions
desktop offline
macmini busy: 27.8 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T04:15:32Z
ludothinkpad ok: 25.7 GB free, 4 threads 26% busy, 0 sessions
desktop offline
macmini ok: 26.7 GB free, 14 threads 73% busy, 1 sessions
macbook offline
2026-10-19T04:16:36Z
ludothinkpad ok: 25.6 GB free, 4 threads 28% busy, 0 sessions
desktop offline
macmini ok: 29.3 GB free, 14 threads 80% busy, 1 sessions
macbook offline
2026-10-19T04:17:40Z
ludothinkpad ok: 25.6 GB free, 4 threads 32% busy, 0 sessions
desktop offline
macmini busy: 26.3 GB free, 14 threads 90% busy, 1 sessions
macbook offline
2026-10-19T04:18:44Z
ludothinkpad ok: 25.5 GB free, 4 threads 76% busy, 0 sessions
desktop offline
macmini ok: 28.4 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T04:19:48Z
ludothinkpad ok: 25.5 GB free, 4 threads 26% busy, 0 sessions
desktop offline
macmini ok: 30.4 GB free, 14 threads 79% busy, 1 sessions
macbook offline
2026-10-19T04:20:52Z
ludothinkpad ok: 25.5 GB free, 4 threads 26% busy, 0 sessions
desktop offline
macmini ok: 28.1 GB free, 14 threads 73% busy, 1 sessions
macbook offline
2026-10-19T04:21:57Z
ludothinkpad ok: 25.5 GB free, 4 threads 28% busy, 0 sessions
desktop offline
macmini ok: 31.0 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T04:23:01Z
ludothinkpad ok: 25.5 GB free, 4 threads 10% busy, 0 sessions
desktop offline
macmini ok: 31.7 GB free, 14 threads 83% busy, 1 sessions
macbook offline
2026-10-19T04:24:05Z
ludothinkpad ok: 28.3 GB free, 4 threads 41% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 71% busy, 1 sessions
macbook offline
2026-10-19T04:25:15Z
ludothinkpad ok: 28.8 GB free, 4 threads 4% busy, 0 sessions
desktop offline
macmini busy: 25.7 GB free, 14 threads 98% busy, 1 sessions
macbook offline
2026-10-19T04:26:19Z
ludothinkpad ok: 28.3 GB free, 4 threads 40% busy, 0 sessions
desktop offline
macmini ok: 32.1 GB free, 14 threads 65% busy, 1 sessions
macbook offline
2026-10-19T04:27:23Z
ludothinkpad ok: 28.2 GB free, 4 threads 41% busy, 0 sessions
desktop offline
macmini busy: 29.4 GB free, 14 threads 89% busy, 1 sessions
macbook offline
2026-10-19T04:28:27Z
ludothinkpad ok: 28.2 GB free, 4 threads 44% busy, 0 sessions
desktop offline
macmini ok: 28.9 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T04:29:32Z
ludothinkpad ok: 28.1 GB free, 4 threads 33% busy, 0 sessions
desktop offline
macmini ok: 25.3 GB free, 14 threads 71% busy, 1 sessions
macbook offline
2026-10-19T04:30:36Z
ludothinkpad ok: 28.1 GB free, 4 threads 31% busy, 0 sessions
desktop offline
macmini ok: 30.0 GB free, 14 threads 63% busy, 1 sessions
macbook offline
2026-10-19T04:31:40Z
ludothinkpad ok: 28.0 GB free, 4 threads 40% busy, 0 sessions
desktop offline
macmini ok: 27.4 GB free, 14 threads 73% busy, 1 sessions
macbook offline
2026-10-19T04:32:44Z
ludothinkpad ok: 28.0 GB free, 4 threads 35% busy, 0 sessions
desktop offline
macmini ok: 29.3 GB free, 14 threads 51% busy, 1 sessions
macbook offline
2026-10-19T04:33:48Z
ludothinkpad ok: 28.0 GB free, 4 threads 31% busy, 0 sessions
desktop offline
macmini ok: 27.5 GB free, 14 threads 67% busy, 1 sessions
macbook offline
2026-10-19T04:34:52Z
ludothinkpad ok: 28.0 GB free, 4 threads 35% busy, 0 sessions
desktop offline
macmini ok: 26.6 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T04:35:56Z
ludothinkpad ok: 27.9 GB free, 4 threads 34% busy, 0 sessions
desktop offline
macmini ok: 28.9 GB free, 14 threads 66% busy, 1 sessions
macbook offline
2026-10-19T04:37:00Z
ludothinkpad busy: 25.8 GB free, 4 threads 98% busy, 0 sessions
desktop offline
macmini ok: 26.9 GB free, 14 threads 75% busy, 1 sessions
macbook offline
2026-10-19T04:38:04Z
ludothinkpad ok: 25.7 GB free, 4 threads 64% busy, 0 sessions
desktop offline
macmini ok: 25.6 GB free, 14 threads 74% busy, 1 sessions
macbook offline
2026-10-19T04:39:09Z
ludothinkpad ok: 25.6 GB free, 4 threads 64% busy, 0 sessions
desktop offline
macmini ok: 26.8 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T04:40:13Z
ludothinkpad ok: 25.5 GB free, 4 threads 51% busy, 0 sessions
desktop offline
macmini ok: 26.3 GB free, 14 threads 70% busy, 1 sessions
macbook offline
2026-10-19T04:41:17Z
ludothinkpad ok: 25.4 GB free, 4 threads 62% busy, 0 sessions
desktop offline
macmini ok: 26.3 GB free, 14 threads 83% busy, 1 sessions
macbook offline
2026-10-19T04:42:21Z
ludothinkpad ok: 25.4 GB free, 4 threads 47% busy, 0 sessions
desktop offline
macmini ok: 26.2 GB free, 14 threads 68% busy, 1 sessions
macbook offline
2026-10-19T04:43:25Z
ludothinkpad ok: 25.4 GB free, 4 threads 67% busy, 0 sessions
desktop offline
macmini busy: 26.9 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T04:44:29Z
ludothinkpad ok: 25.3 GB free, 4 threads 64% busy, 0 sessions
desktop offline
macmini ok: 30.4 GB free, 14 threads 79% busy, 1 sessions
macbook offline
2026-10-19T04:45:33Z
ludothinkpad ok: 25.2 GB free, 4 threads 66% busy, 0 sessions
desktop offline
macmini ok: 25.0 GB free, 14 threads 69% busy, 1 sessions
macbook offline
2026-10-19T04:46:37Z
ludothinkpad ok: 25.1 GB free, 4 threads 48% busy, 0 sessions
desktop offline
macmini busy: 26.4 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T04:47:42Z
ludothinkpad ok: 27.5 GB free, 4 threads 69% busy, 0 sessions
desktop offline
macmini ok: 28.0 GB free, 14 threads 83% busy, 1 sessions
macbook offline
2026-10-19T04:48:46Z
ludothinkpad ok: 25.3 GB free, 4 threads 83% busy, 0 sessions
desktop offline
macmini busy: 25.6 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T04:49:50Z
ludothinkpad ok: 25.3 GB free, 4 threads 55% busy, 0 sessions
desktop offline
macmini busy: 25.3 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T04:50:54Z
ludothinkpad ok: 25.2 GB free, 4 threads 51% busy, 0 sessions
desktop offline
macmini ok: 30.9 GB free, 14 threads 74% busy, 1 sessions
macbook offline
2026-10-19T04:51:58Z
ludothinkpad ok: 25.2 GB free, 4 threads 57% busy, 0 sessions
desktop offline
macmini ok: 29.2 GB free, 14 threads 83% busy, 1 sessions
macbook offline
2026-10-19T04:53:02Z
ludothinkpad ok: 25.1 GB free, 4 threads 59% busy, 0 sessions
desktop offline
macmini busy: 26.2 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T04:54:06Z
ludothinkpad ok: 25.1 GB free, 4 threads 38% busy, 0 sessions
desktop offline
macmini busy: 25.4 GB free, 14 threads 90% busy, 2 sessions
macbook offline
2026-10-19T04:55:10Z
ludothinkpad ok: 25.1 GB free, 4 threads 43% busy, 0 sessions
desktop offline
macmini busy: 25.4 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T04:56:15Z
ludothinkpad ok: 25.1 GB free, 4 threads 42% busy, 0 sessions
desktop offline
macmini ok: 27.6 GB free, 14 threads 80% busy, 1 sessions
macbook offline
2026-10-19T04:57:19Z
ludothinkpad ok: 25.0 GB free, 4 threads 45% busy, 0 sessions
desktop offline
macmini ok: 27.0 GB free, 14 threads 67% busy, 1 sessions
macbook offline
2026-10-19T04:58:23Z
ludothinkpad ok: 25.0 GB free, 4 threads 48% busy, 0 sessions
desktop offline
macmini ok: 27.4 GB free, 14 threads 74% busy, 1 sessions
macbook offline
2026-10-19T04:59:27Z
ludothinkpad busy: 26.6 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 27.7 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T05:00:31Z
ludothinkpad ok: 25.6 GB free, 4 threads 53% busy, 0 sessions
desktop offline
macmini ok: 32.2 GB free, 14 threads 64% busy, 1 sessions
macbook offline
2026-10-19T05:01:35Z
ludothinkpad ok: 25.7 GB free, 4 threads 54% busy, 0 sessions
desktop offline
macmini ok: 26.1 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T05:02:39Z
ludothinkpad ok: 25.6 GB free, 4 threads 59% busy, 0 sessions
desktop offline
macmini ok: 25.4 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T05:03:43Z
ludothinkpad ok: 25.6 GB free, 4 threads 55% busy, 0 sessions
desktop offline
macmini busy: 25.3 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T05:04:48Z
ludothinkpad busy: 25.0 GB free, 4 threads 96% busy, 0 sessions
desktop offline
macmini ok: 26.6 GB free, 14 threads 79% busy, 1 sessions
macbook offline
2026-10-19T05:05:52Z
ludothinkpad ok: 25.4 GB free, 4 threads 51% busy, 0 sessions
desktop offline
macmini ok: 27.8 GB free, 14 threads 84% busy, 1 sessions
macbook offline
2026-10-19T05:06:56Z
ludothinkpad ok: 25.4 GB free, 4 threads 46% busy, 0 sessions
desktop offline
macmini busy: 25.9 GB free, 14 threads 92% busy, 1 sessions
macbook offline
2026-10-19T05:08:00Z
ludothinkpad ok: 25.4 GB free, 4 threads 53% busy, 0 sessions
desktop offline
macmini busy: 29.0 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T05:09:04Z
ludothinkpad ok: 25.1 GB free, 4 threads 64% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 81% busy, 1 sessions
macbook offline
2026-10-19T05:10:08Z
ludothinkpad ok: 28.6 GB free, 4 threads 28% busy, 0 sessions
desktop offline
macmini ok: 26.8 GB free, 14 threads 78% busy, 1 sessions
macbook offline
2026-10-19T05:11:12Z
ludothinkpad ok: 28.2 GB free, 4 threads 48% busy, 0 sessions
desktop offline
macmini busy: 28.5 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T05:12:16Z
ludothinkpad ok: 28.2 GB free, 4 threads 52% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 74% busy, 1 sessions
macbook offline
2026-10-19T05:13:21Z
ludothinkpad ok: 28.1 GB free, 4 threads 41% busy, 0 sessions
desktop offline
macmini busy: 25.9 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T05:14:25Z
ludothinkpad ok: 28.1 GB free, 4 threads 56% busy, 0 sessions
desktop offline
macmini busy: 26.7 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T05:15:29Z
ludothinkpad ok: 28.0 GB free, 4 threads 50% busy, 0 sessions
desktop offline
macmini busy: 25.4 GB free, 14 threads 90% busy, 1 sessions
macbook offline
2026-10-19T05:16:33Z
ludothinkpad ok: 27.9 GB free, 4 threads 47% busy, 0 sessions
desktop offline
macmini ok: 29.1 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T05:17:37Z
ludothinkpad ok: 27.9 GB free, 4 threads 61% busy, 0 sessions
desktop offline
macmini ok: 27.5 GB free, 14 threads 75% busy, 1 sessions
macbook offline
2026-10-19T05:18:41Z
ludothinkpad ok: 27.9 GB free, 4 threads 68% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 76% busy, 1 sessions
macbook offline
2026-10-19T05:19:45Z
ludothinkpad ok: 27.8 GB free, 4 threads 45% busy, 0 sessions
desktop offline
macmini busy: 27.5 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T05:20:49Z
ludothinkpad ok: 27.5 GB free, 4 threads 60% busy, 0 sessions
desktop offline
macmini busy: 26.6 GB free, 14 threads 95% busy, 1 sessions
macbook offline
2026-10-19T05:21:53Z
ludothinkpad busy: 26.7 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 29.8 GB free, 14 threads 72% busy, 1 sessions
macbook offline
2026-10-19T05:22:58Z
ludothinkpad busy: 25.7 GB free, 4 threads 97% busy, 0 sessions
desktop offline
macmini busy: 26.6 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T05:24:02Z
ludothinkpad busy: 25.4 GB free, 4 threads 90% busy, 0 sessions
desktop offline
macmini busy: 28.2 GB free, 14 threads 85% busy, 1 sessions
macbook offline
2026-10-19T05:25:06Z
ludothinkpad ok: 25.3 GB free, 4 threads 73% busy, 0 sessions
desktop offline
macmini busy: 27.8 GB free, 14 threads 89% busy, 1 sessions
macbook offline
2026-10-19T05:26:10Z
ludothinkpad ok: 25.2 GB free, 4 threads 69% busy, 0 sessions
desktop offline
macmini ok: 28.8 GB free, 14 threads 74% busy, 1 sessions
macbook offline
2026-10-19T05:27:14Z
ludothinkpad ok: 25.2 GB free, 4 threads 82% busy, 0 sessions
desktop offline
macmini ok: 29.6 GB free, 14 threads 80% busy, 1 sessions
macbook offline
2026-10-19T05:28:18Z
ludothinkpad ok: 25.2 GB free, 4 threads 66% busy, 0 sessions
desktop offline
macmini busy: 33.7 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T05:29:22Z
ludothinkpad busy: 26.4 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 33.7 GB free, 14 threads 81% busy, 1 sessions
macbook offline
2026-10-19T05:30:27Z
ludothinkpad ok: 25.0 GB free, 4 threads 73% busy, 0 sessions
desktop offline
macmini busy: 31.8 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T05:31:31Z
ludothinkpad ok: 24.9 GB free, 4 threads 66% busy, 0 sessions
desktop offline
macmini ok: 29.7 GB free, 14 threads 77% busy, 1 sessions
macbook offline
2026-10-19T05:32:35Z
ludothinkpad busy: 25.0 GB free, 4 threads 94% busy, 0 sessions
desktop offline
macmini busy: 27.8 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T05:33:39Z
ludothinkpad ok: 24.9 GB free, 4 threads 70% busy, 0 sessions
desktop offline
macmini busy: 25.7 GB free, 14 threads 90% busy, 1 sessions
macbook offline
2026-10-19T05:34:43Z
ludothinkpad busy: 24.9 GB free, 4 threads 88% busy, 0 sessions
desktop offline
macmini busy: 32.7 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T05:35:47Z
ludothinkpad ok: 24.9 GB free, 4 threads 72% busy, 0 sessions
desktop offline
macmini busy: 33.3 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T05:36:51Z
ludothinkpad ok: 24.8 GB free, 4 threads 76% busy, 0 sessions
desktop offline
macmini ok: 32.6 GB free, 14 threads 83% busy, 1 sessions
macbook offline
2026-10-19T05:37:55Z
ludothinkpad ok: 24.7 GB free, 4 threads 71% busy, 0 sessions
desktop offline
macmini ok: 31.5 GB free, 14 threads 65% busy, 1 sessions
macbook offline
2026-10-19T05:39:00Z
ludothinkpad busy: 24.7 GB free, 4 threads 92% busy, 0 sessions
desktop offline
macmini ok: 29.7 GB free, 14 threads 69% busy, 1 sessions
macbook offline
2026-10-19T05:40:04Z
ludothinkpad ok: 28.6 GB free, 4 threads 55% busy, 0 sessions
desktop offline
macmini ok: 30.0 GB free, 14 threads 73% busy, 1 sessions
macbook offline
2026-10-19T05:41:08Z
ludothinkpad busy: 25.6 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 28.3 GB free, 14 threads 88% busy, 1 sessions
macbook offline
2026-10-19T05:42:12Z
ludothinkpad busy: 25.5 GB free, 4 threads 97% busy, 0 sessions
desktop offline
macmini ok: 27.6 GB free, 14 threads 80% busy, 1 sessions
macbook offline
2026-10-19T05:43:16Z
ludothinkpad busy: 25.3 GB free, 4 threads 98% busy, 0 sessions
desktop offline
macmini ok: 29.5 GB free, 14 threads 76% busy, 1 sessions
macbook offline
2026-10-19T05:44:20Z
ludothinkpad ok: 25.2 GB free, 4 threads 85% busy, 0 sessions
desktop offline
macmini ok: 28.6 GB free, 14 threads 64% busy, 1 sessions
macbook offline
2026-10-19T05:45:25Z
ludothinkpad ok: 25.2 GB free, 4 threads 74% busy, 0 sessions
desktop offline
macmini ok: 26.6 GB free, 14 threads 78% busy, 1 sessions
macbook offline
2026-10-19T05:46:29Z
ludothinkpad ok: 25.2 GB free, 4 threads 68% busy, 0 sessions
desktop offline
macmini ok: 26.5 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T05:47:33Z
ludothinkpad ok: 24.5 GB free, 4 threads 84% busy, 0 sessions
desktop offline
macmini busy: 26.5 GB free, 14 threads 90% busy, 1 sessions
macbook offline
2026-10-19T05:48:37Z
ludothinkpad busy: 24.4 GB free, 4 threads 86% busy, 0 sessions
desktop offline
macmini ok: 30.6 GB free, 14 threads 61% busy, 1 sessions
macbook offline
2026-10-19T05:49:41Z
ludothinkpad ok: 24.2 GB free, 4 threads 80% busy, 0 sessions
desktop offline
macmini busy: 26.9 GB free, 14 threads 85% busy, 3 sessions
macbook offline
2026-10-19T05:50:45Z
ludothinkpad ok: 24.2 GB free, 4 threads 72% busy, 0 sessions
desktop offline
macmini busy: 25.7 GB free, 14 threads 89% busy, 1 sessions
macbook offline
2026-10-19T05:51:49Z
ludothinkpad ok: 24.1 GB free, 4 threads 17% busy, 0 sessions
desktop offline
macmini busy: 29.1 GB free, 14 threads 87% busy, 1 sessions
macbook offline
2026-10-19T05:52:54Z
ludothinkpad ok: 27.5 GB free, 4 threads 83% busy, 0 sessions
desktop offline
macmini ok: 26.9 GB free, 14 threads 76% busy, 1 sessions
macbook offline
2026-10-19T05:53:58Z
ludothinkpad ok: 25.8 GB free, 4 threads 56% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 82% busy, 1 sessions
macbook offline
2026-10-19T05:55:02Z
ludothinkpad ok: 25.8 GB free, 4 threads 35% busy, 0 sessions
desktop offline
macmini busy: 27.2 GB free, 14 threads 95% busy, 1 sessions
macbook offline
2026-10-19T05:56:06Z
ludothinkpad ok: 25.0 GB free, 4 threads 52% busy, 0 sessions
desktop offline
macmini ok: 29.6 GB free, 14 threads 56% busy, 1 sessions
macbook offline
2026-10-19T05:57:10Z
ludothinkpad ok: 25.1 GB free, 4 threads 30% busy, 0 sessions
desktop offline
macmini busy: 26.2 GB free, 14 threads 91% busy, 1 sessions
macbook offline
2026-10-19T05:58:14Z
ludothinkpad ok: 25.1 GB free, 4 threads 36% busy, 0 sessions
desktop offline
macmini busy: 27.8 GB free, 14 threads 94% busy, 1 sessions
macbook offline
2026-10-19T05:59:18Z
ludothinkpad ok: 25.0 GB free, 4 threads 27% busy, 0 sessions
desktop offline
macmini ok: 30.3 GB free, 14 threads 51% busy, 1 sessions
macbook offline
2026-10-19T06:00:22Z
ludothinkpad ok: 25.0 GB free, 4 threads 24% busy, 0 sessions
desktop offline
macmini busy: 26.7 GB free, 14 threads 86% busy, 1 sessions
macbook offline
2026-10-19T06:01:26Z
ludothinkpad ok: 24.4 GB free, 4 threads 38% busy, 0 sessions
desktop offline
macmini busy: 27.1 GB free, 14 threads 89% busy, 1 sessions
macbook offline
2026-10-19T06:02:31Z
ludothinkpad ok: 25.2 GB free, 4 threads 27% busy, 0 sessions
desktop offline
macmini ok: 29.1 GB free, 14 threads 55% busy, 1 sessions
macbook offline
@@ -0,0 +1,437 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-19T05:52:44+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "38505a07b5334f3ea2c467efb4a00e5557f910f8",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"versions": {
"paper_build": 205,
"fabric_loader": "0.19.5",
"lithium": "mc26.3-0.26.2-fabric"
},
"settings": {
"servers": "horizon,vanilla,paper,fabric",
"bots": "25,50",
"behaviour": "mixed",
"join_every": 1.0,
"warmup": 300,
"seconds": 300,
"seed": 12345,
"explore_speed": 0.8,
"gamerule": "",
"heap": "4g",
"out": "/home/ludovic/sh10/out/mixed-equal-25-50.json",
"nice": 19,
"bots_via": "ssh macmini",
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/sh10-bench/target/release/horizon-bots",
"connect": "the ThinkPad's tailnet address",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 8.51,
"mspt": {
"average": 30.92,
"p50": 27.88,
"p95": 48.25,
"p99": 72.0
},
"mspt_worst_p99": 121.1,
"tps": 20.0,
"rss_mb": {
"mean": 1012.01,
"max": 1121.3
},
"cpu_percent": 98.57,
"chunks_per_second": 155.81,
"unique_per_second": 5.78,
"forgotten_per_second": 155.62,
"waited_ticks_per_second": 0.0,
"chunks_total": 106268,
"chunks_per_bot": {
"walk": 6506.33,
"explore": 1261.6,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 702.13,
"explore": 1184.4,
"idle": 473.0
},
"resent": 0,
"pulled_back": 3008,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 15.47,
"mspt": {
"average": 37.35,
"p50": 35.64,
"p95": 48.2,
"p99": 82.66
},
"mspt_worst_p99": 579.8,
"tps": 20.0,
"rss_mb": {
"mean": 3935.5,
"max": 4157.4
},
"cpu_percent": 191.44,
"chunks_per_second": 158.52,
"unique_per_second": 5.71,
"forgotten_per_second": 158.39,
"waited_ticks_per_second": 0.0,
"chunks_total": 103148,
"chunks_per_bot": {
"walk": 6306.87,
"explore": 1236.0,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 704.2,
"explore": 1160.2,
"idle": 473.0
},
"resent": 0,
"pulled_back": 3762,
"moved_too_quickly": 11,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "paper",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 18.95,
"mspt": {
"average": 28.3,
"p50": 23.7,
"p95": 61.68,
"p99": 90.4
},
"mspt_worst_p99": 124.3,
"tps": 20.0,
"rss_mb": {
"mean": 4160.34,
"max": 4246.4
},
"cpu_percent": 124.24,
"chunks_per_second": 156.35,
"unique_per_second": 5.72,
"forgotten_per_second": 156.25,
"waited_ticks_per_second": 0.0,
"chunks_total": 102715,
"chunks_per_bot": {
"walk": 6280.4,
"explore": 1228.8,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 703.07,
"explore": 1155.6,
"idle": 473.0
},
"resent": 0,
"pulled_back": 4337,
"moved_too_quickly": 9,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "fabric",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 17.45,
"mspt": {
"average": 31.44,
"p50": 28.33,
"p95": 52.45,
"p99": 93.35
},
"mspt_worst_p99": 409.3,
"tps": 20.0,
"rss_mb": {
"mean": 3884.61,
"max": 4186.4
},
"cpu_percent": 174.04,
"chunks_per_second": 158.89,
"unique_per_second": 5.75,
"forgotten_per_second": 158.54,
"waited_ticks_per_second": 0.0,
"chunks_total": 103913,
"chunks_per_bot": {
"walk": 6356.8,
"explore": 1239.2,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 704.27,
"explore": 1163.0,
"idle": 473.0
},
"resent": 0,
"pulled_back": 4194,
"moved_too_quickly": 11,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 59
},
{
"server": "horizon",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 8.31,
"mspt": {
"average": 47.83,
"p50": 46.02,
"p95": 62.42,
"p99": 73.21
},
"mspt_worst_p99": 133.0,
"tps": 20.0,
"rss_mb": {
"mean": 1112.46,
"max": 1296.4
},
"cpu_percent": 143.22,
"chunks_per_second": 302.68,
"unique_per_second": 11.02,
"forgotten_per_second": 303.15,
"waited_ticks_per_second": 0.0,
"chunks_total": 211296,
"chunks_per_bot": {
"walk": 6468.47,
"explore": 1251.2,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 701.67,
"explore": 1180.5,
"idle": 473.0
},
"resent": 0,
"pulled_back": 8562,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 15.45,
"error": "[Errno 32] Broken pipe"
},
{
"server": "paper",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 19.35,
"mspt": {
"average": 46.86,
"p50": 45.11,
"p95": 61.16,
"p99": 109.72
},
"mspt_worst_p99": 213.4,
"tps": 20.0,
"rss_mb": {
"mean": 4353.02,
"max": 4516.1
},
"cpu_percent": 210.21,
"chunks_per_second": 310.51,
"unique_per_second": 11.16,
"forgotten_per_second": 310.64,
"waited_ticks_per_second": 0.0,
"chunks_total": 209303,
"chunks_per_bot": {
"walk": 6411.0,
"explore": 1224.3,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 703.47,
"explore": 1155.8,
"idle": 473.0
},
"resent": 0,
"pulled_back": 7344,
"moved_too_quickly": 35,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "fabric",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 17.45,
"mspt": {
"average": 58.3,
"p50": 54.73,
"p95": 83.06,
"p99": 174.37
},
"mspt_worst_p99": 887.6,
"tps": 17.15,
"rss_mb": {
"mean": 4521.47,
"max": 4744.2
},
"cpu_percent": 236.04,
"chunks_per_second": 311.52,
"unique_per_second": 11.28,
"forgotten_per_second": 311.74,
"waited_ticks_per_second": 0.0,
"chunks_total": 206965,
"chunks_per_bot": {
"walk": 6331.7,
"explore": 1228.4,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 703.3,
"explore": 1160.3,
"idle": 473.0
},
"resent": 0,
"pulled_back": 6350,
"moved_too_quickly": 46,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 58
}
],
"work": [
{
"behaviour": "mixed",
"bots": 25,
"servers": {
"horizon": {
"chunks_per_second": {
"value": 155.81,
"share": 0.981
},
"unique_per_second": {
"value": 5.78,
"share": 1.0
},
"waited_ticks_per_second": 0.0
},
"vanilla": {
"chunks_per_second": {
"value": 158.52,
"share": 0.998
},
"unique_per_second": {
"value": 5.71,
"share": 0.988
},
"waited_ticks_per_second": 0.0
},
"paper": {
"chunks_per_second": {
"value": 156.35,
"share": 0.984
},
"unique_per_second": {
"value": 5.72,
"share": 0.99
},
"waited_ticks_per_second": 0.0
},
"fabric": {
"chunks_per_second": {
"value": 158.89,
"share": 1.0
},
"unique_per_second": {
"value": 5.75,
"share": 0.995
},
"waited_ticks_per_second": 0.0
}
},
"equal": true
},
{
"behaviour": "mixed",
"bots": 50,
"servers": {
"horizon": {
"chunks_per_second": {
"value": 302.68,
"share": 0.972
},
"unique_per_second": {
"value": 11.02,
"share": 0.977
},
"waited_ticks_per_second": 0.0
},
"paper": {
"chunks_per_second": {
"value": 310.51,
"share": 0.997
},
"unique_per_second": {
"value": 11.16,
"share": 0.989
},
"waited_ticks_per_second": 0.0
},
"fabric": {
"chunks_per_second": {
"value": 311.52,
"share": 1.0
},
"unique_per_second": {
"value": 11.28,
"share": 1.0
},
"waited_ticks_per_second": 0.0
}
},
"equal": true
}
]
}
@@ -0,0 +1,90 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-19T06:03:34+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4
},
"commit": "38505a07b5334f3ea2c467efb4a00e5557f910f8",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"versions": {
"paper_build": 205
},
"settings": {
"servers": "paper",
"bots": "10",
"behaviour": "mixed",
"join_every": 1.0,
"warmup": 300,
"seconds": 300,
"seed": 12345,
"explore_speed": 0.8,
"gamerule": "",
"heap": "4g",
"out": "/home/ludovic/sh10/out/mixed-equal-paper-10.json",
"nice": 19,
"bots_via": "ssh macmini",
"bots_bin": "/Users/ludovicmarie/code/horizon/.claude/worktrees/sh10-bench/target/release/horizon-bots",
"connect": "the ThinkPad's tailnet address",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "paper",
"bots": 10,
"behaviour": "mixed",
"start_seconds": 20.48,
"mspt": {
"average": 20.64,
"p50": 18.1,
"p95": 35.94,
"p99": 56.29
},
"mspt_worst_p99": 166.9,
"tps": 20.0,
"rss_mb": {
"mean": 4069.94,
"max": 4080.2
},
"cpu_percent": 73.56,
"chunks_per_second": 60.76,
"unique_per_second": 1.85,
"forgotten_per_second": 60.96,
"waited_ticks_per_second": 0.0,
"chunks_total": 40348,
"chunks_per_bot": {
"walk": 6176.67,
"explore": 1171.0,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 701.0,
"explore": 1091.5,
"idle": 473.0
},
"resent": 0,
"pulled_back": 3974,
"moved_too_quickly": 7,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 59
}
],
"work": []
}
@@ -0,0 +1,16 @@
{"server": "horizon", "bots": 1, "behaviour": "mixed", "start_seconds": 8.31, "mspt": {"average": 13.7, "p50": 14.18, "p95": 19.06, "p99": 27.38}, "mspt_worst_p99": 43.4, "tps": 20.0, "rss_mb": {"mean": 715.63, "max": 719.6}, "cpu_percent": 46.22, "chunks_per_second": 9.94, "unique_per_second": 0.01, "forgotten_per_second": 9.89, "waited_ticks_per_second": 0.0, "chunks_total": 6280, "chunks_per_bot": {"walk": 6280.0}, "unique_chunks_per_bot": {"walk": 700.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
{"server": "vanilla", "bots": 1, "behaviour": "mixed", "start_seconds": 14.85, "mspt": {"average": 17.31, "p50": 18.3, "p95": 24.68, "p99": 36.43}, "mspt_worst_p99": 71.0, "tps": 20.0, "rss_mb": {"mean": 3442.51, "max": 3444.5}, "cpu_percent": 55.03, "chunks_per_second": 9.99, "unique_per_second": 0.0, "forgotten_per_second": 9.95, "waited_ticks_per_second": 0.0, "chunks_total": 6210, "chunks_per_bot": {"walk": 6210.0}, "unique_chunks_per_bot": {"walk": 700.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
{"server": "paper", "bots": 1, "behaviour": "mixed", "start_seconds": 22.27, "mspt": {"average": 10.31, "p50": 10.4, "p95": 13.9, "p99": 22.5}, "mspt_worst_p99": 81.2, "tps": 20.0, "rss_mb": {"mean": 4239.22, "max": 4245.1}, "cpu_percent": 31.68, "chunks_per_second": 9.81, "unique_per_second": 0.02, "forgotten_per_second": 9.77, "waited_ticks_per_second": 0.0, "chunks_total": 6049, "chunks_per_bot": {"walk": 6049.0}, "unique_chunks_per_bot": {"walk": 698.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
{"server": "fabric", "bots": 1, "behaviour": "mixed", "start_seconds": 17.55, "mspt": {"average": 12.46, "p50": 12.62, "p95": 17.69, "p99": 32.74}, "mspt_worst_p99": 70.1, "tps": 20.0, "rss_mb": {"mean": 3428.55, "max": 3430.2}, "cpu_percent": 45.86, "chunks_per_second": 9.98, "unique_per_second": 0.0, "forgotten_per_second": 9.93, "waited_ticks_per_second": 0.0, "chunks_total": 6188, "chunks_per_bot": {"walk": 6188.0}, "unique_chunks_per_bot": {"walk": 700.0}, "resent": 0, "pulled_back": 0, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 1, "bots_failed": [], "tick_queries": 58}
{"server": "horizon", "bots": 10, "behaviour": "mixed", "start_seconds": 8.21, "mspt": {"average": 22.93, "p50": 20.03, "p95": 39.83, "p99": 53.33}, "mspt_worst_p99": 89.5, "tps": 20.0, "rss_mb": {"mean": 922.28, "max": 973.7}, "cpu_percent": 66.6, "chunks_per_second": 61.21, "unique_per_second": 1.78, "forgotten_per_second": 61.34, "waited_ticks_per_second": 0.0, "chunks_total": 42095, "chunks_per_bot": {"walk": 6460.0, "explore": 1194.5, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 702.17, "explore": 1112.5, "idle": 473.0}, "resent": 0, "pulled_back": 3702, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 58}
{"server": "vanilla", "bots": 10, "behaviour": "mixed", "start_seconds": 14.65, "mspt": {"average": 22.94, "p50": 20.61, "p95": 36.92, "p99": 56.48}, "mspt_worst_p99": 196.6, "tps": 20.0, "rss_mb": {"mean": 3706.57, "max": 3761.6}, "cpu_percent": 100.83, "chunks_per_second": 61.63, "unique_per_second": 1.84, "forgotten_per_second": 61.68, "waited_ticks_per_second": 0.0, "chunks_total": 40938, "chunks_per_bot": {"walk": 6272.17, "explore": 1179.5, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 702.0, "explore": 1097.0, "idle": 473.0}, "resent": 0, "pulled_back": 3370, "moved_too_quickly": 7, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 59}
{"server": "paper", "bots": 10, "behaviour": "mixed", "start_seconds": 20.48, "mspt": {"average": 20.64, "p50": 18.1, "p95": 35.94, "p99": 56.29}, "mspt_worst_p99": 166.9, "tps": 20.0, "rss_mb": {"mean": 4069.94, "max": 4080.2}, "cpu_percent": 73.56, "chunks_per_second": 60.76, "unique_per_second": 1.85, "forgotten_per_second": 60.96, "waited_ticks_per_second": 0.0, "chunks_total": 40348, "chunks_per_bot": {"walk": 6176.67, "explore": 1171.0, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 701.0, "explore": 1091.5, "idle": 473.0}, "resent": 0, "pulled_back": 3974, "moved_too_quickly": 7, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 59}
{"server": "fabric", "bots": 10, "behaviour": "mixed", "start_seconds": 16.64, "mspt": {"average": 20.18, "p50": 17.63, "p95": 37.04, "p99": 56.08}, "mspt_worst_p99": 217.9, "tps": 20.0, "rss_mb": {"mean": 3732.43, "max": 3776.5}, "cpu_percent": 94.48, "chunks_per_second": 61.65, "unique_per_second": 1.84, "forgotten_per_second": 61.76, "waited_ticks_per_second": 0.0, "chunks_total": 41055, "chunks_per_bot": {"walk": 6291.17, "explore": 1181.0, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 701.83, "explore": 1098.0, "idle": 473.0}, "resent": 0, "pulled_back": 4002, "moved_too_quickly": 3, "moved_wrongly": 0, "bots_joined": 10, "bots_failed": [], "tick_queries": 59}
{"server": "horizon", "bots": 25, "behaviour": "mixed", "start_seconds": 8.51, "mspt": {"average": 30.92, "p50": 27.88, "p95": 48.25, "p99": 72.0}, "mspt_worst_p99": 121.1, "tps": 20.0, "rss_mb": {"mean": 1012.01, "max": 1121.3}, "cpu_percent": 98.57, "chunks_per_second": 155.81, "unique_per_second": 5.78, "forgotten_per_second": 155.62, "waited_ticks_per_second": 0.0, "chunks_total": 106268, "chunks_per_bot": {"walk": 6506.33, "explore": 1261.6, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 702.13, "explore": 1184.4, "idle": 473.0}, "resent": 0, "pulled_back": 3008, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 58}
{"server": "vanilla", "bots": 25, "behaviour": "mixed", "start_seconds": 15.47, "mspt": {"average": 37.35, "p50": 35.64, "p95": 48.2, "p99": 82.66}, "mspt_worst_p99": 579.8, "tps": 20.0, "rss_mb": {"mean": 3935.5, "max": 4157.4}, "cpu_percent": 191.44, "chunks_per_second": 158.52, "unique_per_second": 5.71, "forgotten_per_second": 158.39, "waited_ticks_per_second": 0.0, "chunks_total": 103148, "chunks_per_bot": {"walk": 6306.87, "explore": 1236.0, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 704.2, "explore": 1160.2, "idle": 473.0}, "resent": 0, "pulled_back": 3762, "moved_too_quickly": 11, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 59}
{"server": "paper", "bots": 25, "behaviour": "mixed", "start_seconds": 18.95, "mspt": {"average": 28.3, "p50": 23.7, "p95": 61.68, "p99": 90.4}, "mspt_worst_p99": 124.3, "tps": 20.0, "rss_mb": {"mean": 4160.34, "max": 4246.4}, "cpu_percent": 124.24, "chunks_per_second": 156.35, "unique_per_second": 5.72, "forgotten_per_second": 156.25, "waited_ticks_per_second": 0.0, "chunks_total": 102715, "chunks_per_bot": {"walk": 6280.4, "explore": 1228.8, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 703.07, "explore": 1155.6, "idle": 473.0}, "resent": 0, "pulled_back": 4337, "moved_too_quickly": 9, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 59}
{"server": "fabric", "bots": 25, "behaviour": "mixed", "start_seconds": 17.45, "mspt": {"average": 31.44, "p50": 28.33, "p95": 52.45, "p99": 93.35}, "mspt_worst_p99": 409.3, "tps": 20.0, "rss_mb": {"mean": 3884.61, "max": 4186.4}, "cpu_percent": 174.04, "chunks_per_second": 158.89, "unique_per_second": 5.75, "forgotten_per_second": 158.54, "waited_ticks_per_second": 0.0, "chunks_total": 103913, "chunks_per_bot": {"walk": 6356.8, "explore": 1239.2, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 704.27, "explore": 1163.0, "idle": 473.0}, "resent": 0, "pulled_back": 4194, "moved_too_quickly": 11, "moved_wrongly": 0, "bots_joined": 25, "bots_failed": [], "tick_queries": 59}
{"server": "horizon", "bots": 50, "behaviour": "mixed", "start_seconds": 8.31, "mspt": {"average": 47.83, "p50": 46.02, "p95": 62.42, "p99": 73.21}, "mspt_worst_p99": 133.0, "tps": 20.0, "rss_mb": {"mean": 1112.46, "max": 1296.4}, "cpu_percent": 143.22, "chunks_per_second": 302.68, "unique_per_second": 11.02, "forgotten_per_second": 303.15, "waited_ticks_per_second": 0.0, "chunks_total": 211296, "chunks_per_bot": {"walk": 6468.47, "explore": 1251.2, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 701.67, "explore": 1180.5, "idle": 473.0}, "resent": 0, "pulled_back": 8562, "moved_too_quickly": 0, "moved_wrongly": 0, "bots_joined": 50, "bots_failed": [], "tick_queries": 58}
{"server": "vanilla", "bots": 50, "behaviour": "mixed", "start_seconds": 15.45, "error": "[Errno 32] Broken pipe"}
{"server": "paper", "bots": 50, "behaviour": "mixed", "start_seconds": 19.35, "mspt": {"average": 46.86, "p50": 45.11, "p95": 61.16, "p99": 109.72}, "mspt_worst_p99": 213.4, "tps": 20.0, "rss_mb": {"mean": 4353.02, "max": 4516.1}, "cpu_percent": 210.21, "chunks_per_second": 310.51, "unique_per_second": 11.16, "forgotten_per_second": 310.64, "waited_ticks_per_second": 0.0, "chunks_total": 209303, "chunks_per_bot": {"walk": 6411.0, "explore": 1224.3, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 703.47, "explore": 1155.8, "idle": 473.0}, "resent": 0, "pulled_back": 7344, "moved_too_quickly": 35, "moved_wrongly": 0, "bots_joined": 50, "bots_failed": [], "tick_queries": 58}
{"server": "fabric", "bots": 50, "behaviour": "mixed", "start_seconds": 17.45, "mspt": {"average": 58.3, "p50": 54.73, "p95": 83.06, "p99": 174.37}, "mspt_worst_p99": 887.6, "tps": 17.15, "rss_mb": {"mean": 4521.47, "max": 4744.2}, "cpu_percent": 236.04, "chunks_per_second": 311.52, "unique_per_second": 11.28, "forgotten_per_second": 311.74, "waited_ticks_per_second": 0.0, "chunks_total": 206965, "chunks_per_bot": {"walk": 6331.7, "explore": 1228.4, "idle": 473.0}, "unique_chunks_per_bot": {"walk": 703.3, "explore": 1160.3, "idle": 473.0}, "resent": 0, "pulled_back": 6350, "moved_too_quickly": 46, "moved_wrongly": 0, "bots_joined": 50, "bots_failed": [], "tick_queries": 58}
@@ -0,0 +1,125 @@
# Horizon's server against vanilla 26.3: mixed bots (2026-10-18)
The first benchmark from docs/server.md section 10 (item 4): the same bots on Horizon's dedicated server, as
`install.sh` installs it today, and on vanilla 26.3's, one server at a time on the same quiet machine.
There were 1, 10, 25 and 50 bots, all with the `mixed` behaviour.
## Summary
- **Both servers held 20 TPS in every run.**
- **Memory:** Horizon's RSS was 3.7 to 5.2 times smaller: 0.65 GB against 3.4 GB with one player, and
1.0 GB against 4.0 GB with 50. Vanilla runs with the usual fixed heap (`-Xms4g -Xmx4g`), so its RSS
is mostly that heap.
- **Tick time (MSPT):**
- with 1, 10 and 25 players, Horizon's average tick was shorter (2.4 against 17.9 ms, 12.4 against 34.5,
24.0 against 31.6);
- with 50, it was longer (35.2 against 29.7 ms);
- Horizon's P99 was lower at every count, and so were its worst 5-second windows (241 ms at most,
against 424 to 2320 ms for vanilla).
- **The work was not equal, and the difference counts against Horizon.** In `mixed`, 20 % of the bots
fly straight out at 22 blocks a second, as fast as each server sends them chunks. Horizon kept up:
- with 10 or more players it sent 2.9 to 5.9 times as many chunks a second, and generated 1.9 to 2.9
times as much new terrain a second;
- its walking bots each received 3300 to 4000 chunks, against 640 to 1100 on vanilla once 10 or more
were online.
So at 50 players Horizon's tick is 18 % longer while it generates twice the new terrain. The
equal-rate explore pass of `../server-equal-work-2026-10-17/` is the fair comparison for exploring.
- **Start (fresh world, to `Done`):** 10 to 20 s for Horizon, 14.5 to 28.6 s for vanilla.
## Results
MSPT is milliseconds per tick from each server's own `/tick query` (50 is the limit for 20 TPS). P95 and
P99 are over the whole run. "Worst P99" is the highest P99 of a single 5-second query. RSS and CPU cover
the server's whole process tree (100 % is one thread; the machine has 4). "New chunks/s" counts chunks
no bot had before. "Pulled back" counts position packets that moved a bot back.
| Bots | Server | MSPT | P95 | P99 | Worst P99 | TPS | RSS (mean / max) | CPU | Start | Chunks/s | New chunks/s | Pulled back |
|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 1 | Horizon | **2.4** | 5.2 | 18.2 | 45.6 | 20 | **0.65 / 0.66 GB** | 25 % | 10.9 s | 9.9 | 0.3 | 0 |
| 1 | vanilla | 17.9 | 27.6 | 47.3 | 423.8 | 20 | 3.38 / 3.39 GB | 64 % | 14.8 s | 9.9 | 0.3 | 0 |
| 10 | Horizon | **12.4** | 36.0 | 65.9 | 117.1 | 20 | **0.94 / 1.01 GB** | 210 % | 10.2 s | 98.2 | 36.5 | 6267 |
| 10 | vanilla | 34.5 | 63.5 | 138.6 | 884.7 | 20 | 3.59 / 3.66 GB | 217 % | 14.5 s | 24.3 | 19.1 | 5500 |
| 25 | Horizon | **24.0** | 49.7 | 80.6 | 168.8 | 20 | **1.01 / 1.18 GB** | 192 % | 14.7 s | 178.2 | 31.9 | 5532 |
| 25 | vanilla | 31.6 | 70.0 | 222.7 | 2319.6 | 20 | 3.74 / 4.06 GB | 218 % | 28.6 s | 30.4 | 10.8 | 4524 |
| 50 | Horizon | 35.2 | 69.0 | 96.6 | 240.8 | 20 | **1.01 / 1.25 GB** | 218 % | 19.8 s | 273.2 | 34.6 | 8369 |
| 50 | vanilla | **29.7** | 62.3 | 137.4 | 489.3 | 20 | 4.00 / 4.60 GB | 294 % | 20.6 s | 94.4 | 17.8 | 12034 |
Chunks each bot received, by behaviour (mean):
| Bots | Server | walk | explore | idle |
|---:|---|---:|---:|---:|
| 10 | Horizon | 3992 | 7360 | 473 |
| 10 | vanilla | 680 | 2824 | 358 |
| 25 | Horizon | 3928 | 2475 | 473 |
| 25 | vanilla | 640 | 738 | 180 |
| 50 | Horizon | 3300 | 1192 | 460 |
| 50 | vanilla | 1105 | 629 | 220 |
No bot failed to join or was dropped, and no chunk was sent twice (`resent` 0), on either server. Vanilla
also logged "moved too quickly" 12, 324 and 2193 times at 10, 25 and 50 bots.
## Method
`tools/server_bench.py`, unchanged, runs each configuration on a fresh server:
- the same `server.properties` on both: seed 12345, view and simulation distance 10, offline, creative,
difficulty easy, `sync-chunk-writes=true`, white-list off;
- `gamerule respawn_radius 0`, and otherwise vanilla's default game rules on both. The locator bar is on
for both: Horizon's server has run it since v2026.10.18.13. The 2026-10-17 runs turned it off on vanilla
because Horizon had none then;
- the server waits for `Done`, then the bots join one a second, warm up for 60 seconds, and are measured
for 300 seconds, with `/tick query` every 5 seconds and RSS and CPU every second.
The bots are `horizon-bots --behaviour mixed`: 60 % walk, 20 % explore and 20 % idle, each choice
deterministic per bot. All are creative and fly 24 blocks above their spawn:
- `walk`: random points within 48 blocks at 5.6 blocks a second;
- `explore`: straight out at up to 22 blocks a second, as fast as chunks come;
- `idle`: hovers.
A bot doesn't move while the chunk it stands in hasn't arrived, and it acknowledges chunk batches as
vanilla's client does.
**Machine.** The ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS (Linux 6.18). No other
session ran on it (`load.txt`: the fleet monitor shows 0 sessions throughout). The busy periods are the
servers themselves, which used 2 to 3 of its 4 threads from 10 bots up, so both servers were CPU-bound
there. The servers ran under `nice -n 19`. The desktop was meant to run this (24 threads) but didn't
wake. The bots ran on the Mac mini (M4 Pro) and reached the ThinkPad over the tailnet, so they took none
of the servers' CPU. The Mac mini was shared with other work (70 to 90 % busy), which only affects the
bots' timing.
**What ran.**
- Horizon: the published v2026.10.18.15 release, `horizon-client` (the engine) at 6c078b28, as
`install.sh` installs it. Its game files were prepared from the owner's own Minecraft by the release's
`horizon-prepare`. `horizon-server` (the supervisor, which takes no part in the tick) came from #898
(4ea2243e), because the release's copy can't find game files prepared without sounds (#893).
`horizon-bots` came from main.
- Vanilla: the pinned 26.3 server jar (the owner's own copy) on OpenJDK 25.0.4.1, `-Xms4g -Xmx4g`.
On Linux the release's engine needs `libasound.so.2` and `libudev.so.1`. The ThinkPad has them only in
the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
HORIZON_BENCH_BIN=<a folder with horizon-server and horizon-client> \
python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25,50 --behaviour mixed --nice 19 \
--bots-via 'ssh <bots machine>' --bots-bin /path/to/horizon-bots --connect <this machine's address> \
--out mixed.json
`mixed.json` is the report (`horizon-server-benchmark`: machine, versions, settings and every run), and
`load.txt` is the ThinkPad's load before and during the run.
## Next
- An equal-work `mixed` pass (`--explore-speed` at a rate both servers keep up with), then 100 bots.
- The same run on a bigger machine, so neither server is CPU-bound at 25 to 50 bots.
- Paper and Fabric + Lithium (docs/server.md section 9).
- At 50 players Horizon's tick is longer than vanilla's while it generates and sends far more. The
profile of the 2026-10-17 run (streaming and block entity ticks growing with players) is
perf-server's to take on.
+286
View File
@@ -0,0 +1,286 @@
ludothinkpad — OK
RAM 29.1 GB free of 31.2 GB · swap 0.3 GB/15.6 GB
CPU 4 threads, 2% busy (agents 0.0, rest 0.1 cores) · 67°C · load 2.1
· disk 295.5 GB free · battery 96%
Sessions, most important first:
(finished session) 0.0 GB leftovers: node 0.0 GB
Other machines (≤ 1 min old): desktop offline · macbook offline · macmini ok: 26.6 GB free, 14 threads 69% busy, 1 sessions
# During the run, once a minute (uptime, then ~/fleet/load fleet)
18:25:30
18:25:30 up 8 days 3:08, 0 users, load average: 2.13, 1.53, 1.29
ludothinkpad ok: 29.1 GB free, 4 threads 2% busy, 0 sessions
desktop offline
macmini busy: 27.6 GB free, 14 threads 87% busy, 1 sessions
18:26:34
18:26:34 up 8 days 3:09, 0 users, load average: 1.70, 1.56, 1.31
ludothinkpad ok: 28.5 GB free, 4 threads 23% busy, 0 sessions
desktop offline
macmini busy: 26.4 GB free, 14 threads 85% busy, 1 sessions
18:27:38
18:27:38 up 8 days 3:10, 0 users, load average: 1.10, 1.43, 1.28
ludothinkpad ok: 28.5 GB free, 4 threads 4% busy, 0 sessions
desktop offline
macmini ok: 27.7 GB free, 14 threads 80% busy, 1 sessions
18:28:42
18:28:42 up 8 days 3:11, 0 users, load average: 0.93, 1.29, 1.24
ludothinkpad ok: 28.5 GB free, 4 threads 22% busy, 0 sessions
desktop offline
macmini ok: 26.0 GB free, 14 threads 82% busy, 1 sessions
18:29:46
18:29:46 up 8 days 3:12, 0 users, load average: 0.62, 1.12, 1.18
ludothinkpad ok: 28.5 GB free, 4 threads 12% busy, 0 sessions
desktop offline
macmini ok: 25.7 GB free, 14 threads 81% busy, 1 sessions
18:30:50
18:30:51 up 8 days 3:13, 0 users, load average: 0.79, 1.05, 1.15
ludothinkpad ok: 28.5 GB free, 4 threads 7% busy, 0 sessions
desktop offline
macmini busy: 27.0 GB free, 14 threads 88% busy, 1 sessions
18:31:55
18:31:55 up 8 days 3:14, 0 users, load average: 0.67, 0.96, 1.11
ludothinkpad ok: 28.6 GB free, 4 threads 73% busy, 0 sessions
desktop offline
macmini ok: 27.3 GB free, 14 threads 79% busy, 1 sessions
18:32:59
18:32:59 up 8 days 3:15, 0 users, load average: 4.01, 1.91, 1.44
ludothinkpad ok: 25.9 GB free, 4 threads 24% busy, 0 sessions
desktop offline
macmini ok: 26.4 GB free, 14 threads 85% busy, 1 sessions
18:34:03
18:34:03 up 8 days 3:16, 0 users, load average: 2.24, 1.84, 1.44
ludothinkpad ok: 25.9 GB free, 4 threads 21% busy, 0 sessions
desktop offline
macmini busy: 29.2 GB free, 14 threads 86% busy, 1 sessions
18:35:07
18:35:07 up 8 days 3:17, 0 users, load average: 1.28, 1.63, 1.40
ludothinkpad ok: 25.8 GB free, 4 threads 21% busy, 0 sessions
desktop offline
macmini busy: 31.0 GB free, 14 threads 86% busy, 1 sessions
18:36:11
18:36:11 up 8 days 3:18, 0 users, load average: 0.79, 1.41, 1.33
ludothinkpad ok: 25.9 GB free, 4 threads 18% busy, 0 sessions
desktop offline
macmini busy: 26.3 GB free, 14 threads 93% busy, 1 sessions
18:37:15
18:37:15 up 8 days 3:20, 0 users, load average: 1.44, 1.47, 1.36
ludothinkpad ok: 25.9 GB free, 4 threads 23% busy, 0 sessions
desktop offline
macmini busy: 26.8 GB free, 14 threads 86% busy, 1 sessions
18:38:19
18:38:19 up 8 days 3:21, 0 users, load average: 1.37, 1.45, 1.36
ludothinkpad ok: 25.9 GB free, 4 threads 13% busy, 0 sessions
desktop offline
macmini busy: 26.7 GB free, 14 threads 86% busy, 1 sessions
18:39:24
18:39:24 up 8 days 3:22, 0 users, load average: 2.27, 1.66, 1.44
ludothinkpad ok: 28.3 GB free, 4 threads 62% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 70% busy, 1 sessions
18:40:28
18:40:28 up 8 days 3:23, 0 users, load average: 1.95, 1.72, 1.48
ludothinkpad ok: 28.3 GB free, 4 threads 63% busy, 0 sessions
desktop offline
macmini ok: 26.7 GB free, 14 threads 84% busy, 1 sessions
18:41:32
18:41:32 up 8 days 3:24, 0 users, load average: 3.32, 2.22, 1.67
ludothinkpad ok: 28.2 GB free, 4 threads 61% busy, 0 sessions
desktop offline
macmini busy: 26.8 GB free, 14 threads 85% busy, 1 sessions
18:42:36
18:42:36 up 8 days 3:25, 0 users, load average: 3.13, 2.33, 1.75
ludothinkpad ok: 28.2 GB free, 4 threads 60% busy, 0 sessions
desktop offline
macmini busy: 27.1 GB free, 14 threads 91% busy, 1 sessions
18:43:40
18:43:40 up 8 days 3:26, 0 users, load average: 3.27, 2.48, 1.83
ludothinkpad busy: 28.1 GB free, 4 threads 90% busy, 0 sessions
desktop offline
macmini busy: 28.3 GB free, 14 threads 92% busy, 1 sessions
18:44:44
18:44:44 up 8 days 3:27, 0 users, load average: 2.14, 2.32, 1.82
ludothinkpad ok: 28.1 GB free, 4 threads 55% busy, 0 sessions
desktop offline
macmini busy: 30.4 GB free, 14 threads 88% busy, 1 sessions
18:45:48
18:45:48 up 8 days 3:28, 0 users, load average: 4.94, 3.06, 2.11
ludothinkpad busy: 25.9 GB free, 4 threads 86% busy, 0 sessions
desktop offline
macmini ok: 28.7 GB free, 14 threads 84% busy, 1 sessions
18:46:53
18:46:53 up 8 days 3:29, 0 users, load average: 6.55, 3.92, 2.47
ludothinkpad busy: 25.4 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 29.3 GB free, 14 threads 81% busy, 1 sessions
18:47:57
18:47:57 up 8 days 3:30, 0 users, load average: 8.50, 4.96, 2.93
ludothinkpad busy: 24.0 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 27.9 GB free, 14 threads 90% busy, 1 sessions
18:49:01
18:49:01 up 8 days 3:31, 0 users, load average: 8.59, 5.68, 3.31
ludothinkpad busy: 22.7 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 28.2 GB free, 14 threads 85% busy, 1 sessions
18:50:05
18:50:05 up 8 days 3:32, 0 users, load average: 7.81, 6.07, 3.61
ludothinkpad busy: 24.5 GB free, 4 threads 90% busy, 0 sessions
desktop offline
macmini busy: 28.6 GB free, 14 threads 96% busy, 1 sessions
18:51:09
18:51:09 up 8 days 3:33, 0 users, load average: 8.29, 6.50, 3.93
ludothinkpad busy: 24.3 GB free, 4 threads 92% busy, 0 sessions
desktop offline
macmini busy: 28.0 GB free, 14 threads 94% busy, 1 sessions
18:52:14
18:52:14 up 8 days 3:35, 0 users, load average: 5.58, 6.06, 3.95
ludothinkpad busy: 26.0 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 26.3 GB free, 14 threads 87% busy, 1 sessions
18:53:18
18:53:18 up 8 days 3:36, 0 users, load average: 4.53, 5.69, 3.97
ludothinkpad busy: 27.2 GB free, 4 threads 89% busy, 0 sessions
desktop offline
macmini busy: 26.8 GB free, 14 threads 87% busy, 1 sessions
18:54:22
18:54:22 up 8 days 3:37, 0 users, load average: 5.31, 5.64, 4.07
ludothinkpad busy: 26.3 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 29.3 GB free, 14 threads 91% busy, 1 sessions
18:55:26
18:55:26 up 8 days 3:38, 0 users, load average: 10.47, 6.99, 4.63
ludothinkpad busy: 26.1 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 26.7 GB free, 14 threads 90% busy, 1 sessions
18:56:30
18:56:30 up 8 days 3:39, 0 users, load average: 9.27, 7.45, 4.96
ludothinkpad busy: 26.2 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 28.5 GB free, 14 threads 92% busy, 1 sessions
18:57:35
18:57:35 up 8 days 3:40, 0 users, load average: 7.79, 7.39, 5.10
ludothinkpad busy: 26.1 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 27.1 GB free, 14 threads 80% busy, 1 sessions
18:58:39
18:58:39 up 8 days 3:41, 0 users, load average: 8.06, 7.55, 5.32
ludothinkpad busy: 26.4 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 31.8 GB free, 14 threads 95% busy, 1 sessions
18:59:43
18:59:43 up 8 days 3:42, 0 users, load average: 8.44, 7.65, 5.50
ludothinkpad busy: 26.2 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 31.4 GB free, 14 threads 94% busy, 1 sessions
19:00:47
19:00:47 up 8 days 3:43, 0 users, load average: 11.04, 8.66, 6.00
ludothinkpad busy: 24.9 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 31.8 GB free, 14 threads 88% busy, 1 sessions
19:01:51
19:01:51 up 8 days 3:44, 0 users, load average: 11.91, 9.37, 6.41
ludothinkpad busy: 24.4 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 32.9 GB free, 14 threads 77% busy, 1 sessions
19:02:56
19:02:56 up 8 days 3:45, 0 users, load average: 11.64, 9.85, 6.79
ludothinkpad ok: 25.5 GB free, 4 threads 70% busy, 0 sessions
desktop offline
macmini busy: 30.9 GB free, 14 threads 91% busy, 1 sessions
19:04:00
19:04:00 up 8 days 3:46, 0 users, load average: 11.42, 10.06, 7.06
ludothinkpad busy: 23.4 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini busy: 28.4 GB free, 14 threads 94% busy, 1 sessions
19:05:04
19:05:04 up 8 days 3:47, 0 users, load average: 9.83, 9.96, 7.24
ludothinkpad busy: 23.2 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 31.4 GB free, 14 threads 80% busy, 1 sessions
19:06:09
19:06:09 up 8 days 3:48, 0 users, load average: 8.38, 9.68, 7.34
ludothinkpad ok: 22.7 GB free, 4 threads 17% busy, 0 sessions
desktop offline
macmini busy: 29.8 GB free, 14 threads 86% busy, 1 sessions
19:07:13
19:07:13 up 8 days 3:49, 0 users, load average: 4.51, 8.27, 7.00
ludothinkpad ok: 25.7 GB free, 4 threads 75% busy, 0 sessions
desktop offline
macmini busy: 26.5 GB free, 14 threads 89% busy, 1 sessions
19:08:17
19:08:17 up 8 days 3:51, 0 users, load average: 6.77, 8.13, 7.04
ludothinkpad busy: 25.8 GB free, 4 threads 95% busy, 0 sessions
desktop offline
macmini busy: 30.8 GB free, 14 threads 91% busy, 1 sessions
19:09:22
19:09:22 up 8 days 3:52, 0 users, load average: 8.27, 8.25, 7.14
ludothinkpad busy: 25.2 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 28.3 GB free, 14 threads 91% busy, 1 sessions
19:10:26
19:10:26 up 8 days 3:53, 0 users, load average: 8.88, 8.57, 7.34
ludothinkpad busy: 24.8 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 32.4 GB free, 14 threads 72% busy, 1 sessions
19:11:30
19:11:30 up 8 days 3:54, 0 users, load average: 13.33, 10.05, 7.94
ludothinkpad busy: 24.7 GB free, 4 threads 97% busy, 0 sessions
desktop offline
macmini busy: 30.1 GB free, 14 threads 87% busy, 1 sessions
19:12:35
19:12:35 up 8 days 3:55, 0 users, load average: 10.92, 10.13, 8.12
ludothinkpad ok: 26.4 GB free, 4 threads 76% busy, 0 sessions
desktop offline
macmini busy: 29.3 GB free, 14 threads 86% busy, 1 sessions
19:13:39
19:13:39 up 8 days 3:56, 0 users, load average: 9.98, 9.89, 8.16
ludothinkpad busy: 25.8 GB free, 4 threads 90% busy, 0 sessions
desktop offline
macmini busy: 28.8 GB free, 14 threads 93% busy, 1 sessions
19:14:43
19:14:43 up 8 days 3:57, 0 users, load average: 8.95, 9.63, 8.19
ludothinkpad busy: 25.1 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 29.6 GB free, 14 threads 71% busy, 1 sessions
19:15:47
19:15:47 up 8 days 3:58, 0 users, load average: 12.02, 10.38, 8.55
ludothinkpad busy: 24.5 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini busy: 28.1 GB free, 14 threads 93% busy, 1 sessions
19:16:52
19:16:52 up 8 days 3:59, 0 users, load average: 13.82, 11.21, 8.95
ludothinkpad busy: 23.9 GB free, 4 threads 100% busy, 0 sessions
desktop offline
macmini ok: 28.4 GB free, 14 threads 75% busy, 1 sessions
19:17:56
19:17:56 up 8 days 4:00, 0 users, load average: 11.95, 11.20, 9.10
ludothinkpad busy: 25.0 GB free, 4 threads 99% busy, 0 sessions
desktop offline
macmini ok: 28.4 GB free, 14 threads 81% busy, 1 sessions
19:19:00
19:19:00 up 8 days 4:01, 0 users, load average: 11.46, 11.14, 9.22
ludothinkpad busy: 24.6 GB free, 4 threads 95% busy, 0 sessions
desktop offline
macmini ok: 26.4 GB free, 14 threads 83% busy, 1 sessions
19:20:04
19:20:04 up 8 days 4:02, 0 users, load average: 11.11, 10.97, 9.28
ludothinkpad busy: 24.6 GB free, 4 threads 93% busy, 0 sessions
desktop offline
macmini ok: 26.7 GB free, 14 threads 85% busy, 1 sessions
19:21:08
19:21:08 up 8 days 4:03, 0 users, load average: 9.81, 10.60, 9.27
ludothinkpad busy: 24.4 GB free, 4 threads 91% busy, 0 sessions
desktop offline
macmini ok: 27.8 GB free, 14 threads 75% busy, 1 sessions
19:22:13
19:22:13 up 8 days 4:04, 0 users, load average: 5.98, 9.51, 9.00
ludothinkpad ok: 23.8 GB free, 4 threads 18% busy, 0 sessions
desktop offline
macmini busy: 29.7 GB free, 14 threads 85% busy, 1 sessions
19:23:17
19:23:17 up 8 days 4:06, 0 users, load average: 4.01, 8.27, 8.60
ludothinkpad ok: 23.1 GB free, 4 threads 56% busy, 0 sessions
desktop offline
macmini ok: 28.2 GB free, 14 threads 79% busy, 1 sessions
@@ -0,0 +1,367 @@
{
"kind": "horizon-server-benchmark",
"date": "2026-10-18T17:23:30+00:00",
"machine": {
"system": "Linux",
"machine": "x86_64",
"cpus": 4,
"note": "ThinkPad: Intel Core i7-7500U (2 cores, 4 threads), 32 GB, NixOS, Linux 6.18; the bots on the Mac mini (M4 Pro) over the tailnet"
},
"commit": "4ea2243e4d1a44f910857bd759845db70e3690a8",
"java": [
"openjdk 25.0.4.1 2026-08-18"
],
"settings": {
"servers": "horizon,vanilla",
"bots": "1,10,25,50",
"behaviour": "mixed",
"join_every": 1.0,
"warmup": 60,
"seconds": 300,
"seed": 12345,
"explore_speed": 0,
"gamerule": "",
"heap": "4g",
"out": "mixed.json",
"nice": 19,
"bots_via": "ssh macmini",
"bots_bin": "horizon-bots (on the Mac mini)",
"connect": "<the ThinkPad's tailnet address>",
"properties": {
"level-seed": "12345",
"view-distance": "10",
"simulation-distance": "10",
"online-mode": "false",
"white-list": "false",
"gamemode": "creative",
"force-gamemode": "true",
"spawn-protection": "0",
"max-players": "200",
"difficulty": "easy",
"sync-chunk-writes": "true"
}
},
"runs": [
{
"server": "horizon",
"bots": 1,
"behaviour": "mixed",
"start_seconds": 10.92,
"mspt": {
"average": 2.38,
"p50": 1.97,
"p95": 5.17,
"p99": 18.2
},
"mspt_worst_p99": 45.6,
"tps": 20.0,
"rss_mb": {
"mean": 653.7,
"max": 659.2
},
"cpu_percent": 24.51,
"chunks_per_second": 9.9,
"unique_per_second": 0.3,
"forgotten_per_second": 9.93,
"waited_ticks_per_second": 0.0,
"chunks_total": 3951,
"chunks_per_bot": {
"walk": 3951.0
},
"unique_chunks_per_bot": {
"walk": 699.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 1,
"behaviour": "mixed",
"start_seconds": 14.75,
"mspt": {
"average": 17.9,
"p50": 18.39,
"p95": 27.58,
"p99": 47.28
},
"mspt_worst_p99": 423.8,
"tps": 20.0,
"rss_mb": {
"mean": 3382.06,
"max": 3394.1
},
"cpu_percent": 64.02,
"chunks_per_second": 9.92,
"unique_per_second": 0.29,
"forgotten_per_second": 9.95,
"waited_ticks_per_second": 0.0,
"chunks_total": 3846,
"chunks_per_bot": {
"walk": 3846.0
},
"unique_chunks_per_bot": {
"walk": 699.0
},
"resent": 0,
"pulled_back": 0,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 1,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 10,
"behaviour": "mixed",
"start_seconds": 10.22,
"mspt": {
"average": 12.39,
"p50": 8.33,
"p95": 35.99,
"p99": 65.9
},
"mspt_worst_p99": 117.1,
"tps": 20.0,
"rss_mb": {
"mean": 935.85,
"max": 1008.6
},
"cpu_percent": 210.37,
"chunks_per_second": 98.22,
"unique_per_second": 36.49,
"forgotten_per_second": 98.25,
"waited_ticks_per_second": 0.0,
"chunks_total": 39618,
"chunks_per_bot": {
"walk": 3992.17,
"explore": 7359.5,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 696.83,
"explore": 6869.0,
"idle": 473.0
},
"resent": 0,
"pulled_back": 6267,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 10,
"behaviour": "mixed",
"start_seconds": 14.54,
"mspt": {
"average": 34.47,
"p50": 29.67,
"p95": 63.52,
"p99": 138.58
},
"mspt_worst_p99": 884.7,
"tps": 20.0,
"rss_mb": {
"mean": 3594.26,
"max": 3661.7
},
"cpu_percent": 217.13,
"chunks_per_second": 24.34,
"unique_per_second": 19.13,
"forgotten_per_second": 20.12,
"waited_ticks_per_second": 0.62,
"chunks_total": 10443,
"chunks_per_bot": {
"walk": 679.83,
"explore": 2824.0,
"idle": 358.0
},
"unique_chunks_per_bot": {
"walk": 389.17,
"explore": 2824.0,
"idle": 358.0
},
"resent": 0,
"pulled_back": 5500,
"moved_too_quickly": 12,
"moved_wrongly": 0,
"bots_joined": 10,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "horizon",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 14.73,
"mspt": {
"average": 24.0,
"p50": 21.07,
"p95": 49.72,
"p99": 80.57
},
"mspt_worst_p99": 168.8,
"tps": 20.0,
"rss_mb": {
"mean": 1013.07,
"max": 1179.4
},
"cpu_percent": 192.47,
"chunks_per_second": 178.23,
"unique_per_second": 31.85,
"forgotten_per_second": 177.93,
"waited_ticks_per_second": 60.59,
"chunks_total": 73657,
"chunks_per_bot": {
"walk": 3927.87,
"explore": 2474.8,
"idle": 473.0
},
"unique_chunks_per_bot": {
"walk": 691.73,
"explore": 2447.8,
"idle": 473.0
},
"resent": 0,
"pulled_back": 5532,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 58
},
{
"server": "vanilla",
"bots": 25,
"behaviour": "mixed",
"start_seconds": 28.57,
"mspt": {
"average": 31.59,
"p50": 25.18,
"p95": 70.03,
"p99": 222.74
},
"mspt_worst_p99": 2319.6,
"tps": 20.0,
"rss_mb": {
"mean": 3742.51,
"max": 4055.0
},
"cpu_percent": 218.34,
"chunks_per_second": 30.37,
"unique_per_second": 10.84,
"forgotten_per_second": 29.44,
"waited_ticks_per_second": 50.91,
"chunks_total": 14193,
"chunks_per_bot": {
"walk": 640.2,
"explore": 738.0,
"idle": 180.0
},
"unique_chunks_per_bot": {
"walk": 211.33,
"explore": 737.6,
"idle": 180.0
},
"resent": 0,
"pulled_back": 4524,
"moved_too_quickly": 324,
"moved_wrongly": 0,
"bots_joined": 25,
"bots_failed": [],
"tick_queries": 56
},
{
"server": "horizon",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 19.84,
"mspt": {
"average": 35.2,
"p50": 32.12,
"p95": 69.04,
"p99": 96.56
},
"mspt_worst_p99": 240.8,
"tps": 20.0,
"rss_mb": {
"mean": 1013.2,
"max": 1249.1
},
"cpu_percent": 217.68,
"chunks_per_second": 273.18,
"unique_per_second": 34.58,
"forgotten_per_second": 268.19,
"waited_ticks_per_second": 138.29,
"chunks_total": 115527,
"chunks_per_bot": {
"walk": 3300.1,
"explore": 1192.4,
"idle": 460.0
},
"unique_chunks_per_bot": {
"walk": 632.67,
"explore": 1186.9,
"idle": 460.0
},
"resent": 0,
"pulled_back": 8369,
"moved_too_quickly": 0,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 57
},
{
"server": "vanilla",
"bots": 50,
"behaviour": "mixed",
"start_seconds": 20.58,
"mspt": {
"average": 29.74,
"p50": 23.6,
"p95": 62.25,
"p99": 137.41
},
"mspt_worst_p99": 489.3,
"tps": 20.0,
"rss_mb": {
"mean": 3995.71,
"max": 4597.8
},
"cpu_percent": 294.31,
"chunks_per_second": 94.36,
"unique_per_second": 17.76,
"forgotten_per_second": 94.69,
"waited_ticks_per_second": 101.48,
"chunks_total": 41637,
"chunks_per_bot": {
"walk": 1104.97,
"explore": 628.8,
"idle": 220.0
},
"unique_chunks_per_bot": {
"walk": 279.4,
"explore": 626.4,
"idle": 220.0
},
"resent": 0,
"pulled_back": 12034,
"moved_too_quickly": 2193,
"moved_wrongly": 0,
"bots_joined": 50,
"bots_failed": [],
"tick_queries": 57
}
],
"horizon_build": "v2026.10.18.15 release binaries (horizon-client, the engine, at 6c078b28); horizon-server from #898 (4ea2243e, the supervisor only)"
}
@@ -0,0 +1,379 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 10,
"ordering": [
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
],
[
"released",
"retained"
],
[
"retained",
"released"
]
],
"load_average_before": [
8.2421875,
6.56396484375,
6.39208984375
],
"load_average_after": [
7.98095703125,
6.6455078125,
6.4248046875
],
"executables": {
"released": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
"mode": "deferred",
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
},
"retained": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
"mode": "deferred-held",
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
}
},
"metrics": {
"released": {
"phase_ms": {
"median": 482.591,
"p25": 444.276,
"p75": 586.92,
"min": 430.128,
"max": 736.543,
"samples": [
529.244,
606.146,
449.23,
442.624,
669.108,
511.519,
736.543,
432.616,
430.128,
453.663
]
},
"total_ms": {
"median": 987.004,
"p25": 932.885,
"p75": 1134.876,
"min": 914.242,
"max": 1201.759,
"samples": [
1055.119,
1161.462,
968.629,
914.242,
1201.759,
1005.379,
1200.01,
928.249,
935.775,
931.921
]
},
"cpu_ms": {
"median": 1004.675,
"p25": 936.465,
"p75": 1116.791,
"min": 908.327,
"max": 1241.168,
"samples": [
1074.425,
1130.913,
984.832,
931.205,
1186.169,
1024.519,
1241.168,
908.327,
952.245,
927.15
]
},
"peak_rss_kib": {
"median": 268124.0,
"p25": 267753.0,
"p75": 268455.0,
"min": 267708,
"max": 268700,
"samples": [
268268,
268500,
267744,
267724,
267708,
267780,
268700,
268496,
268332,
267980
]
},
"behaviour_json_ms": {
"median": 24.371,
"p25": 22.469,
"p75": 28.486,
"min": 20.662,
"max": 35.297,
"samples": [
33.387,
35.297,
24.732,
24.011,
27.699,
28.748,
23.623,
22.084,
20.662,
21.266
]
},
"facts_ms": {
"median": 292.77,
"p25": 270.649,
"p75": 383.193,
"min": 264.691,
"max": 443.291,
"samples": [
346.613,
398.468,
274.673,
264.691,
443.291,
292.926,
395.387,
267.232,
269.308,
292.615
]
},
"behaviours_ms": {
"median": 148.635,
"p25": 141.329,
"p75": 181.741,
"min": 136.361,
"max": 307.004,
"samples": [
145.009,
168.982,
146.535,
150.736,
194.604,
185.994,
307.004,
140.102,
136.785,
136.361
]
},
"post_rss_kib": {
"median": 268668.0,
"p25": 268246.0,
"p75": 268922.0,
"min": 268048.0,
"max": 269012.0,
"samples": [
268668.0,
268936.0,
268108.0,
268088.0,
268048.0,
268668.0,
268880.0,
269012.0,
268960.0,
268660.0
]
}
},
"retained": {
"phase_ms": {
"median": 487.954,
"p25": 471.456,
"p75": 531.586,
"min": 401.516,
"max": 834.154,
"samples": [
486.974,
613.755,
540.535,
470.333,
488.934,
474.825,
834.154,
504.74,
401.516,
440.381
]
},
"total_ms": {
"median": 1038.875,
"p25": 989.724,
"p75": 1079.197,
"min": 880.933,
"max": 1746.386,
"samples": [
996.407,
1067.467,
1082.69,
1103.131,
1010.283,
1068.719,
1746.386,
987.496,
890.46,
880.933
]
},
"cpu_ms": {
"median": 1035.537,
"p25": 982.276,
"p75": 1082.698,
"min": 908.774,
"max": 1660.068,
"samples": [
1011.712,
1087.688,
1076.523,
1084.757,
998.654,
1059.363,
1660.068,
976.817,
912.849,
908.774
]
},
"peak_rss_kib": {
"median": 268150.0,
"p25": 267537.0,
"p75": 268614.0,
"min": 267268,
"max": 268848,
"samples": [
268748,
267968,
267268,
268332,
267540,
268560,
267492,
268632,
267536,
268848
]
},
"behaviour_json_ms": {
"median": 23.208,
"p25": 22.305,
"p75": 26.544,
"min": 20.059,
"max": 32.669,
"samples": [
32.669,
22.62,
22.2,
27.331,
25.088,
27.029,
23.488,
20.977,
22.928,
20.059
]
},
"facts_ms": {
"median": 304.44,
"p25": 291.562,
"p75": 330.991,
"min": 244.758,
"max": 384.634,
"samples": [
307.936,
374.145,
336.931,
300.944,
290.81,
293.817,
384.634,
313.171,
244.758,
279.303
]
},
"behaviours_ms": {
"median": 162.285,
"p25": 143.135,
"p75": 179.31,
"min": 133.83,
"max": 426.031,
"samples": [
146.367,
216.988,
181.402,
142.058,
173.036,
153.978,
426.031,
170.592,
133.83,
141.017
]
},
"post_rss_kib": {
"median": 268694.0,
"p25": 268116.0,
"p75": 269056.0,
"min": 267988.0,
"max": 269212.0,
"samples": [
269212.0,
268564.0,
268096.0,
268824.0,
268176.0,
269032.0,
267988.0,
269064.0,
268044.0,
269120.0
]
}
}
}
}
@@ -0,0 +1,176 @@
{
"scope": "Isolated gameplay-facts.json validation/typed decoding; includes JSON file read",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 10,
"load_averages_before_after": [
[
14.7451171875,
8.8994140625,
6.89599609375
],
[
14.7451171875,
8.8994140625,
6.89599609375
]
],
"binary_sha256": "c2be9140c792720499d70c5806b8def0472734264ad72486d01365447e0cc2d7",
"metrics": {
"validate": {
"phase_ms": {
"median": 117.417,
"p25": 114.101,
"p75": 158.855,
"min": 109.199,
"max": 173.408,
"samples": [
109.199,
113.958,
110.195,
118.987,
171.275,
124.701,
170.24,
173.408,
115.848,
114.531
]
},
"total_ms": {
"median": 139.933,
"p25": 133.863,
"p75": 193.565,
"min": 129.75,
"max": 276.311,
"samples": [
276.311,
135.448,
129.75,
138.452,
195.127,
141.413,
188.88,
199.802,
133.335,
132.094
]
},
"cpu_ms": {
"median": 138.849,
"p25": 134.285,
"p75": 168.881,
"min": 129.089,
"max": 190.82,
"samples": [
140.962,
136.06,
129.089,
139.059,
189.478,
138.639,
178.187,
190.82,
133.693,
132.716
]
},
"peak_rss_kib": {
"median": 56654.0,
"p25": 56642.0,
"p75": 56704.0,
"min": 56616,
"max": 56724,
"samples": [
56620,
56656,
56640,
56720,
56616,
56724,
56652,
56720,
56648,
56656
]
}
},
"typed": {
"phase_ms": {
"median": 115.873,
"p25": 109.422,
"p75": 129.67,
"min": 100.872,
"max": 183.923,
"samples": [
116.965,
132.071,
100.872,
105.098,
122.468,
170.777,
107.997,
183.923,
113.699,
114.781
]
},
"total_ms": {
"median": 133.883,
"p25": 126.939,
"p75": 155.913,
"min": 117.54,
"max": 210.164,
"samples": [
135.04,
159.38,
117.54,
122.391,
145.512,
189.132,
125.846,
210.164,
130.217,
132.727
]
},
"cpu_ms": {
"median": 134.375,
"p25": 126.231,
"p75": 154.675,
"min": 118.132,
"max": 204.242,
"samples": [
135.633,
157.961,
118.132,
122.77,
144.816,
178.293,
124.755,
204.242,
130.661,
133.117
]
},
"peak_rss_kib": {
"median": 83974.0,
"p25": 82744.0,
"p75": 84490.0,
"min": 82332,
"max": 84596,
"samples": [
83596,
84596,
82332,
82424,
84288,
82460,
84532,
83660,
84364,
84536
]
}
}
}
}
@@ -0,0 +1,453 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 8,
"ordering": [
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
]
],
"load_average_before": [
10.57958984375,
8.45947265625,
6.8134765625
],
"load_average_after": [
7.158203125,
7.7998046875,
6.66455078125
],
"executables": {
"baseline": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-e422bd13",
"mode": "baseline",
"sha256": "bf61f2e6c92e001be3c3bc93cf8072cc6af6fd1d7bcfa7273045960948ec89e7"
},
"a01d47f2": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-a01d47f2",
"mode": "shared",
"sha256": "88be56f8dbd28d0b4abd6c672486ba758df96ba18f68da45b94db14231dd3eea"
},
"cf837118": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
"mode": "deferred",
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
},
"cd61e11b": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
"mode": "deferred",
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
},
"current": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-new",
"mode": "deferred",
"sha256": "84eb1baae8e138f52e300b0b44f1339fca38e70db6e06126e78db776ea3e5cd0"
}
},
"metrics": {
"baseline": {
"phase_ms": {
"median": 1277.781,
"p25": 1271.23,
"p75": 1296.157,
"min": 1223.341,
"max": 1338.745,
"samples": [
1265.934,
1223.341,
1274.931,
1289.177,
1338.745,
1317.096,
1272.995,
1280.631
]
},
"total_ms": {
"median": 1711.802,
"p25": 1695.745,
"p75": 1754.276,
"min": 1645.69,
"max": 2887.078,
"samples": [
2887.078,
1645.69,
1690.413,
1711.459,
1761.859,
1751.748,
1697.522,
1712.144
]
},
"cpu_ms": {
"median": 1769.996,
"p25": 1748.925,
"p75": 1807.254,
"min": 1705.167,
"max": 1950.463,
"samples": [
1950.463,
1705.167,
1749.023,
1768.05,
1804.543,
1815.386,
1771.942,
1748.632
]
},
"peak_rss_kib": {
"median": 820180.0,
"p25": 820030.0,
"p75": 820502.0,
"min": 819472,
"max": 820884,
"samples": [
819472,
820416,
820060,
820096,
820264,
820884,
819940,
820760
]
}
},
"a01d47f2": {
"phase_ms": {
"median": 430.783,
"p25": 427.542,
"p75": 451.763,
"min": 423.035,
"max": 550.345,
"samples": [
491.599,
423.035,
428.85,
427.694,
432.715,
427.084,
438.484,
550.345
]
},
"total_ms": {
"median": 868.457,
"p25": 863.709,
"p75": 932.066,
"min": 837.75,
"max": 997.779,
"samples": [
911.935,
837.75,
865.419,
868.626,
858.578,
997.779,
868.288,
992.46
]
},
"cpu_ms": {
"median": 897.639,
"p25": 887.661,
"p75": 944.363,
"min": 865.497,
"max": 1001.635,
"samples": [
925.321,
865.497,
899.505,
895.774,
876.929,
1001.635,
891.239,
1001.488
]
},
"peak_rss_kib": {
"median": 351906.0,
"p25": 351505.0,
"p75": 352181.0,
"min": 351176,
"max": 352440,
"samples": [
352008,
352120,
351352,
351556,
351804,
351176,
352440,
352364
]
}
},
"cf837118": {
"phase_ms": {
"median": 408.459,
"p25": 398.987,
"p75": 428.585,
"min": 387.999,
"max": 524.425,
"samples": [
433.521,
387.999,
412.195,
397.366,
404.724,
399.527,
426.939,
524.425
]
},
"total_ms": {
"median": 851.294,
"p25": 823.291,
"p75": 890.55,
"min": 801.356,
"max": 1030.737,
"samples": [
892.054,
801.356,
840.381,
818.285,
862.208,
824.96,
890.049,
1030.737
]
},
"cpu_ms": {
"median": 870.765,
"p25": 850.849,
"p75": 907.922,
"min": 825.718,
"max": 1041.996,
"samples": [
906.657,
825.718,
866.472,
843.591,
875.058,
853.268,
911.715,
1041.996
]
},
"peak_rss_kib": {
"median": 274172.0,
"p25": 273909.0,
"p75": 274438.0,
"min": 273668,
"max": 274964,
"samples": [
274372,
274964,
273668,
273972,
274552,
274400,
273840,
273932
]
}
},
"cd61e11b": {
"phase_ms": {
"median": 419.114,
"p25": 404.191,
"p75": 460.634,
"min": 388.557,
"max": 735.687,
"samples": [
457.971,
418.303,
388.557,
405.041,
419.925,
401.64,
468.624,
735.687
]
},
"total_ms": {
"median": 840.723,
"p25": 835.398,
"p75": 917.995,
"min": 811.43,
"max": 1565.471,
"samples": [
906.328,
837.706,
811.43,
835.727,
843.74,
834.412,
952.996,
1565.471
]
},
"cpu_ms": {
"median": 863.404,
"p25": 858.916,
"p75": 934.2,
"min": 833.955,
"max": 1550.132,
"samples": [
916.017,
864.82,
833.955,
859.057,
861.989,
858.494,
988.748,
1550.132
]
},
"peak_rss_kib": {
"median": 267508.0,
"p25": 267310.0,
"p75": 267890.0,
"min": 267172,
"max": 268412,
"samples": [
267304,
268292,
267312,
267756,
267340,
267676,
267172,
268412
]
}
},
"current": {
"phase_ms": {
"median": 406.779,
"p25": 397.839,
"p75": 413.957,
"min": 385.028,
"max": 552.051,
"samples": [
387.136,
385.028,
409.317,
404.242,
401.407,
411.451,
421.476,
552.051
]
},
"total_ms": {
"median": 819.787,
"p25": 814.211,
"p75": 855.69,
"min": 800.867,
"max": 1128.453,
"samples": [
808.685,
800.867,
816.053,
817.907,
821.667,
843.26,
892.982,
1128.453
]
},
"cpu_ms": {
"median": 847.078,
"p25": 836.598,
"p75": 877.524,
"min": 826.002,
"max": 1134.369,
"samples": [
834.665,
826.002,
837.243,
842.86,
851.296,
864.892,
915.419,
1134.369
]
},
"peak_rss_kib": {
"median": 267956.0,
"p25": 267612.0,
"p75": 268326.0,
"min": 267308,
"max": 268428,
"samples": [
268428,
267308,
268260,
267516,
267652,
268312,
268368,
267644
]
}
}
},
"experiment_note": "Experimental projection variant (reused sounds, Arc-keyed string pools and direct flags); reverted after mixed CPU/RSS evidence. Current in this file is not the final PR version."
}
@@ -0,0 +1,582 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 10,
"ordering": [
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
],
[
"baseline",
"a01d47f2",
"cf837118",
"cd61e11b",
"current"
],
[
"current",
"cd61e11b",
"cf837118",
"a01d47f2",
"baseline"
]
],
"load_average_before": [
11.4921875,
7.46875,
6.69677734375
],
"load_average_after": [
17.763671875,
11.39013671875,
8.20556640625
],
"executables": {
"baseline": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-e422bd13",
"mode": "baseline",
"sha256": "bf61f2e6c92e001be3c3bc93cf8072cc6af6fd1d7bcfa7273045960948ec89e7"
},
"a01d47f2": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-a01d47f2",
"mode": "shared",
"sha256": "88be56f8dbd28d0b4abd6c672486ba758df96ba18f68da45b94db14231dd3eea"
},
"cf837118": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
"mode": "deferred",
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
},
"cd61e11b": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
"mode": "deferred",
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
},
"current": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-integrated",
"mode": "deferred",
"sha256": "a52d6a7efc7b3198ef0dd8150325f56a3fe5d0c5883fd6d14c4b0c281866ce7c"
}
},
"metrics": {
"baseline": {
"phase_ms": {
"median": 1506.588,
"p25": 1388.314,
"p75": 2119.85,
"min": 1319.319,
"max": 3143.124,
"samples": [
3143.124,
2483.307,
2124.952,
2104.544,
1525.153,
1396.75,
1381.771,
1488.022,
1385.502,
1319.319
]
},
"total_ms": {
"median": 1962.122,
"p25": 1858.188,
"p75": 3152.608,
"min": 1804.713,
"max": 4136.912,
"samples": [
4136.912,
3250.285,
3134.13,
3158.767,
2001.229,
1883.076,
1849.892,
1923.016,
1818.34,
1804.713
]
},
"cpu_ms": {
"median": 1969.296,
"p25": 1893.348,
"p75": 2694.824,
"min": 1842.049,
"max": 3059.824,
"samples": [
3059.824,
2765.392,
2698.837,
2682.786,
2026.89,
1911.703,
1888.282,
1908.545,
1859.233,
1842.049
]
},
"peak_rss_kib": {
"median": 819952.0,
"p25": 819778.0,
"p75": 820164.0,
"min": 818944,
"max": 820304,
"samples": [
819956,
819744,
818944,
818984,
820128,
820176,
819948,
820236,
819880,
820304
]
}
},
"a01d47f2": {
"phase_ms": {
"median": 666.765,
"p25": 520.278,
"p75": 1156.022,
"min": 450.677,
"max": 1882.249,
"samples": [
1001.208,
1309.911,
1207.627,
1882.249,
747.233,
544.933,
512.06,
450.677,
466.596,
586.298
]
},
"total_ms": {
"median": 1141.968,
"p25": 974.723,
"p75": 2326.553,
"min": 922.496,
"max": 3158.114,
"samples": [
2374.699,
2275.18,
2343.677,
3158.114,
1267.594,
1006.411,
964.16,
922.496,
936.687,
1016.342
]
},
"cpu_ms": {
"median": 1106.901,
"p25": 973.629,
"p75": 1525.998,
"min": 929.573,
"max": 1568.573,
"samples": [
1545.278,
1568.573,
1468.158,
1563.377,
1196.236,
1017.565,
960.219,
929.573,
946.096,
1013.861
]
},
"peak_rss_kib": {
"median": 351564.0,
"p25": 351148.0,
"p75": 352106.0,
"min": 350416,
"max": 353368,
"samples": [
351572,
351268,
351108,
350416,
351656,
353368,
352256,
351556,
350964,
352308
]
}
},
"cf837118": {
"phase_ms": {
"median": 505.124,
"p25": 435.315,
"p75": 1103.87,
"min": 417.056,
"max": 1875.147,
"samples": [
1875.147,
1129.722,
1066.305,
1116.391,
461.019,
423.38,
417.056,
445.353,
549.229,
431.969
]
},
"total_ms": {
"median": 1209.375,
"p25": 895.862,
"p75": 2133.144,
"min": 872.434,
"max": 2936.234,
"samples": [
2936.234,
2215.538,
1885.964,
2695.131,
1378.495,
879.351,
897.968,
895.16,
1040.255,
872.434
]
},
"cpu_ms": {
"median": 1068.41,
"p25": 896.926,
"p75": 1488.578,
"min": 882.398,
"max": 1570.652,
"samples": [
1530.601,
1520.851,
1391.76,
1570.652,
1116.403,
900.779,
894.039,
895.642,
1020.417,
882.398
]
},
"peak_rss_kib": {
"median": 273832.0,
"p25": 273592.0,
"p75": 274131.0,
"min": 272732,
"max": 274976,
"samples": [
274080,
272732,
273560,
273468,
274200,
274148,
273728,
273936,
274976,
273688
]
}
},
"cd61e11b": {
"phase_ms": {
"median": 429.481,
"p25": 410.043,
"p75": 1178.091,
"min": 399.576,
"max": 1644.881,
"samples": [
1194.624,
1128.491,
1644.881,
1428.78,
405.544,
434.62,
424.341,
414.829,
408.447,
399.576
]
},
"total_ms": {
"median": 902.865,
"p25": 868.889,
"p75": 2443.447,
"min": 827.16,
"max": 2961.261,
"samples": [
2682.056,
2199.278,
2524.837,
2961.261,
884.838,
885.282,
848.79,
863.573,
920.448,
827.16
]
},
"cpu_ms": {
"median": 920.917,
"p25": 869.777,
"p75": 1475.629,
"min": 840.036,
"max": 1626.459,
"samples": [
1518.201,
1496.456,
1413.149,
1626.459,
893.762,
899.049,
861.782,
861.417,
942.785,
840.036
]
},
"peak_rss_kib": {
"median": 267762.0,
"p25": 267612.0,
"p75": 267966.0,
"min": 266300,
"max": 268636,
"samples": [
266672,
268636,
266300,
267596,
267660,
268280,
267968,
267680,
267960,
267844
]
}
},
"current": {
"phase_ms": {
"median": 460.489,
"p25": 429.004,
"p75": 986.935,
"min": 404.103,
"max": 1300.033,
"samples": [
992.891,
1126.098,
1300.033,
969.067,
443.604,
408.397,
472.429,
424.137,
448.549,
404.103
]
},
"total_ms": {
"median": 935.623,
"p25": 874.18,
"p75": 2282.679,
"min": 839.642,
"max": 2965.198,
"samples": [
2320.566,
2169.019,
2965.198,
2531.591,
924.347,
839.642,
946.899,
873.617,
875.87,
854.328
]
},
"cpu_ms": {
"median": 950.702,
"p25": 892.992,
"p75": 1540.918,
"min": 861.669,
"max": 1586.478,
"samples": [
1557.765,
1503.564,
1553.369,
1586.478,
931.391,
861.669,
970.013,
888.14,
907.549,
868.73
]
},
"peak_rss_kib": {
"median": 268010.0,
"p25": 267013.0,
"p75": 268385.0,
"min": 266144,
"max": 268876,
"samples": [
266824,
267580,
266516,
266144,
267976,
268304,
268044,
268480,
268412,
268876
]
},
"behaviour_json_ms": {
"median": 34.288,
"p25": 23.085,
"p75": 37.474,
"min": 20.137,
"max": 70.397,
"samples": [
37.07,
70.397,
37.609,
38.225,
34.568,
21.094,
23.469,
22.957,
34.008,
20.137
]
},
"facts_ms": {
"median": 272.839,
"p25": 256.553,
"p75": 618.472,
"min": 248.146,
"max": 780.388,
"samples": [
625.908,
693.735,
780.388,
596.165,
272.718,
249.954,
272.959,
264.913,
253.767,
248.146
]
},
"behaviours_ms": {
"median": 162.633,
"p25": 133.345,
"p75": 326.117,
"min": 132.828,
"max": 476.9,
"samples": [
325.198,
356.28,
476.9,
326.424,
132.828,
133.946,
167.929,
133.144,
157.337,
132.934
]
},
"post_rss_kib": {
"median": 268408.0,
"p25": 267369.0,
"p75": 268861.0,
"min": 267052.0,
"max": 269072.0,
"samples": [
267084.0,
268056.0,
267052.0,
267140.0,
268184.0,
268632.0,
268660.0,
269072.0,
268928.0,
268960.0
]
}
}
}
}
@@ -0,0 +1,188 @@
{
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
"resources": ".local/horizon-client/26.3",
"repeats": 5,
"comparison": "historical cf837118 release binary",
"reference_binary": "target/release/examples/shared_json_startup_profile-cf837118",
"metrics": {
"baseline": {
"phase_ms": {
"median": 1462.003,
"samples": [
1639.999,
1506.864,
1429.844,
1462.003,
1346.061
]
},
"total_ms": {
"median": 1922.795,
"samples": [
3383.516,
1990.554,
1912.354,
1922.795,
1777.912
]
},
"cpu_ms": {
"median": 1961.459,
"samples": [
2419.086,
2021.457,
1951.587,
1961.459,
1839.984
]
},
"peak_rss_kib": {
"median": 820912,
"samples": [
819956,
821052,
820912,
821792,
820424
]
}
},
"pr607": {
"phase_ms": {
"median": 486.715,
"samples": [
460.738,
442.013,
623.053,
486.715,
550.652
]
},
"total_ms": {
"median": 948.089,
"samples": [
923.057,
935.599,
1109.64,
948.089,
1052.546
]
},
"cpu_ms": {
"median": 968.424,
"samples": [
928.489,
946.932,
1128.431,
968.424,
1079.642
]
},
"peak_rss_kib": {
"median": 351796,
"samples": [
351796,
351844,
351536,
351532,
351988
]
}
},
"cf837118": {
"phase_ms": {
"median": 474.268,
"samples": [
443.122,
474.268,
520.497,
433.525,
532.058
]
},
"total_ms": {
"median": 945.87,
"samples": [
934.722,
945.87,
1006.539,
888.201,
1032.6
]
},
"cpu_ms": {
"median": 953.668,
"samples": [
941.957,
953.668,
1029.122,
903.574,
1065.025
]
},
"peak_rss_kib": {
"median": 274476,
"samples": [
273448,
274844,
274476,
274556,
274252
]
}
},
"current": {
"phase_ms": {
"median": 445.924,
"samples": [
472.165,
427.797,
419.288,
445.924,
524.211
]
},
"total_ms": {
"median": 999.669,
"samples": [
999.669,
892.616,
1051.427,
940.595,
1112.158
]
},
"cpu_ms": {
"median": 1001.24,
"samples": [
1001.24,
887.874,
1051.732,
951.495,
1132.208
]
},
"peak_rss_kib": {
"median": 267604,
"samples": [
267716,
267920,
267448,
267604,
267344
]
}
}
},
"builds": {
"current": "release example built at cf837118 + current uncommitted delta in PR #607 worktree",
"historical": "release example built at exact cf837118 commit, with same Cargo release profile and shared dependency target"
},
"mode_commits": {
"baseline": "independent loader algorithm at e422bd13, executed in current binary",
"pr607": "shared early construction algorithm at a01d47f2, executed in current binary",
"cf837118": "exact historical binary at cf837118, deferred mode",
"current": "current PR worktree, deferred mode with interned strings"
},
"limitations": "Loader-only (Assets + BlockFacts + Behaviours), no full client startup or first-frame timing. Same release profile but different compilation contexts for historical binary; hardware activity and cache warmth vary. CPU and elapsed differences between cf837118 and current are mixed and may be noise. Peak RSS is whole-process Linux ru_maxrss."
}
@@ -0,0 +1,93 @@
{
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
"resources": ".local/horizon-client/26.3",
"repeats": 5,
"metrics": {
"baseline": {
"phase_ms": {
"median": 4730.402,
"samples": [
3258.541,
6539.764,
4773.735,
3980.032,
4730.402
]
},
"total_ms": {
"median": 6325.026,
"samples": [
6596.141,
7627.893,
6296.443,
5435.408,
6325.026
]
},
"cpu_ms": {
"median": 3199.722,
"samples": [
3322.801,
3199.722,
3227.438,
3042.687,
3144.658
]
},
"peak_rss_kib": {
"median": 820000,
"samples": [
818540,
820400,
819352,
820000,
820128
]
}
},
"shared": {
"phase_ms": {
"median": 1180.588,
"samples": [
869.228,
842.213,
1180.588,
1560.087,
1728.575
]
},
"total_ms": {
"median": 2260.709,
"samples": [
1632.489,
1553.753,
2260.709,
2879.728,
3388.639
]
},
"cpu_ms": {
"median": 1603.393,
"samples": [
1568.288,
1432.106,
1603.393,
1604.423,
1619.02
]
},
"peak_rss_kib": {
"median": 351728,
"samples": [
353256,
353664,
351728,
350724,
351668
]
}
}
},
"build": "cargo build --release -p horizon-client --example shared_json_startup_profile",
"limitations": "Loader-only wall-clock timings under concurrent machine load; not full startup or time to first frame. RSS includes Assets::load."
}
@@ -0,0 +1,227 @@
{
"scope": "Assets + block facts + behaviours loader only; excludes full startup",
"resources": ".local/horizon-client/26.3",
"repeats": 5,
"metrics": {
"baseline": {
"phase_ms": {
"median": 1996.921,
"samples": [
2213.335,
1453.207,
1637.901,
1996.921,
2010.335
],
"range": [
1453.207,
2213.335
]
},
"total_ms": {
"median": 4728.136,
"samples": [
5628.975,
2147.329,
2162.696,
4728.136,
6404.524
],
"range": [
2147.329,
6404.524
]
},
"cpu_ms": {
"median": 2514.776,
"samples": [
2919.908,
2088.239,
2212.892,
2716.104,
2514.776
],
"range": [
2088.239,
2919.908
]
},
"peak_rss_kib": {
"median": 819824,
"samples": [
819824,
820872,
820744,
818980,
818816
]
}
},
"pr607": {
"phase_ms": {
"median": 586.707,
"samples": [
464.424,
739.67,
586.707,
1373.307,
509.266
],
"range": [
464.424,
1373.307
]
},
"total_ms": {
"median": 1128.6,
"samples": [
954.73,
1764.614,
1128.6,
4312.528,
1030.735
],
"range": [
954.73,
4312.528
]
},
"cpu_ms": {
"median": 1145.826,
"samples": [
981.058,
1465.79,
1145.826,
1736.807,
1056.992
],
"range": [
981.058,
1736.807
]
},
"peak_rss_kib": {
"median": 351900,
"samples": [
352164,
351816,
351900,
352348,
350492
]
}
},
"shared": {
"phase_ms": {
"median": 560.117,
"samples": [
522.284,
1255.75,
560.117,
2151.064,
479.329
],
"range": [
479.329,
2151.064
]
},
"total_ms": {
"median": 1101.305,
"samples": [
1011.715,
2565.449,
1101.305,
2960.186,
1006.817
],
"range": [
1006.817,
2960.186
]
},
"cpu_ms": {
"median": 1128.214,
"samples": [
1035.19,
1565.383,
1128.214,
1494.36,
1031.198
],
"range": [
1031.198,
1565.383
]
},
"peak_rss_kib": {
"median": 351532,
"samples": [
351624,
350540,
351532,
351716,
350076
]
}
},
"deferred": {
"phase_ms": {
"median": 564.046,
"samples": [
433.35,
665.149,
587.456,
564.046,
497.422
],
"range": [
433.35,
665.149
]
},
"total_ms": {
"median": 1143.129,
"samples": [
885.053,
1143.129,
1195.869,
1074.27,
1162.103
],
"range": [
885.053,
1195.869
]
},
"cpu_ms": {
"median": 1114.261,
"samples": [
910.946,
1128.96,
1194.638,
1114.261,
1073.823
],
"range": [
910.946,
1194.638
]
},
"peak_rss_kib": {
"median": 273776,
"samples": [
274320,
273860,
272548,
273776,
272520
]
}
}
},
"baseline_commit": "e422bd13",
"pr607_commit": "a01d47f2",
"measurement": "same optimized binary, isolated processes in alternating forward/reverse four-mode runs",
"limitations": "Includes Assets::load, BlockFacts, Behaviours; excludes complete client startup, pack/mod root resolution, graphics and first frame. Different modes emulate the original independent, PR #607 at a01d47f2, paired and deferred startup sequences in the same release build. Machine was not idle. Elapsed times show high variance; no statistically established gain between PR, paired and deferred modes."
}
@@ -0,0 +1,453 @@
{
"scope": "Assets + BlockFacts + Behaviours loader only; no full startup or first frame",
"resources": "/srv/bob-data/horizon/.local/horizon-client/26.3",
"repeats": 8,
"ordering": [
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
],
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
],
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
],
[
"cf837118",
"cd61e11b",
"arc-key",
"reused-sounds",
"current"
],
[
"current",
"reused-sounds",
"arc-key",
"cd61e11b",
"cf837118"
]
],
"load_average_before": [
10.9873046875,
8.37646484375,
6.91015625
],
"load_average_after": [
8.6962890625,
8.1591796875,
6.90087890625
],
"executables": {
"cf837118": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cf837118",
"mode": "deferred",
"sha256": "f1b044cd9ac2f67c644fb40bd7ecbe38128ec739a8112d57fc36f05fe9036a52"
},
"cd61e11b": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-cd61e11b",
"mode": "deferred",
"sha256": "fb2d3725b09ee38f85b49901f7053a6fe339b980a2b2dac6930a8ded22c139e8"
},
"arc-key": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-arcpool",
"mode": "deferred",
"sha256": "8cbb53d01afedf5c7a5da0e36c9f8c3569f34def9bc51488fe8699e7f615a131"
},
"reused-sounds": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-reusesounds",
"mode": "deferred",
"sha256": "f45794bfbdbb3f9b5d34c375d397191ac67984b3ae91268aa3fdd02f2c1b7b98"
},
"current": {
"path": "/srv/bob-data/horizon/.worktrees/shared-block-json-20261017/target/release/examples/shared_json_startup_profile-new",
"mode": "deferred",
"sha256": "84eb1baae8e138f52e300b0b44f1339fca38e70db6e06126e78db776ea3e5cd0"
}
},
"metrics": {
"cf837118": {
"phase_ms": {
"median": 424.082,
"p25": 417.508,
"p75": 430.923,
"min": 395.439,
"max": 948.609,
"samples": [
948.609,
425.085,
425.571,
417.945,
395.439,
416.197,
423.078,
446.98
]
},
"total_ms": {
"median": 853.026,
"p25": 839.421,
"p75": 867.731,
"min": 813.144,
"max": 1906.081,
"samples": [
1906.081,
861.641,
864.233,
844.411,
813.144,
830.783,
842.3,
878.226
]
},
"cpu_ms": {
"median": 876.143,
"p25": 862.934,
"p75": 891.4,
"min": 837.769,
"max": 1517.372,
"samples": [
1517.372,
885.13,
887.888,
865.404,
837.769,
855.526,
867.157,
901.937
]
},
"peak_rss_kib": {
"median": 273904.0,
"p25": 273717.0,
"p75": 274142.0,
"min": 273648,
"max": 274836,
"samples": [
273648,
273936,
273872,
274836,
274064,
274376,
273740,
273648
]
}
},
"cd61e11b": {
"phase_ms": {
"median": 422.356,
"p25": 406.294,
"p75": 425.846,
"min": 394.111,
"max": 472.536,
"samples": [
425.832,
420.666,
472.536,
425.886,
400.588,
394.111,
424.047,
408.196
]
},
"total_ms": {
"median": 852.475,
"p25": 828.474,
"p75": 876.413,
"min": 817.3,
"max": 951.587,
"samples": [
951.587,
865.442,
899.564,
868.696,
823.74,
817.3,
839.508,
830.052
]
},
"cpu_ms": {
"median": 875.04,
"p25": 850.012,
"p75": 900.57,
"min": 837.672,
"max": 938.285,
"samples": [
938.285,
888.533,
926.846,
891.812,
845.956,
837.672,
861.548,
851.364
]
},
"peak_rss_kib": {
"median": 268086.0,
"p25": 267719.0,
"p75": 268486.0,
"min": 267444,
"max": 268920,
"samples": [
267776,
268660,
267752,
268920,
268428,
268396,
267444,
267620
]
}
},
"arc-key": {
"phase_ms": {
"median": 421.422,
"p25": 401.781,
"p75": 451.774,
"min": 394.041,
"max": 472.337,
"samples": [
446.155,
472.337,
468.632,
420.636,
400.91,
394.041,
422.207,
402.072
]
},
"total_ms": {
"median": 866.601,
"p25": 822.563,
"p75": 918.038,
"min": 817.092,
"max": 1001.287,
"samples": [
1001.287,
918.457,
917.898,
892.666,
817.092,
824.225,
840.536,
817.576
]
},
"cpu_ms": {
"median": 887.84,
"p25": 844.533,
"p75": 937.331,
"min": 838.687,
"max": 991.212,
"samples": [
991.212,
949.048,
933.425,
911.921,
838.687,
845.021,
863.759,
843.069
]
},
"peak_rss_kib": {
"median": 267728.0,
"p25": 267598.0,
"p75": 268039.0,
"min": 267452,
"max": 268636,
"samples": [
268264,
267616,
267840,
267964,
268636,
267452,
267580,
267604
]
}
},
"reused-sounds": {
"phase_ms": {
"median": 421.683,
"p25": 412.913,
"p75": 439.098,
"min": 395.338,
"max": 487.446,
"samples": [
487.446,
434.111,
402.815,
416.279,
424.524,
454.058,
395.338,
418.842
]
},
"total_ms": {
"median": 879.846,
"p25": 845.021,
"p75": 901.166,
"min": 823.721,
"max": 1013.539,
"samples": [
1013.539,
892.033,
928.565,
884.284,
847.853,
875.407,
823.721,
836.527
]
},
"cpu_ms": {
"median": 894.546,
"p25": 871.499,
"p75": 921.844,
"min": 844.891,
"max": 1028.688,
"samples": [
1028.688,
913.48,
946.935,
903.785,
868.684,
885.308,
844.891,
872.438
]
},
"peak_rss_kib": {
"median": 267960.0,
"p25": 267678.0,
"p75": 268133.0,
"min": 267212,
"max": 268404,
"samples": [
267212,
267888,
268404,
267692,
268096,
267636,
268244,
268032
]
}
},
"current": {
"phase_ms": {
"median": 411.403,
"p25": 406.814,
"p75": 452.869,
"min": 391.768,
"max": 532.402,
"samples": [
532.402,
442.563,
408.857,
410.202,
483.788,
391.768,
400.683,
412.604
]
},
"total_ms": {
"median": 905.017,
"p25": 851.488,
"p75": 937.934,
"min": 818.206,
"max": 1075.445,
"samples": [
1075.445,
878.155,
837.627,
955.655,
932.027,
818.206,
856.108,
931.88
]
},
"cpu_ms": {
"median": 922.9,
"p25": 874.849,
"p75": 956.282,
"min": 839.639,
"max": 1067.693,
"samples": [
1067.693,
900.18,
862.137,
970.356,
951.591,
839.639,
879.086,
945.62
]
},
"peak_rss_kib": {
"median": 268354.0,
"p25": 267889.0,
"p75": 268492.0,
"min": 267056,
"max": 268820,
"samples": [
268008,
268384,
268564,
268820,
267056,
267532,
268324,
268468
]
}
}
},
"experiment_note": "Exploratory variants (Arc-key pool, reused sounds, combined proposal) alongside cd61e11b; none gave a robust consistent improvement and all were reverted."
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "cargo-horizon-mod"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run: make Horizon mods in Rust (docs/mods.md)"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["pack"] }
serde_json = "1.0.151"
[lints]
workspace = true
+201
View File
@@ -0,0 +1,201 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
//! the mod; run `build` again and the game reloads it.
use horizon_mods::{
Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
};
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode, Stdio},
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
let mut args: Vec<String> = std::env::args().skip(1).collect();
if args.first().is_some_and(|a| a == "horizon-mod") {
args.remove(0);
}
let result = match args.first().map(String::as_str) {
Some("new") => new(&args[1..]),
Some("build") => build(!args.iter().any(|a| a == "--debug")).map(|p| {
println!("Built {}", p.display());
}),
Some("run") => run(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>]".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// The value after `flag` in `args`.
fn flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
let i = args.iter().position(|a| a == flag)?;
args.get(i + 1).map(String::as_str)
}
fn new(args: &[String]) -> Result<(), String> {
let path = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("cargo horizon-mod new <path>")?;
let dir = Path::new(path);
if dir.exists() {
return Err(format!("{} already exists", dir.display()));
}
let base = dir
.file_name()
.and_then(|n| n.to_str())
.ok_or("the path needs a folder name")?;
let id: String = base
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
}
None => SDK_GIT.to_string(),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
let files = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(MANIFEST, include_str!("../template/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/src/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
for (file, text) in files {
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
}
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
);
Ok(())
}
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
fn build(release: bool) -> Result<PathBuf, String> {
let manifest_text = std::fs::read_to_string(MANIFEST)
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
let manifest = Manifest::parse(&manifest_text)?;
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args([
"build",
"--lib",
"--target",
TARGET,
"--message-format=json-render-diagnostics",
]);
if release {
cmd.arg("--release");
}
let out = cmd
.stderr(Stdio::inherit())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!(
"cargo build failed (is the target installed? rustup target add {TARGET})"
));
}
let core = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|m| m["reason"] == "compiler-artifact")
.flat_map(|m| m["filenames"].as_array().cloned().unwrap_or_default())
.filter_map(|f| f.as_str().map(PathBuf::from))
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
// The game refuses a component importing capabilities the manifest lacks.
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
let dir = target_dir.join("horizon-mod");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{}.hmod", manifest.id));
// Write then rename, so a running game never reads half a file.
let partial = path.with_extension("hmod.partial");
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
Ok(path)
}
fn run(args: &[String]) -> Result<(), String> {
let path = build(true)?;
let game = flag(args, "--game")
.map(str::to_string)
.or_else(|| std::env::var("HORIZON_CLIENT").ok())
.unwrap_or_else(|| "horizon-client".into());
let extra = args.iter().skip_while(|a| *a != "--").skip(1);
println!(
"Starting {game} with {} (rebuild with `cargo horizon-mod build` to reload it)",
path.display()
);
let status = Command::new(&game)
.arg("--dev-mod")
.arg(&path)
.args(extra)
.status()
.map_err(|e| {
format!("{game}: {e} (pass --game <path to horizon-client> or set HORIZON_CLIENT)")
})?;
status
.success()
.then_some(())
.ok_or_else(|| format!("the game exited with {status}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-new-{}", std::process::id()));
let path = dir.join("My-Mod");
new(&[path.display().to_string(), "--name".into(), "My Mod".into()]).unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(
(manifest.id.as_str(), manifest.name.as_str()),
("my_mod", "My Mod")
);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("name = \"my-mod\"") && cargo.contains(SDK_GIT));
assert!(
new(&[path.display().to_string()]).is_err(),
"never overwrites"
);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,14 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
horizon-mod-sdk = {sdk}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1 @@
/target
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the SDK targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,19 @@
use horizon_mod_sdk::{Mod, Registration, chat};
struct MyMod {
greeted: u32,
}
impl Mod for MyMod {
fn init() -> Result<(Self, Registration), String> {
Ok((MyMod { greeted: 0 }, Registration::new().command("hello")))
}
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
self.greeted += 1;
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
Ok(None)
}
}
horizon_mod_sdk::export_mod!(MyMod);
@@ -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.
+36 -18
View File
@@ -50,29 +50,47 @@ impl ModelLibrary {
/// A library that also knows `builtin/generated`, the parent of every
/// flat item model; the native client registers it as a special model.
pub fn with_item_generator(mut models: BTreeMap<String, CuboidModel>) -> Result<Self> {
models.insert(
GENERATED_MODEL.to_owned(),
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
},
);
models.insert(GENERATED_MODEL.to_owned(), Self::item_generator_model());
Self::new(models)
}
/// Register the item built-in while reusing already decoded resources.
pub fn with_shared_item_generator(
mut models: BTreeMap<String, Arc<CuboidModel>>,
) -> Result<Self> {
models.insert(
GENERATED_MODEL.to_owned(),
Arc::new(Self::item_generator_model()),
);
Self::from_shared(models)
}
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
"particle".to_owned(),
TextureSlot::Reference("layer0".to_owned()),
)]),
elements: None,
display: None,
}
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
Self::from_models(
models
.into_iter()
.map(|(name, model)| (name, Arc::new(model))),
)
}
/// Build an immutable resolution view without copying model geometry.
pub fn from_shared(models: BTreeMap<String, Arc<CuboidModel>>) -> Result<Self> {
Self::from_models(models)
}
fn from_models(models: impl IntoIterator<Item = (String, Arc<CuboidModel>)>) -> Result<Self> {
let mut decoded = BTreeMap::new();
for (name, model) in models {
if decoded
.insert(identifier(&name)?, Arc::new(model))
.is_some()
{
if decoded.insert(identifier(&name)?, model).is_some() {
return Err(error("duplicate normalized model identifier"));
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "horizon-bench"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
+291
View File
@@ -0,0 +1,291 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
use std::process::Command;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Hardware {
/// `macos`, `linux`, `windows`.
pub os: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
pub arch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_threads: Option<usize>,
/// Physical cores.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_cores: Option<u16>,
/// Base and top boost clock, where the OS gives them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_base_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_boost_mhz: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ram_mb: Option<u64>,
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
/// `laptop` or `desktop`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub form: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<Gpu>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display: Option<Display>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Gpu {
pub name: String,
pub vendor: String,
pub driver: String,
/// `metal`, `vulkan`, `dx12`, `gl`.
pub backend: String,
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
pub kind: String,
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vram_mb: Option<u32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Display {
/// Physical pixels.
pub width: u32,
pub height: u32,
pub scale: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_hz: Option<u32>,
}
impl Hardware {
pub fn detect() -> Hardware {
let probes = probes();
Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
display: None,
}
}
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
Hardware {
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
..Hardware::detect()
}
}
}
/// What the telemetry probes read (`horizon_telemetry::system`).
struct Probes {
cores: Option<u16>,
base: Option<u32>,
boost: Option<u32>,
form: Option<String>,
}
fn probes() -> Probes {
let s = horizon_telemetry::payload::System::detect();
let some = |n: u32| (n > 0).then_some(n);
Probes {
cores: (s.cores > 0).then_some(s.cores),
base: some(s.cpu_base_mhz),
boost: some(s.cpu_boost_mhz),
form: (!s.form.is_empty()).then_some(s.form),
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
}
}
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
}
fn output(program: &str, args: &[&str]) -> Option<String> {
let out = Command::new(program).args(args).output().ok()?;
let text = String::from_utf8(out.stdout).ok()?.trim().to_owned();
(out.status.success() && !text.is_empty()).then_some(text)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
let name = key_value(&release, "PRETTY_NAME", '=')?
.trim_matches('"')
.to_owned();
Some(match output("uname", &["-r"]) {
Some(kernel) => format!("{name} (kernel {kernel})"),
None => name,
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
let (k, v) = line.split_once(sep)?;
(k.trim() == key).then(|| v.trim())
})
}
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
output("cmd", &["/C", "ver"])
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
std::env::var("PROCESSOR_IDENTIFIER").ok()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_this_machine() {
let hw = Hardware::detect();
assert_eq!(hw.os, std::env::consts::OS);
assert!(hw.cpu_threads.unwrap() >= 1);
if cfg!(any(target_os = "macos", target_os = "linux")) {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
}
+457
View File
@@ -0,0 +1,457 @@
//! The benchmark report format, version 1 (docs/benchmark-report.md).
//!
//! One JSON file per run, the same for the game, the launcher and `tools/perf_bench.py`: the local log
//! a player can attach, and, with consent, what the telemetry upload sends ([`Report::for_upload`]).
//! It holds summaries and fixed-edge histogram counts, never raw frame streams.
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
mod hardware;
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
/// Lower edges of the frame-time histogram buckets, in milliseconds; the last bucket is 1000 ms and
/// over. Fixed by the format version so histograms from any number of reports add up.
pub const HISTOGRAM_EDGES_MS: [f64; 24] = [
0.0, 1.0, 2.0, 3.0, 4.17, 5.0, 6.0, 7.0, 8.33, 10.0, 12.0, 14.0, 16.67, 20.0, 25.0, 33.33,
40.0, 50.0, 66.67, 100.0, 150.0, 250.0, 500.0, 1000.0,
];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Game,
Launcher,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct App {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mods: Vec<Mod>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mod {
pub name: String,
pub version: String,
}
/// What has to match for two reports to be compared. Every field is optional.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Conditions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scenario: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub render_distance: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub simulation_distance: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<[u32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vsync: Option<bool>,
/// Absent: unlimited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fps_cap: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offscreen: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeats: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warmup_s: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub power: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub load_avg_1m: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<String>,
}
/// A measurement over a run's repeats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Summary {
pub n: usize,
pub mean: f64,
/// Sample standard deviation; `None` for a single value.
pub stdev: Option<f64>,
pub min: f64,
pub max: f64,
/// Half-width of the 95% confidence interval of the mean (Student's t); `None` for a single value.
pub ci95: Option<f64>,
}
impl Summary {
/// `None` for no values.
pub fn of(values: &[f64]) -> Option<Summary> {
let n = values.len();
if n == 0 {
return None;
}
let mean = values.iter().sum::<f64>() / n as f64;
let (stdev, ci95) = if n > 1 {
let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
let sd = var.sqrt();
(Some(sd), Some(t95(n - 1) * sd / (n as f64).sqrt()))
} else {
(None, None)
};
Some(Summary {
n,
mean: round(mean),
stdev: stdev.map(round),
min: round(values.iter().copied().fold(f64::INFINITY, f64::min)),
max: round(values.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
ci95: ci95.map(round),
})
}
}
/// Two-sided 95% critical value of Student's t for `df` degrees of freedom.
fn t95(df: usize) -> f64 {
const TABLE: [f64; 30] = [
12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.160,
2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, 2.080, 2.074, 2.069, 2.064, 2.060, 2.056,
2.052, 2.048, 2.045, 2.042,
];
TABLE.get(df.wrapping_sub(1)).copied().unwrap_or(1.96)
}
/// One measured phase of one repeat.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Frames {
pub phase: String,
pub repeat: u32,
pub frames: usize,
pub seconds: f64,
pub avg_fps: f64,
pub low_1pct_fps: f64,
pub low_01pct_fps: f64,
pub p50_ms: f64,
pub p95_ms: f64,
pub p99_ms: f64,
pub p999_ms: f64,
pub max_ms: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_p50_ms: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu_p50_ms: Option<f64>,
/// Frame counts per bucket of [`HISTOGRAM_EDGES_MS`].
pub histogram: Vec<u64>,
}
impl Frames {
/// The figures of a phase from its frame times in milliseconds (time between the starts of
/// consecutive frames); `None` for no frames. CPU and GPU times are per frame too, when measured.
pub fn of(
phase: &str,
repeat: u32,
frame_ms: &[f32],
cpu_ms: &[f32],
gpu_ms: &[f32],
) -> Option<Frames> {
let times = sorted(frame_ms);
let n = times.len();
if n == 0 {
return None;
}
let total: f64 = times.iter().sum();
// The average frame time of the slowest `share` of frames, as frames per second.
let low = |share: f64| {
let slowest = &times[n - ((n as f64 * share).ceil() as usize).max(1)..];
1000.0 * slowest.len() as f64 / slowest.iter().sum::<f64>()
};
let mut histogram = vec![0; HISTOGRAM_EDGES_MS.len()];
for &t in &times {
histogram[HISTOGRAM_EDGES_MS.partition_point(|&edge| edge <= t).max(1) - 1] += 1;
}
Some(Frames {
phase: phase.to_owned(),
repeat,
frames: n,
seconds: round(total / 1000.0),
avg_fps: round(n as f64 * 1000.0 / total),
low_1pct_fps: round(low(0.01)),
low_01pct_fps: round(low(0.001)),
p50_ms: round(percentile(&times, 0.5)),
p95_ms: round(percentile(&times, 0.95)),
p99_ms: round(percentile(&times, 0.99)),
p999_ms: round(percentile(&times, 0.999)),
max_ms: round(times[n - 1]),
cpu_p50_ms: (!cpu_ms.is_empty()).then(|| round(percentile(&sorted(cpu_ms), 0.5))),
gpu_p50_ms: (!gpu_ms.is_empty()).then(|| round(percentile(&sorted(gpu_ms), 0.5))),
histogram,
})
}
}
fn sorted(ms: &[f32]) -> Vec<f64> {
let mut v: Vec<f64> = ms
.iter()
.map(|&m| f64::from(m))
.filter(|m| m.is_finite())
.collect();
v.sort_by(f64::total_cmp);
v
}
/// Three decimals: plenty for milliseconds and FPS, short in JSON, and parsed back exactly.
fn round(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
/// Nearest-rank percentile of sorted, non-empty values.
fn percentile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1]
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Report {
pub format: String,
pub version: u32,
pub kind: Kind,
/// When the run ended, UTC, `YYYY-MM-DDTHH:MM:SSZ`.
pub created: String,
pub app: App,
pub hardware: Hardware,
#[serde(default)]
pub conditions: Conditions,
/// Scalar measurements by name; the unit is the name's suffix (`_ms`, `_mb`...).
#[serde(default)]
pub metrics: BTreeMap<String, Summary>,
#[serde(default)]
pub frames: Vec<Frames>,
/// Local only: a name the person running it chose.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// Local only: logs the run wrote next to the report.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub local: Vec<PathBuf>,
}
impl Report {
/// An empty report for this machine, created now.
pub fn new(kind: Kind, app: App) -> Report {
Report {
format: FORMAT.to_owned(),
version: VERSION,
kind,
created: utc_now(),
app,
hardware: Hardware::detect(),
conditions: Conditions::default(),
metrics: BTreeMap::new(),
frames: Vec::new(),
label: None,
local: Vec::new(),
}
}
/// Records a measurement over the repeats; no values records nothing.
pub fn metric(&mut self, name: &str, values: &[f64]) {
if let Some(summary) = Summary::of(values) {
self.metrics.insert(name.to_owned(), summary);
}
}
/// Reads a report, rejecting other formats and newer versions.
pub fn parse(json: &str) -> Result<Report, String> {
let report: Report = serde_json::from_str(json).map_err(|e| e.to_string())?;
if report.format != FORMAT {
return Err(format!(
"not a benchmark report: format {:?}",
report.format
));
}
if report.version > VERSION {
return Err(format!(
"report version {} is newer than {VERSION}",
report.version
));
}
Ok(report)
}
/// The report without its local-only fields, dated to the day: what an upload sends.
pub fn for_upload(&self) -> Report {
let day = self.created.get(..10).unwrap_or_default();
Report {
label: None,
local: Vec::new(),
created: format!("{day}T00:00:00Z"),
..self.clone()
}
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("a report always serializes")
}
/// Writes `<dir>/<kind>-<created>-<scenario>.json` and returns its path.
pub fn write_to(&self, dir: &Path) -> io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let kind = match self.kind {
Kind::Game => "game",
Kind::Launcher => "launcher",
};
let stamp: String = self.created.chars().filter(char::is_ascii_digit).collect();
let scenario: String = self
.conditions
.scenario
.as_deref()
.unwrap_or("run")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let path = dir.join(format!("{kind}-{stamp}-{scenario}.json"));
std::fs::write(&path, self.to_json() + "\n")?;
Ok(path)
}
}
/// The current time, UTC, `YYYY-MM-DDTHH:MM:SSZ`.
pub fn utc_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
utc(secs)
}
fn utc(secs: u64) -> String {
let (days, rem) = (secs / 86_400, secs % 86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
rem / 60 % 60,
rem % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_dates() {
assert_eq!(utc(0), "1970-01-01T00:00:00Z");
assert_eq!(utc(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(utc(1_791_983_007), "2026-10-14T13:03:27Z");
}
#[test]
fn summary_of_repeats() {
assert_eq!(Summary::of(&[]), None);
let one = Summary::of(&[5.0]).unwrap();
assert_eq!((one.n, one.mean, one.stdev, one.ci95), (1, 5.0, None, None));
let s = Summary::of(&[2.0, 4.0, 6.0]).unwrap();
assert_eq!((s.mean, s.min, s.max), (4.0, 2.0, 6.0));
assert_eq!(s.stdev, Some(2.0));
assert_eq!(s.ci95, Some(4.969), "4.303 * 2 / sqrt(3)");
}
#[test]
fn frame_figures_and_histogram() {
// 998 frames of 2 ms, one of 20 ms, one of 1500 ms.
let mut ms = vec![2.0f32; 998];
ms.extend([20.0, 1500.0]);
let f = Frames::of("flight", 0, &ms, &[], &[]).unwrap();
assert_eq!(f.frames, 1000);
assert_eq!(f.seconds, 3.516);
assert_eq!((f.p50_ms, f.p999_ms, f.max_ms), (2.0, 20.0, 1500.0));
// Slowest 1% = 10 frames: 8 of 2 ms, 20 ms, 1500 ms.
assert_eq!(f.low_1pct_fps, 6.51, "1000 * 10 / 1536");
// Slowest 0.1% = the 1500 ms frame.
assert_eq!(f.low_01pct_fps, 0.667);
assert_eq!(f.histogram.len(), HISTOGRAM_EDGES_MS.len());
assert_eq!(f.histogram[2], 998, "2 ms falls in [2, 3)");
assert_eq!(f.histogram[13], 1, "20 ms falls in [20, 25)");
assert_eq!(f.histogram[23], 1, "1500 ms falls in [1000, inf)");
assert_eq!(f.histogram.iter().sum::<u64>(), 1000);
assert_eq!((f.cpu_p50_ms, f.gpu_p50_ms), (None, None));
assert_eq!(Frames::of("x", 0, &[], &[], &[]), None);
}
#[test]
fn round_trip_and_upload_strips_local_fields() {
let mut r = Report::new(
Kind::Game,
App {
name: "horizon-client".into(),
version: "0.1.0".into(),
..App::default()
},
);
r.conditions.scenario = Some("flight".into());
r.metric("startup.title_ms", &[800.0, 820.0]);
r.frames
.extend(Frames::of("village", 0, &[2.0, 3.0], &[1.0], &[]));
r.label = Some("my run".into());
r.local.push("/home/someone/logs/latest.log".into());
let back = Report::parse(&r.to_json()).unwrap();
assert_eq!(back, r);
r.created = "2026-10-15T14:03:27Z".into();
let upload = r.for_upload();
assert_eq!(upload.created, "2026-10-15T00:00:00Z", "the day only");
let upload = upload.to_json();
assert!(
!upload.contains("my run") && !upload.contains("someone"),
"{upload}"
);
}
#[test]
fn rejects_other_formats_and_newer_versions() {
let r = Report::new(Kind::Launcher, App::default());
let newer = r.to_json().replace("\"version\": 1", "\"version\": 2");
assert!(Report::parse(&newer).unwrap_err().contains("newer"));
let other = r.to_json().replace(FORMAT, "something-else");
assert!(Report::parse(&other).is_err());
}
#[test]
fn writes_into_a_folder() {
let dir = std::env::temp_dir().join(format!("horizon-bench-test-{}", std::process::id()));
let mut r = Report::new(Kind::Launcher, App::default());
r.created = "2026-10-15T14:03:27Z".into();
r.conditions.scenario = Some("cold start".into());
let path = r.write_to(&dir).unwrap();
assert_eq!(
path.file_name().unwrap(),
"launcher-20261015140327-cold_start.json"
);
assert_eq!(
Report::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(),
r
);
std::fs::remove_dir_all(dir).unwrap();
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-bots"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Load-test bots for Minecraft Java Edition 26.3 servers (docs/server.md, section 9)"
[dependencies]
horizon-protocol = { path = "../horizon-protocol" }
serde_json = "1.0.151"
[lints]
workspace = true
+697
View File
@@ -0,0 +1,697 @@
//! `horizon-bots`: load-test bots for any Minecraft Java Edition 26.3 server, vanilla, Paper or Horizon
//! (docs/server.md, section 9). Each bot logs in offline, goes through configuration as vanilla's client
//! does, answers keep-alives and pings, accepts teleports, acknowledges chunk batches, and moves at 20 Hz.
//!
//! Bots fly a little above the ground, so the server must give them creative mode (`gamemode=creative`
//! and `force-gamemode=true`): they never touch blocks, so they need no world of their own, and no server
//! can refuse their moves for walking into terrain. Every behaviour is deterministic from `--seed`.
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
use horizon_protocol::{
PROTOCOL_VERSION,
buf::{Reader, Writer},
connection::{ConnWriter, Connection},
crypto::offline_uuid,
ids,
packets::{
Empty, configuration,
handshake::{self, Intention},
login::{self, ServerHello},
play,
},
};
use serde_json::json;
use std::{
collections::HashSet,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
time::{Duration, Instant},
};
const HELP: &str = "\
horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline mode, creative)
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
--join-every SECONDS default 1
--seconds N how long to run after the first bot joins; default 60
--view-distance N what the bots ask for; default 10
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Idle,
Walk,
Explore,
}
impl Behaviour {
fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Walk => "walk",
Self::Explore => "explore",
}
}
}
struct Options {
host: String,
port: u16,
bots: usize,
behaviour: String,
join_every: f64,
seconds: u64,
view_distance: i8,
seed: u64,
explore_speed: f64,
name: String,
report: Option<String>,
}
fn options() -> Result<Options, String> {
let mut o = Options {
host: "127.0.0.1".into(),
port: 25565,
bots: 10,
behaviour: "mixed".into(),
join_every: 1.0,
seconds: 60,
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
name: "Bot".into(),
report: None,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "-h" || arg == "--help" {
print!("{HELP}");
std::process::exit(0);
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
"--server" => {
let (host, port) = value.rsplit_once(':').unwrap_or((&value, "25565"));
o.host = host.into();
o.port = port.parse().map_err(|_| number("a port"))?;
}
"--bots" => o.bots = value.parse().map_err(|_| number("a number"))?,
"--behaviour" => o.behaviour = value,
"--join-every" => o.join_every = value.parse().map_err(|_| number("seconds"))?,
"--seconds" => o.seconds = value.parse().map_err(|_| number("seconds"))?,
"--view-distance" => o.view_distance = value.parse().map_err(|_| number("2 to 32"))?,
"--seed" => o.seed = value.parse().map_err(|_| number("a number"))?,
"--explore-speed" => {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--name" => o.name = value,
"--report" => o.report = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore or mixed",
o.behaviour
));
}
Ok(o)
}
/// What one bot is doing, shared between its reader and its ticks.
#[derive(Default)]
struct Bot {
online: AtomicBool,
chunks: AtomicU64,
/// Chunks received while the bot already held them (no ForgetLevelChunk in between).
resent: AtomicU64,
/// Different chunks received over the run.
unique: AtomicU64,
forgotten: AtomicU64,
/// Teleports after the spawn one: the server refused a move.
pulled_back: AtomicU64,
/// Ticks the bot couldn't move because it didn't hold its chunk yet.
waited: AtomicU64,
/// The chunks the bot holds (ClientChunkCache).
held: Mutex<HashSet<(i32, i32)>>,
/// Where the server last put the bot (a teleport), to start from.
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn unit(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
}
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
let mut w = Writer::new();
w.varint(id);
write(&mut w);
w.data
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// Handshake, offline login and configuration; returns the connection in the play state.
fn join(o: &Options, name: &str) -> Result<Connection, String> {
let mut c = Connection::connect(&o.host, o.port, Duration::from_secs(10)).map_err(err)?;
c.send(
&handshake::Serverbound::Intention(Intention {
protocol: PROTOCOL_VERSION,
host: o.host.clone(),
port: o.port,
intent: handshake::LOGIN,
})
.encode(),
)
.map_err(err)?;
c.send(
&login::Serverbound::Hello(ServerHello {
name: name.into(),
uuid: offline_uuid(name),
})
.encode(),
)
.map_err(err)?;
loop {
let (id, body) = c.recv().map_err(err)?;
match login::Clientbound::decode(id, &body).map_err(err)? {
login::Clientbound::Compression(p) => c.set_compression(p.threshold),
login::Clientbound::LoginFinished(_) => {
c.send(&login::Serverbound::LoginAcknowledged(Empty).encode())
.map_err(err)?;
break;
}
login::Clientbound::Disconnect(p) => return Err(format!("refused: {}", p.reason_json)),
login::Clientbound::Hello(_) => {
return Err("the server is in online mode; bots need online-mode=false".into());
}
login::Clientbound::CustomQuery(p) => c
.send(
&login::Serverbound::CustomQueryAnswer(login::CustomQueryAnswer {
transaction: p.transaction,
payload: None,
})
.encode(),
)
.map_err(err)?,
login::Clientbound::CookieRequest(p) => c
.send(
&login::Serverbound::CookieResponse(login::CookieResponse {
key: p.key,
payload: None,
})
.encode(),
)
.map_err(err)?,
login::Clientbound::Unknown(..) => {}
}
}
use ids::configuration::clientbound as cb;
c.send(
&configuration::Serverbound::ClientInformation(configuration::ClientInformation::new(
o.view_distance,
))
.encode(),
)
.map_err(err)?;
loop {
let (id, body) = c.recv().map_err(err)?;
let reply = match id {
cb::SELECT_KNOWN_PACKS
| cb::KEEP_ALIVE
| cb::PING
| cb::CODE_OF_CONDUCT
| cb::COOKIE_REQUEST
| cb::DISCONNECT
| cb::FINISH_CONFIGURATION => {
match configuration::Clientbound::decode(id, &body).map_err(err)? {
configuration::Clientbound::SelectKnownPacks(p) => {
configuration::Serverbound::SelectKnownPacks(p)
}
configuration::Clientbound::KeepAlive(p) => {
configuration::Serverbound::KeepAlive(p)
}
configuration::Clientbound::Ping(p) => configuration::Serverbound::Pong(p),
configuration::Clientbound::CodeOfConduct(_) => {
configuration::Serverbound::AcceptCodeOfConduct(Empty)
}
configuration::Clientbound::CookieRequest(p) => {
configuration::Serverbound::CookieResponse(configuration::CookieResponse {
key: p.key,
payload: None,
})
}
configuration::Clientbound::Disconnect(_) => {
return Err("disconnected during configuration".into());
}
configuration::Clientbound::FinishConfiguration(_) => {
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
.map_err(err)?;
return Ok(c);
}
_ => continue,
}
}
_ => continue,
};
c.send(&reply.encode()).map_err(err)?;
}
}
/// ChunkBatchSizeCalculator: the chunks per tick a vanilla client asks for, from how long its batches take
/// to arrive.
struct BatchSize {
nanos_per_chunk: f64,
old_samples_weight: u32,
started: Instant,
}
impl BatchSize {
fn new() -> Self {
Self {
nanos_per_chunk: 2_000_000.0,
old_samples_weight: 1,
started: Instant::now(),
}
}
fn finished(&mut self, size: i32) {
if size > 0 {
let per_chunk = self.started.elapsed().as_nanos() as f64 / f64::from(size);
let clamped = per_chunk.clamp(self.nanos_per_chunk / 3.0, self.nanos_per_chunk * 3.0);
let weight = f64::from(self.old_samples_weight);
self.nanos_per_chunk = (self.nanos_per_chunk * weight + clamped) / (weight + 1.0);
self.old_samples_weight = (self.old_samples_weight + 1).min(49);
}
}
fn desired_chunks_per_tick(&self) -> f32 {
(7_000_000.0 / self.nanos_per_chunk) as f32
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
let mut seen = HashSet::new();
let mut batch = BatchSize::new();
let mut teleports = 0u64;
loop {
let (id, body) = match reader.recv() {
Ok(packet) => packet,
Err(e) => {
if bot.online.swap(false, Ordering::SeqCst) {
*bot.error.lock().unwrap() = Some(e.to_string());
}
return;
}
};
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
Ok(v) => send(raw(sb::KEEP_ALIVE, |w| w.i64(v))),
Err(_) => Ok(()),
},
cb::PING => match r.i32() {
Ok(v) => send(raw(sb::PONG, |w| w.i32(v))),
Err(_) => Ok(()),
},
cb::LEVEL_CHUNK_WITH_LIGHT => {
bot.chunks.fetch_add(1, Ordering::Relaxed);
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
if !bot.held.lock().unwrap().insert((x, z)) {
bot.resent.fetch_add(1, Ordering::Relaxed);
}
if seen.insert((x, z)) {
bot.unique.fetch_add(1, Ordering::Relaxed);
}
}
Ok(())
}
cb::FORGET_LEVEL_CHUNK => {
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
play::Clientbound::decode(id, &body)
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
{
bot.forgotten.fetch_add(1, Ordering::Relaxed);
}
Ok(())
}
cb::CHUNK_BATCH_START => {
batch.started = Instant::now();
Ok(())
}
cb::CHUNK_BATCH_FINISHED => {
batch.finished(r.varint().unwrap_or(0));
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: batch.desired_chunks_per_tick(),
})
.encode(),
)
}
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
Ok(play::Clientbound::PlayerPosition(p)) => {
let mut at = bot.teleport.lock().unwrap();
let previous = at.unwrap_or([0.0; 3]);
// Relative flags: X 1, Y 2, Z 4.
let pos: [f64; 3] = std::array::from_fn(|i| {
if p.relatives & (1 << i) != 0 {
previous[i] + p.position[i]
} else {
p.position[i]
}
});
*at = Some(pos);
teleports += 1;
if teleports > 1 {
bot.pulled_back.fetch_add(1, Ordering::Relaxed);
}
send(
play::Serverbound::AcceptTeleportation(play::AcceptTeleportation {
id: p.teleport,
x: pos[0],
y: pos[1],
z: pos[2],
yaw: p.yaw,
pitch: p.pitch,
})
.encode(),
)
}
_ => Ok(()),
},
cb::DISCONNECT | cb::START_CONFIGURATION => {
bot.online.store(false, Ordering::SeqCst);
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
format!(
"disconnected: {}",
String::from_utf8_lossy(&body)
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
)
} else {
"sent back to configuration".into()
});
return;
}
_ => Ok(()),
};
if sent.is_err() {
bot.online.store(false, Ordering::SeqCst);
return;
}
}
}
/// One bot: join, then move every 50 ms until `until`.
fn run_bot(
o: Arc<Options>,
index: usize,
behaviour: Behaviour,
bot: Arc<Bot>,
until: Arc<Mutex<Option<Instant>>>,
) {
let name = format!("{}{index}", o.name);
let asked = Instant::now();
let connection = match join(&o, &name) {
Ok(c) => c,
Err(e) => {
*bot.error.lock().unwrap() = Some(e);
return;
}
};
let (reader, writer) = connection.split();
let writer = Arc::new(Mutex::new(writer));
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
thread::spawn(move || read(reader, writer, bot));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
let spawn = loop {
if let Some(at) = *bot.teleport.lock().unwrap() {
break at;
}
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
if bot.error.lock().unwrap().is_none() {
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
}
return;
}
thread::sleep(Duration::from_millis(20));
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
loop {
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|| !bot.online.load(Ordering::SeqCst)
{
break;
}
// A teleport from the server wins over where the bot thought it was.
if let Some(at) = bot.teleport.lock().unwrap().take() {
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !bot.held.lock().unwrap().contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
match behaviour {
Behaviour::Idle => {}
Behaviour::Walk => {
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
let distance = dx.hypot(dz);
if distance < 1.0 {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 8.0 + random.unit() * 40.0;
target = [
spawn[0] + angle.cos() * reach,
cruise,
spawn[2] + angle.sin() * reach,
];
} else {
// Sprinting: 5.6 blocks a second.
let step = 0.28_f64.min(distance);
pos[0] += dx / distance * step;
pos[2] += dz / distance * step;
}
}
Behaviour::Explore => {
// Flying in creative, by default about 22 blocks a second, which keeps new
// chunks coming as fast as the server sends them.
let step = o.explore_speed / 20.0;
pos[0] += heading.cos() * step;
pos[2] += heading.sin() * step;
}
}
}
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
if !ok {
bot.online.store(false, Ordering::SeqCst);
break;
}
next += Duration::from_millis(50);
let now = Instant::now();
if next > now {
thread::sleep(next - now);
} else if now - next > Duration::from_secs(1) {
next = now;
}
}
// Offline first, so the reader doesn't count the closed connection as a failure.
bot.online.store(false, Ordering::SeqCst);
writer.lock().unwrap().shutdown();
}
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
match mode {
"idle" => Behaviour::Idle,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
// mixed: of every five bots, three walk, one explores, one stands.
_ => match index % 5 {
0 | 2 | 4 => Behaviour::Walk,
1 => Behaviour::Explore,
_ => Behaviour::Idle,
},
}
}
fn main() {
let o = match options() {
Ok(o) => Arc::new(o),
Err(e) => {
eprintln!("horizon-bots: {e}");
std::process::exit(2);
}
};
let start = Instant::now();
let until: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let bots: Vec<Arc<Bot>> = (0..o.bots).map(|_| Arc::new(Bot::default())).collect();
let launcher = {
let (o, bots, until) = (o.clone(), bots.clone(), until.clone());
thread::spawn(move || {
let mut handles = Vec::new();
for (i, bot) in bots.iter().enumerate() {
let (o2, bot, until) = (o.clone(), bot.clone(), until.clone());
let behaviour = behaviour_of(&o.behaviour, i);
handles.push(thread::spawn(move || run_bot(o2, i, behaviour, bot, until)));
thread::sleep(Duration::from_secs_f64(o.join_every));
}
handles
})
};
// The run lasts --seconds after the first bot is in.
let mut previous_chunks = 0;
let mut first = None;
loop {
thread::sleep(Duration::from_secs(1));
let online = bots
.iter()
.filter(|b| b.online.load(Ordering::SeqCst))
.count();
let chunks: u64 = bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum();
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
};
let failed = bots
.iter()
.filter(|b| b.error.lock().unwrap().is_some())
.count();
if first.is_none() && online > 0 {
first = Some(Instant::now());
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
}
println!(
"{}",
json!({
"t": start.elapsed().as_secs(),
"online": online,
"failed": failed,
"chunks": chunks,
"chunks_per_second": chunks - previous_chunks,
"unique": total(|b| &b.unique),
"forgotten": total(|b| &b.forgotten),
"resent": total(|b| &b.resent),
"pulled_back": total(|b| &b.pulled_back),
"waited_ticks": total(|b| &b.waited),
})
);
previous_chunks = chunks;
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
let all_failed = failed == o.bots;
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
*until.lock().unwrap() = Some(Instant::now());
break;
}
}
for handle in launcher.join().unwrap_or_default() {
let _ = handle.join();
}
let summary = json!({
"server": format!("{}:{}", o.host, o.port),
"bots": o.bots,
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"view_distance": o.view_distance,
"seconds": o.seconds,
"joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(),
"chunks": bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum::<u64>(),
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
"name": format!("{}{i}", o.name),
"behaviour": behaviour_of(&o.behaviour, i).name(),
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
"chunks": b.chunks.load(Ordering::Relaxed),
"unique_chunks": b.unique.load(Ordering::Relaxed),
"resent": b.resent.load(Ordering::Relaxed),
"forgotten": b.forgotten.load(Ordering::Relaxed),
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
"waited_ticks": b.waited.load(Ordering::Relaxed),
"error": *b.error.lock().unwrap(),
})).collect::<Vec<_>>(),
});
if o.report.as_deref() == Some("-") {
println!("{summary:#}");
} else if let Some(path) = &o.report {
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
eprintln!("horizon-bots: {path}: {e}");
}
}
let errors: Vec<String> = bots
.iter()
.enumerate()
.filter_map(|(i, b)| {
b.error
.lock()
.unwrap()
.clone()
.map(|e| format!("{}{i}: {e}", o.name))
})
.collect();
for e in errors.iter().take(10) {
eprintln!("horizon-bots: {e}");
}
std::process::exit(if errors.is_empty() { 0 } else { 1 });
}
+63 -1
View File
@@ -10,22 +10,84 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
cpal = "0.16"
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
toml = "1.1"
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
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); `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"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
[dev-dependencies]
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
+15
View File
@@ -0,0 +1,15 @@
//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
fn main() {
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
resource.set_icon(&ico);
if let Err(e) = resource.compile() {
panic!("embedding {ico}: {e}");
}
}
}
@@ -1,212 +0,0 @@
//! Resident chunk storage benchmark: generates a block of vanilla terrain, then
//! reports the resident block/biome memory, the cost of lookups, meshing and
//! lighting on it.
//!
//! chunk_bench <client resource dir> [--seed S] [--size N]
#![forbid(unsafe_code)]
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS},
light::{LightTables, light_chunk},
mesher::{MeshTables, Mesher},
models,
streaming::{ChunkGenerator, Proto},
vanillagen::VanillaTerrain,
};
use horizon_core::biome::BiomeZoom;
use std::{path::PathBuf, sync::Arc, time::Instant};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let root = PathBuf::from(args.first().ok_or("usage: chunk_bench RESOURCES")?);
let flag = |name: &str, default: i64| -> i64 {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let (seed, size) = (flag("--seed", 12345), flag("--size", 8) as i32);
let assets = Assets::load(&root)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let biomes = Biomes::load(&root)?;
let tables = MeshTables::new(
&assets,
&facts,
models::load_visibility(&root, assets.registry)?,
)?;
let light = LightTables::load(&root, assets.registry, &facts)?;
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
let mut worker = terrain.worker();
// Protos for size+4 columns, decorated for the inner size+2, finished as size+2.
let started = Instant::now();
let outer = size + 4;
let mut protos: Vec<Option<Proto>> = (0..outer * outer)
.map(|i| Some(terrain.proto(&mut worker, i % outer, i / outer)))
.collect();
eprintln!("proto: {:.2}s", started.elapsed().as_secs_f64());
let started = Instant::now();
let inner = size + 2;
for z in 1..=inner {
for x in 1..=inner {
let mut region: [Proto; 9] = std::array::from_fn(|k| {
let (dx, dz) = (k as i32 % 3 - 1, k as i32 / 3 - 1);
protos[((z + dz) * outer + x + dx) as usize]
.take()
.expect("proto")
});
terrain.decorate(&mut worker, &mut region);
for (k, p) in region.into_iter().enumerate() {
let (dx, dz) = (k as i32 % 3 - 1, k as i32 / 3 - 1);
protos[((z + dz) * outer + x + dx) as usize] = Some(p);
}
}
}
eprintln!("decorate: {:.2}s", started.elapsed().as_secs_f64());
let started = Instant::now();
let mut chunks: Vec<Chunk> = Vec::new();
for z in 1..=inner {
for x in 1..=inner {
let proto = protos[(z * outer + x) as usize].take().expect("proto");
let mut chunk = terrain.finish(proto);
chunk.x = x;
chunk.z = z;
chunks.push(chunk);
}
}
let finish = started.elapsed().as_secs_f64();
println!(
"finish (proto -> resident chunk): {:.1} ms per chunk",
finish * 1e3 / chunks.len() as f64
);
let chunks: Vec<Arc<Chunk>> = chunks.into_iter().map(Arc::new).collect();
let at = |x: i32, z: i32| chunks[((z - 1) * inner + x - 1) as usize].clone();
// Light all inner-inner columns.
let started = Instant::now();
let mut lit = Vec::new();
for z in 2..inner {
for x in 2..inner {
let region: [[Option<Arc<Chunk>>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| Some(at(x + dx as i32 - 1, z + dz as i32 - 1)))
});
let borrowed = horizon_client::mesher::borrow(&region);
lit.push(((x, z), light_chunk(&borrowed, &light, true)));
}
}
println!(
"light: {:.2} ms per column",
started.elapsed().as_secs_f64() * 1e3 / lit.len() as f64
);
let mut chunks: Vec<Chunk> = chunks.iter().map(|c| (**c).clone()).collect();
for ((x, z), sections) in lit {
chunks[((z - 1) * inner + x - 1) as usize].sections = sections;
}
let chunks: Vec<Arc<Chunk>> = chunks.into_iter().map(Arc::new).collect();
let at = |x: i32, z: i32| chunks[((z - 1) * inner + x - 1) as usize].clone();
// Memory.
let (mut bytes, mut sections, mut uniform) = (0usize, 0usize, 0usize);
for chunk in &chunks {
bytes += chunk.heap_bytes();
for s in &chunk.sections {
sections += 1;
uniform += usize::from(s.is_uniform(s.get(0)));
}
}
println!(
"resident: {} chunks, {} sections ({} uniform), {:.1} KiB per chunk ({:.2} MiB total)",
chunks.len(),
sections,
uniform,
bytes as f64 / 1024.0 / chunks.len() as f64,
bytes as f64 / 1048576.0
);
// Meshing.
let mut mesher = Mesher {
nether_shading: false,
assets: &assets,
tables: &tables,
biomes: &biomes,
visibility: tables.visibility.clone(),
zoom: BiomeZoom::new(7),
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
};
let mut best = f64::MAX;
let mut quads = 0usize;
for _ in 0..3 {
let started = Instant::now();
let mut jobs = 0;
quads = 0;
for z in 2..inner {
for x in 2..inner {
let region: [[Option<Arc<Chunk>>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| Some(at(x + dx as i32 - 1, z + dz as i32 - 1)))
});
let borrowed = horizon_client::mesher::borrow(&region);
for sy in 0..SECTIONS as i32 {
let mesh = mesher.mesh(&borrowed, sy);
quads += (mesh.solid.len() + mesh.translucent.len()) / 4;
jobs += 1;
}
}
}
best = best.min(started.elapsed().as_secs_f64() / f64::from(jobs));
}
println!(
"mesh: {:.1} us per section ({} quads total)",
best * 1e6,
quads
);
// Random lookups, as collision and entity queries do.
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let started = Instant::now();
let mut sum = 0u64;
let lookups = 20_000_000u64;
for _ in 0..lookups {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let cx = 2 + (state % u64::from((inner - 2) as u32)) as i32;
let cz = 2 + ((state >> 8) % u64::from((inner - 2) as u32)) as i32;
let chunk = &chunks[((cz - 1) * inner + cx - 1) as usize];
let (x, y, z) = (
(state >> 16) as i32 & 15,
MIN_Y + ((state >> 24) % 384) as i32,
(state >> 40) as i32 & 15,
);
sum += u64::from(chunk.get(x, y, z));
}
println!(
"random get: {:.2} ns per lookup (checksum {sum})",
started.elapsed().as_secs_f64() * 1e9 / lookups as f64
);
// Coherent scan, as a column or area query does.
let started = Instant::now();
let mut sum = 0u64;
for _ in 0..4 {
for chunk in &chunks {
for y in MIN_Y..MIN_Y + 384 {
for z in 0..16 {
for x in 0..16 {
sum += u64::from(chunk.get(x, y, z));
}
}
}
}
}
println!(
"scan get: {:.2} ns per lookup (checksum {sum})",
started.elapsed().as_secs_f64() * 1e9 / (4.0 * chunks.len() as f64 * 98304.0)
);
Ok(())
}
@@ -0,0 +1,33 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
#[allow(dead_code)]
#[path = "../src/blocks/decode.rs"]
mod decode;
use std::{hint::black_box, path::Path, time::Instant};
// The included private decoder expects the parent crate Result alias.
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let root = args.next().ok_or("expected resource directory")?;
let mode = args.next().ok_or("expected validate or typed")?;
if mode != "validate" && mode != "typed" {
return Err("expected validate or typed".into());
}
let total = Instant::now();
let bytes = std::fs::read(Path::new(&root).join("gameplay-facts.json"))?;
let started = Instant::now();
if mode == "validate" {
let parsed: decode::Validate = serde_json::from_slice(&bytes)?;
black_box(parsed);
} else {
let parsed: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
black_box(parsed);
}
println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0);
println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0);
Ok(())
}
@@ -0,0 +1,124 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
use serde_json::Value;
use std::{
fs::{self, File},
hint::black_box,
io::BufReader,
path::Path,
time::{Duration, Instant},
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn reader(path: &Path) -> Result<Value> {
Ok(serde_json::from_reader(BufReader::new(File::open(path)?))?)
}
fn slice(path: &Path) -> Result<Value> {
Ok(serde_json::from_slice(&fs::read(path)?)?)
}
fn measure(path: &Path, load: fn(&Path) -> Result<Value>) -> Result<Duration> {
let start = Instant::now();
let value = black_box(load(black_box(path))?);
let elapsed = start.elapsed();
// Value destruction is excluded; the temporary byte buffer is released by slice().
drop(value);
Ok(elapsed)
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let root = args
.next()
.ok_or("usage: json_load_bench ROOT [REPETITIONS]")?;
let repetitions: usize = args.next().map_or(Ok(15), |s| s.parse())?;
if repetitions == 0 {
return Err("REPETITIONS must be positive".into());
}
for name in ["gameplay-facts.json", "facts.json"] {
let path = Path::new(&root).join(name);
// Validate complete parsed trees on the exact files being timed.
assert_eq!(reader(&path)?, slice(&path)?);
for _ in 0..2 {
measure(&path, reader)?;
measure(&path, slice)?;
}
let (mut before, mut after) = (Vec::new(), Vec::new());
for i in 0..repetitions {
// Alternate order to reduce filesystem cache / frequency bias.
if i % 2 == 0 {
before.push(measure(&path, reader)?);
after.push(measure(&path, slice)?);
} else {
after.push(measure(&path, slice)?);
before.push(measure(&path, reader)?);
}
}
before.sort_unstable();
after.sort_unstable();
let median = |samples: &[Duration]| {
(samples[(samples.len() - 1) / 2].as_secs_f64()
+ samples[samples.len() / 2].as_secs_f64())
* 500.0
};
let (before, after) = (median(&before), median(&after));
println!(
"{name}: {} bytes; {repetitions} samples; reader {before:.3} ms; slice {after:.3} ms; {:.2}x",
fs::metadata(&path)?.len(),
before / after
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reader_and_slice_preserve_values_and_error_categories() -> Result<()> {
let path =
std::env::temp_dir().join(format!("horizon-json-load-{}.json", std::process::id()));
let check = || -> Result<()> {
fs::write(
&path,
br#"{"states":[{"id":0,"hardness":-1.5,"name":"escaped\n\u2603","outline":[[0,0,0,1,1,1]],"enabled":true}],"defaults":null}"#,
)?;
assert_eq!(reader(&path)?, slice(&path)?);
for invalid in [
&b"{\"states\":["[..],
&b"{\"states\":tru}"[..],
&b"{} trailing"[..],
&b"{\"name\":\"\xff\"}"[..],
] {
fs::write(&path, invalid)?;
let before = reader(&path).unwrap_err();
let after = slice(&path).unwrap_err();
assert_eq!(
before
.downcast_ref::<serde_json::Error>()
.unwrap()
.classify(),
after
.downcast_ref::<serde_json::Error>()
.unwrap()
.classify()
);
}
fs::remove_file(&path)?;
for load in [reader, slice] {
let error = load(&path).unwrap_err();
assert_eq!(
error.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::NotFound
);
}
Ok(())
};
let result = check();
let _ = fs::remove_file(path);
result
}
}
-140
View File
@@ -1,140 +0,0 @@
//! Serve a small flat world on the LAN server without the game, for joining
//! with a real client while developing the protocol:
//! `cargo run --release --example lan_flat -- RESOURCES PORT`.
use horizon_client::{
assets::Assets,
blocks::BlockFacts,
chunk::{Chunk, MIN_Y},
lan::{
Lan,
chunks::ChunkEncoder,
data::SyncData,
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
},
world::World,
};
use horizon_protocol::packets::play::Serverbound as S;
use std::{path::PathBuf, sync::Arc, time::Duration};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let root = PathBuf::from(args.next().ok_or("RESOURCES PORT")?);
let port: u16 = args.next().ok_or("port")?.parse()?;
let assets = Assets::load(&root)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let state = |name: &str| {
assets
.registry
.default_state(name)
.map_or(0, |s| s.0 as u16)
};
let sync = Arc::new(SyncData::load(&root)?);
let biomes: Vec<String> = sync
.dynamic
.registries
.get("minecraft:worldgen/biome")
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_default();
let encoder = ChunkEncoder::new(
facts
.states
.iter()
.map(|s| (s.name.clone(), s.fluid_level > 0 || s.waterlogged)),
&sync.builtin,
&biomes,
&biomes,
);
let (stone, dirt, grass) = (
state("minecraft:stone"),
state("minecraft:dirt"),
state("minecraft:grass_block"),
);
let mut world = World::new(facts.air, 0);
for cz in -8..=8 {
for cx in -8..=8 {
let mut chunk = Chunk::empty(cx, cz);
for z in 0..16 {
for x in 0..16 {
for y in MIN_Y..60 {
let v = if y == 59 {
grass
} else if y > 55 {
dirt
} else {
stone
};
chunk.set(x, y, z, v);
}
}
}
chunk.lit = true;
world.chunks.insert((cx, cz), Arc::new(chunk));
}
}
let lan = Lan::start(sync.clone(), port, "Flat - Example".into(), 8)?;
println!("listening on {}", lan.port);
let mut hub = Hub::new(
lan,
sync,
encoder,
HubConfig {
default_game_mode: 1,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 8,
simulation_distance: 8,
dimension: "minecraft:overworld".into(),
seed: 0,
max_players: 8,
dedicated: false,
},
);
let mut next_entity = 100u32;
let host = HostState {
uuid: 42,
name: "Host".into(),
entity_id: 1,
position: [0.5, 60.0, 0.5],
yaw: 0.0,
pitch: 0.0,
head_yaw: 0.0,
on_ground: true,
game_mode: 1,
sneaking: false,
sprinting: false,
equipment: Default::default(),
health: 20.0,
};
loop {
world.game_time += 1;
world.day_time += 1;
let mut ctx = TickCtx {
world: &mut world,
host: Some(&host),
entities: &[],
next_entity: &mut next_entity,
spawn: [8.5, 60.0, 8.5],
difficulty: 2,
difficulty_locked: false,
clock_rate: 1.0,
tick_rate: 20.0,
tick_frozen: false,
};
for event in hub.tick(&mut ctx) {
match event {
HostEvent::Joined(conn) => hub.place_new_player(conn, &mut ctx),
HostEvent::Packet(_, packet) => {
if std::env::var_os("HORIZON_NET_DEBUG").is_some()
&& !matches!(*packet, S::ClientTickEnd(_))
{
eprintln!("[lan] {packet:?}");
}
}
HostEvent::Chat(line) => println!("{line}"),
HostEvent::Left(_) => {}
}
}
std::thread::sleep(Duration::from_millis(50));
}
}
@@ -1,91 +0,0 @@
//! Menu clicks for tools/test_menu_click_parity.py: reads one JSON case per
//! line (menu, creative, slots, carried, data, ops) and prints the oracle's
//! state lines after every click.
//!
//! usage: menu_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
crafting::Recipes,
item_data::{canon_stack, parse_stack},
items::Items,
menu::{Env, Input, Kind, Menu},
};
use serde_json::Value;
use std::{fs, path::Path};
fn state(menu: &Menu) -> String {
let slots: Vec<String> = (0..menu.slot_count())
.map(|i| canon_stack(menu.slot(i)))
.collect();
let data: String = menu.data.iter().map(|d| format!(" {d}")).collect();
format!(
"{} | {} |{data} | {}",
slots.join(" "),
canon_stack(menu.carried.as_ref()),
menu.drops.join(";")
)
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: menu_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let recipes = Recipes::load(root, &items)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items);
let banners = horizon_client::banner::Banners::load(root);
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
};
let mut out = String::new();
for line in input.lines() {
let case: Value = serde_json::from_str(line)?;
let kind = Kind::parse(case["menu"].as_str().unwrap_or_default()).ok_or("menu kind")?;
let mut menu = Menu::new(kind);
menu.creative = case["creative"].as_bool().unwrap_or(false);
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("-"))?;
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.drops.clear();
out += &format!("init {}\n", state(&menu));
for op in case["ops"].as_array().ok_or("ops")? {
let slot = op[0].as_i64().unwrap_or(0) as i32;
let button = op[1].as_i64().unwrap_or(0) as i32;
match op[2].as_str().unwrap_or_default() {
// ServerboundContainerButtonClickPacket: clickMenuButton.
"BUTTON" => {
menu.button(&env, slot);
out += &format!("{}\n", state(&menu));
continue;
}
// ServerboundContainerSlotStateChangedPacket.
"SLOT_STATE" => {
if (0..9).contains(&slot) {
menu.set_slot_state(slot as usize, button == 1);
}
out += &format!("{}\n", state(&menu));
continue;
}
_ => {}
}
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
match menu.click(&env, slot, button, kind) {
Ok(()) => out += &format!("{}\n", state(&menu)),
Err(name) => out += &format!("error {name}\n"),
}
}
out += "end\n";
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,273 @@
//! The Horizon side of a mod port's harness (tools/mod_port_harness.py,
//! docs/modpacks.md): builds the content root for carried-over mods and
//! writes what Horizon makes of every block state, drop, recipe and tag the
//! NeoForge oracle exported.
//!
//! cargo run -p horizon-client --example mod_port_probe -- PREPARED WORK OUT.json [MOD...]
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
use horizon_client::{
Result,
assets::Assets,
blocks::BlockFacts,
crafting::{Recipe, Recipes},
items::{ItemStack, Items},
mod_content::{self, ContentMod},
stations::StationRecipes,
};
use horizon_core::{geometry::Axis, random::LegacyRandom};
use horizon_world::chunk::BlockState;
use serde_json::{Map, Value, json};
use std::{collections::BTreeMap, fs, path::Path};
fn boxes(list: impl IntoIterator<Item = [f64; 6]>) -> Value {
let mut v: Vec<[f64; 6]> = list.into_iter().collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
json!(v)
}
fn sound(s: &horizon_client::blocks::Sounds) -> Value {
json!({"volume": s.volume, "pitch": s.pitch, "break": &*s.break_sound, "step": &*s.step,
"place": &*s.place, "hit": &*s.hit, "fall": &*s.fall})
}
fn tool(name: &str) -> Option<ItemStack> {
match name {
"hand" => None,
"silk_touch_pickaxe" => {
let mut pick = ItemStack::new("minecraft:diamond_pickaxe", 1);
pick.enchant("minecraft:silk_touch", 1);
Some(pick)
}
other => Some(ItemStack::new(&format!("minecraft:{other}"), 1)),
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [base, work, out, wanted @ ..] = args.as_slice() else {
return Err("usage: mod_port_probe PREPARED WORK OUT.json [MOD...]".into());
};
let (base, work) = (Path::new(base), Path::new(work));
let oracle: Value = serde_json::from_slice(&fs::read(work.join("oracle/facts.json"))?)?;
let mut mods = Vec::new();
for entry in fs::read_dir(work.join("mods"))?.flatten() {
let id = entry.file_name().to_string_lossy().into_owned();
let dir = entry.path().join("carry");
if !(wanted.is_empty() || wanted.contains(&id)) || !dir.is_dir() {
continue;
}
let package = horizon_mods::Package::read(&dir)?;
let content = horizon_mods::Content::read(&dir, &package.manifest)?;
mods.push(ContentMod {
id: package.manifest.id.clone(),
content,
});
}
mods.sort_by(|a, b| a.id.cmp(&b.id));
let root = work.join("horizon-root");
let began = std::time::Instant::now();
let step = |what: &str| {
let what = what.to_owned();
move |e: Box<dyn std::error::Error>| format!("{what}: {e}")
};
mod_content::build(base, &mods, &root).map_err(step("content root"))?;
let built = began.elapsed().as_secs_f64();
let assets = Assets::load(&root).map_err(step("assets"))?;
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).map_err(step("block facts"))?;
let items = Items::load(&root, registry, &facts).map_err(step("items"))?;
let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?;
let stations = StationRecipes::load(&root, &items);
let collisions =
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
let loaded = began.elapsed().as_secs_f64() - built;
let ours = |id: &str| {
id.split_once(':').is_some_and(|(ns, _)| {
mods.iter()
.any(|m| m.content.namespaces.iter().any(|n| n == ns))
})
};
let tools: Vec<String> = serde_json::from_value(oracle["tools"].clone())?;
let mut blocks = Map::new();
for (name, block) in oracle["blocks"].as_object().ok_or("oracle: no blocks")? {
if !ours(name) {
continue;
}
let Some(default) = registry.default_state(name) else {
blocks.insert(name.clone(), Value::Null);
continue;
};
let mut states = Map::new();
for (key, o) in block["states"].as_object().into_iter().flatten() {
let properties: BTreeMap<String, String> = key
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
let descriptor = BlockState {
name: name.clone(),
properties,
};
let Some(id) = registry.id(&descriptor) else {
states.insert(key.clone(), Value::Null);
continue;
};
let f = facts.get(id);
let collision = collisions
.cached_shapes()
.cached_shape(id)
.ok()
.flatten()
.map(|s| {
boxes(s.boxes().iter().map(|b| {
[
b.min(Axis::X),
b.min(Axis::Y),
b.min(Axis::Z),
b.max(Axis::X),
b.max(Axis::Y),
b.max(Axis::Z),
]
}))
});
let mut row = json!({
"hardness": f.hardness,
"explosion_resistance": f.explosion_resistance,
"emission": f.emission,
"requires_tool": f.requires_tool,
"friction": f.friction,
"map_color_id": f.map_color,
"can_occlude": f.can_occlude,
"sound": sound(&f.sounds),
"outline": boxes(f.outline.iter().copied()),
"collision": collision,
"classes": f.classes.iter().map(|c| &**c).collect::<Vec<_>>(),
});
if o.get("drops").is_some() {
let mut drops = Map::new();
for t in &tools {
let mut random = LegacyRandom::new(0);
let held = tool(t);
let mut counts: BTreeMap<String, u32> = BTreeMap::new();
for s in items.block_drops(registry, id, held.as_ref(), &mut random) {
*counts.entry(s.item.to_string()).or_default() += s.count;
}
drops.insert(t.clone(), json!(counts));
}
row["drops"] = Value::Object(drops);
}
states.insert(key.clone(), row);
}
let item = facts.get(default).item.to_string();
blocks.insert(
name.clone(),
json!({"states": states, "item": item, "item_known": items.items.contains_key(&*item)}),
);
}
// Vanilla's sounds, by block: the harness maps the oracle's version's
// sound events to this one's through them.
let mut vanilla_sounds = Map::new();
for name in oracle["blocks"]
.as_object()
.into_iter()
.flatten()
.map(|(k, _)| k)
{
if let Some(id) = name
.starts_with("minecraft:")
.then(|| registry.default_state(name))
.flatten()
{
vanilla_sounds.insert(name.clone(), sound(&facts.get(id).sounds));
}
}
let mut item_rows = Map::new();
for name in oracle["items"]
.as_object()
.into_iter()
.flatten()
.map(|(k, _)| k)
{
item_rows.insert(
name.clone(),
match items.items.get(name.as_str()) {
Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}),
None => Value::Null,
},
);
}
let sorted = |list: &[String]| {
let mut v = list.to_vec();
v.sort();
v.dedup();
v
};
let mut recipe_rows = Map::new();
for (name, recipe) in &recipes.crafting {
if !ours(name) {
continue;
}
let row = match recipe {
Recipe::Shaped { cells, result, .. } => json!({
"result": result.item.as_deref(), "count": result.count,
"ingredients": cells.iter().flatten().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
Recipe::Shapeless {
ingredients,
result,
} => json!({
"result": result.item.as_deref(), "count": result.count,
"ingredients": ingredients.iter().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
Recipe::Special(_) => json!({"special": true}),
};
recipe_rows.insert(name.clone(), row);
}
for c in recipes.cooking.iter().filter(|c| ours(&c.id)) {
recipe_rows.insert(
c.id.clone(),
json!({"result": &*c.result.item, "count": c.result.count,
"ingredients": [sorted(&c.ingredient.1)]}),
);
}
for s in stations.stonecutting.iter().filter(|s| ours(&s.id)) {
recipe_rows.insert(
s.id.clone(),
json!({"result": &*s.result.item, "count": s.result.count,
"ingredients": [sorted(&s.input.1)]}),
);
}
let mut block_tags = Map::new();
for (tag, members) in &items.block_tags {
let mut ids: Vec<&String> = members.iter().filter(|m| ours(m)).collect();
if !ids.is_empty() {
ids.sort();
block_tags.insert(tag.clone(), json!(ids));
}
}
let report = json!({
"states": registry.len(),
"build_seconds": built,
"load_seconds": loaded,
"blocks": blocks,
"items": item_rows,
"recipes": recipe_rows,
"block_tags": block_tags,
"vanilla_sounds": vanilla_sounds,
});
fs::write(out, serde_json::to_vec(&report)?)?;
println!(
"{out}: {} blocks, {} items, {} recipes; root built in {built:.2} s, loaded in {loaded:.2} s",
blocks.len(),
item_rows.len(),
recipe_rows.len()
);
Ok(())
}
@@ -0,0 +1,130 @@
//! Full block/assets + item startup. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
hash::{Hash, Hasher},
path::Path,
sync::atomic::{AtomicUsize, Ordering::Relaxed},
time::Instant,
};
struct CountingAllocator;
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
static LIVE: AtomicUsize = AtomicUsize::new(0);
// The allocator delegates every operation and layout unchanged to System.
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
let ptr = unsafe { System.alloc_zeroed(layout) };
if TRACK && !ptr.is_null() {
LIVE.fetch_add(layout.size(), Relaxed);
}
ptr
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
if TRACK {
LIVE.fetch_sub(layout.size(), Relaxed);
}
unsafe { System.dealloc(ptr, layout) };
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
let out = unsafe { System.realloc(ptr, layout, size) };
if TRACK && !out.is_null() {
LIVE.fetch_sub(layout.size(), Relaxed);
LIVE.fetch_add(size, Relaxed);
}
out
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: model_library_bench ROOT [--parity]")?;
let root = Path::new(&root);
let initial = LIVE.load(Relaxed);
let start = Instant::now();
let assets = Assets::load(root)?;
let render = assets.reload(root)?;
let block_ms = start.elapsed().as_secs_f64() * 1000.;
let block_retained = LIVE.load(Relaxed) - initial;
let start_items = Instant::now();
let items = ItemRenderer::load_with_library(root, render.model_library.clone())?;
let item_ms = start_items.elapsed().as_secs_f64() * 1000.;
let total_ms = start.elapsed().as_secs_f64() * 1000.;
let retained = LIVE.load(Relaxed) - initial;
println!(
"block_ms={block_ms:.3} item_ms={item_ms:.3} total_ms={total_ms:.3} retained_bytes={retained} block_retained_bytes={block_retained}"
);
if std::env::args().any(|a| a == "--parity") {
let mut hash = std::collections::hash_map::DefaultHasher::new();
for id in 0..assets.registry.len() {
let id = horizon_world::registry::BlockStateId(id as u32);
for pos in [
horizon_core::position::BlockPos::new(0, 0, 0),
horizon_core::position::BlockPos::new(-17, 65, 31),
] {
if let Ok(selected) = render.model_catalog.collect(id, pos) {
for (variant, model) in selected {
format!("{variant:?}").hash(&mut hash);
model.ambient_occlusion.hash(&mut hash);
for face in &model.faces {
format!("{:?}", face.quad).hash(&mut hash);
face.layer.hash(&mut hash);
face.opaque.hash(&mut hash);
face.translucent.hash(&mut hash);
}
}
}
}
}
let mut names = Vec::new();
for ns in std::fs::read_dir(root.join("assets"))? {
let ns = ns?;
let base = ns.path().join("models");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries {
let path = entry?.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "json") {
names.push(format!(
"{}:{}",
ns.file_name().to_string_lossy(),
path.strip_prefix(&base)?
.with_extension("")
.to_string_lossy()
.replace('\\', "/")
));
}
}
}
}
names.sort();
for name in &names {
format!("{:?}", items.bake(name)).hash(&mut hash);
}
println!(
"parity_models={} fingerprint={:016x}",
names.len(),
hash.finish()
);
}
std::hint::black_box((&assets, &items));
Ok(())
}
@@ -0,0 +1,291 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
DATA_VERSION,
chunk::BlockState,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, fs, hint::black_box, path::PathBuf, time::Instant};
struct Fixture {
root: PathBuf,
registry: BlockRegistry,
bytes: usize,
blocks: usize,
}
impl Fixture {
fn new(target_bytes: usize) -> Result<Self> {
let root =
std::env::temp_dir().join(format!("horizon-model-states-{}", std::process::id()));
fs::create_dir_all(root.join("assets/minecraft/blockstates"))?;
fs::create_dir_all(root.join("assets/minecraft/models/block"))?;
let mut possible = Vec::new();
for age in 0..8 {
for facing in ["north", "south", "east", "west"] {
for waterlogged in ["false", "true"] {
possible.push(
json!({"age":age.to_string(), "facing":facing, "waterlogged":waterlogged}),
);
}
}
}
let fact = json!({"schema":{
"age":{"integer":[0,7]},
"facing":{"named":["north","south","east","west"]},
"waterlogged":{"named":["false","true"]}
},"states":possible});
let blocks = (target_bytes / (serde_json::to_vec(&fact)?.len() + 26)).max(1);
let mut facts = BTreeMap::new();
let mut states = Vec::new();
let mut defaults = BTreeMap::new();
for i in 0..blocks {
let block = format!("fixture_{i:04}");
let name = format!("minecraft:{block}");
defaults.insert(name.as_str().into(), Tag::Int(states.len() as i32));
for properties in &possible {
let state = BlockState {
name: name.clone(),
properties: serde_json::from_value(properties.clone())?,
};
states.push(state.to_nbt());
}
facts.insert(name, fact.clone());
fs::write(
root.join(format!("assets/minecraft/blockstates/{block}.json")),
r#"{"multipart":[{"apply":[{"model":"minecraft:block/fixture","weight":2},{"model":"minecraft:block/fixture","y":90,"weight":1}]},{"when":{"waterlogged":"true"},"apply":{"model":"minecraft:block/fixture","y":180,"uvlock":true}}]}"#,
)?;
}
fs::write(
root.join("assets/minecraft/models/block/fixture.json"),
r##"{"textures":{"all":"minecraft:block/fixture"},"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"north":{"texture":"#all","cullface":"north"}}}]}"##,
)?;
let document = serde_json::to_vec(&facts)?;
let bytes = document.len();
fs::write(root.join("model-states.json"), document)?;
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("states", Tag::List(states)),
("defaults", Tag::Compound(defaults)),
]))?;
Ok(Self {
root,
registry,
bytes,
blocks,
})
}
fn facts(&self) -> Result<BTreeMap<String, Value>> {
Ok(serde_json::from_slice(&fs::read(
self.root.join("model-states.json"),
)?)?)
}
fn block_names<'a>(&self, names: impl Iterator<Item = &'a String>) -> Vec<&'a str> {
names
.filter(|name| self.registry.default_state(name).is_some())
.filter_map(|name| name.strip_prefix("minecraft:"))
.collect()
}
fn load(&self, reuse: bool) -> Result<(StateModels, Sprites)> {
// The first parse and block filtering match models::prepare in both paths.
let mut sprites = Sprites::default();
let catalog = if reuse {
let facts = self.facts()?;
let blocks = self.block_names(facts.keys());
StateModels::from_facts(&self.root, &self.registry, &blocks, &mut sprites, &facts)?
} else {
let facts: serde_json::Map<String, Value> =
serde_json::from_slice(&fs::read(self.root.join("model-states.json"))?)?;
let blocks = self.block_names(facts.keys());
StateModels::load(&self.root, &self.registry, &blocks, &mut sprites)?
};
Ok((catalog, sprites))
}
fn verify(&self) -> Result<()> {
let (before, before_sprites) = self.load(false)?;
let (after, after_sprites) = self.load(true)?;
assert_eq!(before_sprites.layers, after_sprites.layers);
assert_eq!(before_sprites.opaque, after_sprites.opaque);
assert_eq!(before_sprites.translucent, after_sprites.translucent);
for id in 0..self.registry.len() {
for position in [
BlockPos::new(0, 0, 0),
BlockPos::new(12, -4, 7),
BlockPos::new(42, 9, -19),
] {
let a = before.collect(BlockStateId(id as u32), position)?;
let b = after.collect(BlockStateId(id as u32), position)?;
assert_eq!(a.len(), b.len());
for ((av, am), (bv, bm)) in a.into_iter().zip(b) {
assert_eq!(av, bv);
assert_eq!(am.ambient_occlusion, bm.ambient_occlusion);
assert_eq!(am.cube, bm.cube);
assert_eq!(am.faces.len(), bm.faces.len());
for (af, bf) in am.faces.iter().zip(&bm.faces) {
assert_eq!(af.quad, bf.quad);
assert_eq!(
(af.layer, af.opaque, af.translucent),
(bf.layer, bf.opaque, bf.translucent)
);
}
}
}
}
Ok(())
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn median(values: &mut [f64]) -> f64 {
values.sort_by(f64::total_cmp);
values[values.len() / 2]
}
fn main() -> Result<()> {
let repetitions: usize = std::env::args()
.nth(1)
.map(|s| s.parse())
.transpose()?
.unwrap_or(15);
if repetitions == 0 {
return Err("repetitions must be positive".into());
}
let fixture = Fixture::new(2_840_000)?;
fixture.verify()?;
for _ in 0..2 {
for reuse in [false, true] {
drop(black_box(fixture.load(reuse)?));
}
}
let mut before = Vec::new();
let mut after = Vec::new();
for iteration in 0..repetitions {
// Alternate order to limit cache/order bias. Both paths use slice parsing.
for reuse in if iteration % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let started = Instant::now();
drop(black_box(fixture.load(black_box(reuse))?));
let ms = started.elapsed().as_secs_f64() * 1000.0;
if reuse {
after.push(ms);
} else {
before.push(ms);
}
}
}
let before = median(&mut before);
let after = median(&mut after);
println!(
"fixture: {} bytes, {} blocks, {} states; equality: all states at 3 positions",
fixture.bytes,
fixture.blocks,
fixture.registry.len()
);
println!(
"2 warmups/path, {repetitions} samples/path; median before {before:.3} ms, after {after:.3} ms; {:.3}x speedup ({:.2}% less time)",
before / after,
(1.0 - after / before) * 100.0
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_facts_match_load_and_preserve_errors() -> Result<()> {
let fixture = Fixture::new(1)?;
fixture.verify()?;
let mut facts = fixture.facts()?;
let blocks = ["fixture_0000"];
let load_error = |facts: &BTreeMap<String, Value>| -> Result<String> {
fs::write(
fixture.root.join("model-states.json"),
serde_json::to_vec(facts)?,
)?;
let a = StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default(),
)
.err()
.ok_or("expected load error")?
.to_string();
let b = StateModels::from_facts(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default(),
facts,
)
.err()
.ok_or("expected shared facts error")?
.to_string();
assert_eq!(a, b);
Ok(a)
};
let good = facts.clone();
facts.clear();
assert_eq!(load_error(&facts)?, "missing block model state schema");
facts = good.clone();
facts.get_mut("minecraft:fixture_0000").unwrap()["schema"] = Value::Null;
assert_eq!(load_error(&facts)?, "invalid model schema");
facts = good.clone();
facts.get_mut("minecraft:fixture_0000").unwrap()["states"]
.as_array_mut()
.unwrap()
.reverse();
assert_eq!(
load_error(&facts)?,
"model state definitions disagree with the imported block registry"
);
fs::remove_file(fixture.root.join("model-states.json"))?;
// A parsed document remains usable without reopening its source file.
StateModels::from_facts(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default(),
&good,
)?;
assert!(
StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default()
)
.is_err()
);
fs::write(fixture.root.join("model-states.json"), b"null")?;
assert!(
StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default()
)
.is_err()
);
fs::write(fixture.root.join("model-states.json"), b"{")?;
assert!(
StateModels::load(
&fixture.root,
&fixture.registry,
&blocks,
&mut Sprites::default()
)
.is_err()
);
Ok(())
}
}
@@ -1,688 +0,0 @@
//! Natural spawning decisions for differential tests against vanilla
//! (tools/test_natural_spawn_parity.py): reads the box-world cases of
//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in
//! the same order and format.
//!
//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat
//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears,
//! ocelots and rabbits after the first of their group are babies (chance
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
//! are small, slimes and magma cubes keep size 1, tropical fish school.
//!
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
biome::{Biomes, SpawnEntry},
blocks::BlockFacts,
collisions::{self, LookupCollisions, no_collision_empty},
items::Items,
mobs::{
MobTypes, Species,
data::MobData,
spawn_rules::{Dimension, SpawnLevel},
spawn_selection::{
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
spawn_box,
},
species::{self, Baby},
},
};
use horizon_core::{
geometry::Aabb,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
};
use serde_json::Value;
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap, HashSet},
fmt::Write as _,
fs,
path::Path,
};
#[derive(Clone, Copy)]
struct Bx([i32; 6]);
impl Bx {
fn parse(s: &str) -> Self {
let v: Vec<i32> = s.split(',').map(|n| n.parse().unwrap_or(0)).collect();
Self([v[0], v[1], v[2], v[3], v[4], v[5]])
}
fn has(&self, x: i32, y: i32, z: i32) -> bool {
let b = self.0;
x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5]
}
}
struct Start {
id: String,
pieces: Vec<Bx>,
/// The start's bounding box (pieces encapsulated, inflated by 12 for
/// terrain adaptation).
bounds: Bx,
}
struct Shared<'a> {
facts: &'a BlockFacts,
data: &'a MobData,
block_tags: &'a HashMap<String, HashSet<String>>,
types: &'a MobTypes,
biomes: &'a Biomes,
structures: &'a StructureSpawns,
collision: &'a BlockCollisionRegistry<'a>,
air: BlockStateId,
}
struct World<'a> {
s: &'a Shared<'a>,
blocks: Vec<(Bx, BlockStateId)>,
lights: Vec<(Bx, i32, i32)>,
biomes: Vec<(Bx, String)>,
biome: String,
sky: i32,
light: i32,
darken: i32,
thunder: bool,
sea: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
dimension_id: String,
slime: f32,
starts: Vec<Start>,
players: Vec<([f64; 3], String)>,
respawn: Option<[i32; 3]>,
unloaded: HashSet<(i32, i32)>,
spawn_mobs: bool,
/// Boxes of living entities (case entities, then spawned mobs).
entities: RefCell<Vec<Aabb>>,
}
impl World<'_> {
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
self.biomes
.iter()
.rev()
.find(|(b, _)| b.has(x, y, z))
.map_or(&self.biome, |(_, n)| n)
}
fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> {
let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?;
Some(self.s.biomes.get(i))
}
fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) {
self.lights
.iter()
.rev()
.find(|(b, ..)| b.has(x, y, z))
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
}
}
impl SpawnLevel for World<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.blocks
.iter()
.rev()
.find(|(b, _)| b.has(x, y, z))
.map_or(self.s.air, |(_, s)| *s)
}
fn facts(&self) -> &BlockFacts {
self.s.facts
}
fn mob_data(&self) -> &MobData {
self.s.data
}
fn block_tag(&self, tag: &str, block: &str) -> bool {
self.s
.block_tags
.get(tag)
.is_some_and(|s| s.contains(block))
}
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
self.s
.data
.biome_tags
.get(tag)
.is_some_and(|s| s.contains(self.biome_at(x, y, z)))
}
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.lights_at(x, y, z).0
}
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.lights_at(x, y, z).1
}
fn sky_darken(&self) -> i32 {
self.darken
}
fn thundering(&self) -> bool {
self.thunder
}
fn sea_level(&self) -> i32 {
self.sea
}
fn world_surface(&self, x: i32, z: i32) -> i32 {
self.heightmap(Heightmap::WorldSurface, x, z)
}
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
self.slime
}
fn world_seed(&self) -> i64 {
self.seed
}
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
self.players.iter().any(|(p, mode)| {
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
(mode == "survival" || mode == "adventure") && d < distance * distance
})
}
}
impl NaturalLevel for World<'_> {
fn min_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
-64
} else {
0
}
}
fn max_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
319
} else {
255
}
}
fn ambient_light(&self) -> f32 {
match self.dimension {
Dimension::Overworld => 0.0,
Dimension::Nether => 0.1,
Dimension::End => 0.25,
}
}
fn has_ceiling(&self) -> bool {
self.dimension == Dimension::Nether
}
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
let t = self.s.types.types.get(entity)?;
Some((
t.species,
t.width as f32,
t.height as f32,
Category::from_name(&t.category),
))
}
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
self.biome_info(x, y, z).map(|b| &b.spawns)
}
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
self.biome_info(x, y, z)?.spawn_costs.get(entity).copied()
}
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
self.biome_info(x, y, z)
.map_or(0.1, |b| b.creature_probability)
}
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
let (cx, cz) = (x >> 4, z >> 4);
let mut out: Vec<StructureAt> = Vec::new();
for start in &self.starts {
let b = start.bounds.0;
// Referenced: the start's box reaches the chunk's columns.
if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 {
continue;
}
let in_piece = start.pieces.iter().any(|p| p.has(x, y, z));
let in_bounds = start.bounds.has(x, y, z);
match out.iter_mut().find(|s| s.id == start.id) {
Some(s) => {
s.in_piece |= in_piece;
s.in_bounds |= in_bounds;
}
None => out.push(StructureAt {
id: start.id.clone(),
in_piece,
in_bounds,
}),
}
}
out
}
fn structure_spawns(&self) -> &StructureSpawns {
self.s.structures
}
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
let mut best: Option<f64> = None;
for (p, mode) in &self.players {
if mode == "spectator" {
continue;
}
let d = [p[0] - x, p[1] - y, p[2] - z];
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if best.is_none_or(|b| d < b) {
best = Some(d);
}
}
best
}
fn respawn(&self) -> Option<[i32; 3]> {
self.respawn
}
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
!self.unloaded.contains(&(cx, cz))
}
fn no_collision(&self, bounds: Aabb) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
let mut world = LookupCollisions {
block: &lookup,
binding: self.s.collision,
};
no_collision_empty(&mut world, bounds)
}
fn unobstructed(&self, bounds: Aabb) -> bool {
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
}
fn spawn_mobs(&self) -> bool {
self.spawn_mobs
}
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
(self.min_y()..=self.max_y())
.rev()
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
.map_or(self.min_y(), |y| y + 1)
}
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0
}
}
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
let t = &l.s.types.types[r.entity];
let (mut w, mut h) = (t.width as f32, t.height as f32);
let baby = match r.species {
Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0,
Species::Fox | Species::Axolotl => r.group_index >= 2,
_ => false,
};
if baby {
match species::def(r.species).baby {
Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32),
Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32),
Baby::None => {}
}
}
match r.species {
Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0),
Species::Salmon => (w, h) = (w * 0.5, h * 0.5),
_ => {}
}
let [x, y, z] = r.position;
spawn_box(x, y, z, w, h)
}
fn parse_state(spec: &str) -> BlockState {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
BlockState {
name: name.to_owned(),
properties: props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
}
}
fn bits(v: f64) -> String {
format!("{:x}", v.to_bits())
}
/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn`
/// (sulfur cubes, size 2 under the fixed random) takes its constructor's
/// head rotation from the entity's unseeded random.
fn yaw_text(entity: &str, v: f32) -> String {
if entity == "minecraft:sulfur_cube" {
"head".to_owned()
} else {
format!("{:x}", v.to_bits())
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let types = MobTypes::load(root)?;
let biomes = Biomes::load(root)?;
let structures = StructureSpawns::load(root);
let collision = collisions::load(root, registry)?;
let shared = Shared {
facts: &facts,
data: &data,
block_tags: &items.block_tags,
types: &types,
biomes: &biomes,
structures: &structures,
collision: &collision,
air: facts.air,
};
let mut adaptation: HashMap<String, bool> = HashMap::new();
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() {
continue;
}
let mut c = BTreeMap::new();
let mut name = "";
for token in raw.split_whitespace() {
match token.split_once('=') {
Some((k, v)) => {
c.insert(k, v);
}
None => name = token,
}
}
let get =
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
let indexed = |prefix: &str| -> Vec<&str> {
(0..)
.map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied())
.collect()
};
let mut blocks = Vec::new();
for k in indexed("k") {
let (b, s) = k.split_once('/').ok_or("bad block box")?;
blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?));
}
let lights = indexed("l")
.into_iter()
.filter_map(|l| {
let (b, v) = l.split_once('/')?;
let (s, k) = v.split_once(',')?;
Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?))
})
.collect();
let biome_boxes = indexed("b")
.into_iter()
.filter_map(|l| {
let (b, n) = l.split_once('/')?;
Some((Bx::parse(b), n.to_owned()))
})
.collect();
let mut starts = Vec::new();
for s in indexed("s") {
let (id, pieces) = s.split_once('/').ok_or("bad structure")?;
let pieces: Vec<Bx> = pieces.split(';').map(Bx::parse).collect();
let mut b = pieces[0].0;
for p in &pieces[1..] {
for i in 0..3 {
b[i] = b[i].min(p.0[i]);
b[i + 3] = b[i + 3].max(p.0[i + 3]);
}
}
let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| {
let path = root.join(format!(
"data/minecraft/worldgen/structure/{}.json",
id.trim_start_matches("minecraft:")
));
fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none"))
.unwrap_or(false)
});
if adapts {
for i in 0..3 {
b[i] -= 12;
b[i + 3] += 12;
}
}
starts.push(Start {
id: id.to_owned(),
pieces,
bounds: Bx(b),
});
}
let players = indexed("p")
.into_iter()
.map(|p| {
let mut parts = p.split('/');
let v: Vec<f64> = parts
.next()
.unwrap_or("")
.split(',')
.map(|n| n.parse().unwrap_or(0.0))
.collect();
(
[v[0], v[1], v[2]],
parts.next().unwrap_or("survival").to_owned(),
)
})
.collect::<Vec<_>>();
let dimension_id = get("dim", "minecraft:overworld");
let dimension = match dimension_id.as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
};
let respawn = {
let r = get("respawn", "0,-1000,0/minecraft:overworld");
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
let v: Vec<i32> = p.split(',').map(|n| n.parse().unwrap_or(0)).collect();
(d == dimension_id).then_some([v[0], v[1], v[2]])
};
let unloaded = c
.get("unloaded")
.map(|u| {
u.split(';')
.filter_map(|p| {
let (x, z) = p.split_once(',')?;
Some((x.parse().ok()?, z.parse().ok()?))
})
.collect()
})
.unwrap_or_default();
// Case entities: (type, position, persistent).
let mut case_entities = Vec::new();
for e in indexed("e") {
let parts: Vec<&str> = e.split('/').collect();
let v: Vec<f64> = parts[1]
.split(',')
.map(|n| n.parse().unwrap_or(0.0))
.collect();
// EntityType.create makes no monsters in peaceful.
if get("difficulty", "normal") == "peaceful"
&& types.types.get(parts[0]).is_some_and(|t| {
!horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
})
{
continue;
}
case_entities.push((
parts[0].to_owned(),
[v[0], v[1], v[2]],
parts.get(2) == Some(&"p"),
));
}
let world = World {
s: &shared,
blocks,
lights,
biomes: biome_boxes,
biome: get("biome", "minecraft:plains"),
sky: get("sky", "15").parse()?,
light: get("light", "0").parse()?,
darken: get("darken", "0").parse()?,
thunder: get("thunder", "0") == "1",
sea: get("sea", "63").parse()?,
seed: get("wseed", "0").parse()?,
peaceful: get("difficulty", "normal") == "peaceful",
dimension,
dimension_id: dimension_id.clone(),
slime: get("slime", "0.0").parse()?,
starts,
players,
respawn,
unloaded,
spawn_mobs: get("rules", "1") != "0",
entities: RefCell::new(
case_entities
.iter()
.filter_map(|(t, p, _)| {
let t = types.types.get(t)?;
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
})
.collect(),
),
};
let _ = &world.dimension_id;
let chunk = {
let v: Vec<i32> = get("chunk", "0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
(v[0], v[1])
};
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
let mut log: Vec<String> = Vec::new();
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
let bounds = neutral_box(l, r);
l.entities.borrow_mut().push(bounds);
Some(Spawned {
bounds,
school: true,
})
};
let mode = get("mode", "chunk");
if mode == "gen" {
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
// The level random of the spawn predicates: no creature's predicate
// draws from it (the oracle's answers fixed values).
let mut rules = LegacyRandom::new(0);
let mut spawns = Vec::new();
let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16];
spawn_selection::spawn_mobs_for_chunk_generation(
&world,
source,
chunk,
&mut gen_random,
&mut rules,
&mut spawn,
&mut |e| match e {
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
"spawn {t} {} {} {} {}",
bits(p[0]),
bits(p[1]),
bits(p[2]),
yaw_text(&t, yaw)
)),
},
);
log.extend(spawns);
log.push(format!(
"next {}",
gen_random.next_int_bound(1000).unwrap_or(0)
));
} else {
let counted: Vec<Counted> = case_entities
.iter()
.filter_map(|(t, p, persistent)| {
let kind = types.types.get(t)?;
Some(Counted {
entity: t,
category: Category::from_name(&kind.category),
block: [
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
],
is_mob: true,
persistent: *persistent,
})
})
.collect();
let players = world
.players
.iter()
.map(|(p, mode)| SpawnPlayer {
position: *p,
spectator: mode == "spectator",
})
.collect();
let mut state = SpawnState::new(
&world,
get("chunkcount", "289").parse()?,
players,
&counted,
|cx, cz| !world.unloaded.contains(&(cx, cz)),
);
let mut trace = |e: Event| {
log.push(match e {
Event::Categories(list) => format!(
"cats {}",
list.iter().map(|c| c.name()).collect::<Vec<_>>().join(",")
),
Event::Local(c, ok) => format!("local {} {ok}", c.name()),
Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]),
Event::Try(x, z) => format!("try {x} {z}"),
Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]),
Event::Spawn(t, p, yaw) => format!(
"spawn {t} {} {} {} {}",
bits(p[0]),
bits(p[1]),
bits(p[2]),
yaw_text(&t, yaw)
),
})
};
if mode == "pos" {
let category = Category::from_name(&get("cat", "monster"));
let v: Vec<i32> = get("start", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
spawn_selection::spawn_category_for_position(
&world,
&mut random,
&mut state,
category,
chunk,
[v[0], v[1], v[2]],
&mut spawn,
&mut trace,
);
} else {
let categories = state
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
trace(Event::Categories(categories.clone()));
spawn_selection::spawn_for_chunk(
&world,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut trace,
);
}
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
}
let _ = writeln!(out, "{name} {}", log.join(" ; "));
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,263 @@
//! The `entity_portals` section scan only: `may_hold_portal` over synthetic
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
fast_hash::FxHashMap,
portal_sections::PortalSections,
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_world::{level::Behaviours, registry::BlockStateId};
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
/// `Game::swept_portal`'s reach (`Entity.checkInsideBlocks`'s swept box).
fn reach(from: Vec3, to: Vec3, width: f32, height: f32) -> Aabb {
let half = f64::from(width) / 2.0;
let bounds = Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
)
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
bounds
.union_bounds(bounds.move_by(from.subtract(to)))
.inflate(Vec3::new(1.0, 1.0, 1.0))
}
fn sections(reach: Aabb) -> (i64, i32, i32, i32, i32, i32, i32) {
let (min, max) = (reach.min_position(), reach.max_position());
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
let top = SECTIONS as i32 - 1;
let (x0, x1) = (min.x >> 4, max.x >> 4);
let (z0, z1) = (min.z >> 4, max.z >> 4);
let (y0, y1) = (
((min.y - MIN_Y) >> 4).clamp(0, top),
((max.y - MIN_Y) >> 4).clamp(0, top),
);
let count = (i64::from(x1) - i64::from(x0) + 1)
* (i64::from(y1) - i64::from(y0) + 1)
* (i64::from(z1) - i64::from(z0) + 1);
(count, x0, x1, y0, y1, z0, z1)
}
/// The original `Game::may_hold_portal`: per-tick `seen`, palette per section.
fn may_hold_portal_baseline(
reach: Aabb,
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
seen: &mut FxHashMap<(i32, i32, i32), bool>,
behaviours: &Behaviours,
) -> bool {
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
if count > 64 {
return true;
}
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
(y0..=y1).any(|sy| {
*seen.entry((cx, sy, cz)).or_insert_with(|| {
world
.get(&(cx, cz))
.and_then(|c| c.sections.get(sy as usize))
.is_some_and(|s| {
s.blocks
.maybe_has(|v| behaviours.portal_axis(BlockStateId(v)).is_some())
})
})
})
})
})
}
/// The cached `Game::may_hold_portal`: one mask per resident chunk.
fn may_hold_portal_cached(
reach: Aabb,
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
cache: &mut PortalSections,
behaviours: &Behaviours,
) -> bool {
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
if count > 64 {
return true;
}
let mut y_mask = 0u32;
for sy in y0..=y1 {
y_mask |= 1 << sy;
}
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
world.get(&(cx, cz)).is_some_and(|chunk| {
cache.get((cx, cz), chunk, |v| {
behaviours.portal_axis(BlockStateId(v)).is_some()
}) & y_mask
!= 0
})
})
})
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("expected prepared client resource directory")?;
let start = Instant::now();
let registry = horizon_world::level::data::load_registry(Path::new(&root))?;
let (facts, behaviours) = BlockFacts::load_pair(Path::new(&root), &registry)?;
println!(
"resource_load_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
let len = registry.len() as u32;
let portal: Vec<u32> = (0..len)
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_some())
.collect();
let inactive_states: Vec<u32> = (0..len)
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_none())
.collect();
let _ = &facts;
let portal_state = *portal
.first()
.ok_or("no nether portal block state in the registry")?;
println!("portal_states={}", portal.len());
// 500 entities spread over a 17x17 chunk world. Their reaches touch 1-4
// sections, as an item or a mob standing in or stepping into a portal does.
let grid = 17;
let base = -(grid / 2) * 16;
let entities: Vec<(Vec3, Vec3, f32, f32)> = (0..500)
.map(|i| {
let x = base + (i % grid) * 16 + (i % 5);
let z = base + (i / grid % grid) * 16 + (i % 7);
let y = 64 + (i % 3) * 16;
let from = Vec3::new(f64::from(x), f64::from(y), f64::from(z));
let to = from.add_vector(Vec3::new(0.1, 0.0, 0.07));
(from, to, 0.25, 0.25)
})
.collect();
let reaches: Vec<Aabb> = entities
.iter()
.map(|&(from, to, w, h)| reach(from, to, w, h))
.collect();
for (name, palette, portals_every) in [
("sparse", 1usize, 0usize),
("dense", 128, 0),
("global", 300, 0),
("dense_portal", 128, 5),
] {
let start = Instant::now();
let mut world: FxHashMap<(i32, i32), Arc<Chunk>> = FxHashMap::default();
for czi in 0..grid {
for cxi in 0..grid {
let (cx, cz) = (-(grid / 2) + cxi, -(grid / 2) + czi);
let mut chunk = Chunk::empty(cx, cz);
for (i, section) in chunk.sections.iter_mut().enumerate() {
let values: [u32; 4096] = std::array::from_fn(|j| {
if portals_every > 0 && i % portals_every == 0 && j == 0 {
portal_state
} else {
inactive_states[(j * 7 + i) % palette.min(inactive_states.len())]
}
});
Arc::make_mut(section).blocks = Section::from_blocks(&values);
}
world.insert((cx, cz), Arc::new(chunk));
}
}
println!(
"{name} fixture_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
let keys: Vec<_> = world.keys().copied().collect();
let portal_chunks: Vec<(i32, i32)> = keys
.iter()
.copied()
.filter(|&(cx, cz)| {
world[&(cx, cz)]
.sections
.iter()
.any(|s| s.blocks.maybe_has(|v| v == portal_state))
})
.collect();
println!("{name} portal_chunks={}", portal_chunks.len());
// The global palette's maybe_has walks the whole ID range, so rescanning
// every chunk each tick (cold/reload/edit_all) is intractable; those rows
// are only meaningful for the palettes whose scans are bounded.
let workloads: &[(&str, usize)] = if name == "global" {
&[("steady", 0), ("edit_1pct", 3)]
} else {
&[
("steady", 0),
("cold", 0),
("reload", 0),
("edit_1pct", 3),
("edit_all", 289),
]
};
for &(workload, changed) in workloads {
let mut baseline = Vec::new();
let mut candidate = Vec::new();
let mut cache = PortalSections::default();
let (mut base_hits, mut cand_hits) = (0u64, 0u64);
for sample in 0..8 {
for cached in if sample % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let start = Instant::now();
let mut result = 0u64;
for tick in 0..300 {
if cached && workload == "cold" {
cache = PortalSections::default();
}
if cached && workload == "reload" {
cache.clear();
}
for key in keys.iter().take(changed) {
Arc::make_mut(world.get_mut(key).unwrap()).set(
0,
MIN_Y,
0,
inactive_states[tick % inactive_states.len()],
);
}
let mut seen = FxHashMap::default();
for reach in &reaches {
let hit = if cached {
may_hold_portal_cached(*reach, &world, &mut cache, &behaviours)
} else {
may_hold_portal_baseline(*reach, &world, &mut seen, &behaviours)
};
result += hit as u64;
}
}
if cached {
cache = PortalSections::default();
cand_hits += black_box(result);
} else {
base_hits += black_box(result);
}
if sample > 0 {
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
if cached {
candidate.push(us);
} else {
baseline.push(us);
}
}
}
}
assert_eq!(
base_hits, cand_hits,
"{name} {workload}: cached may_hold_portal diverged"
);
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
}
}
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
Ok(())
}
@@ -1,8 +1,7 @@
//! Replays `tools/test_ambient_sound_parity.py` scripts through Horizon's
//! ambient sound handlers over its `SoundEngine`:
//! `ambient_fixture ROOT INPUT OUTPUT`.
#[path = "audio_common/mod.rs"]
mod common;
use crate::audio_common as common;
use common::{Fake, d, f, flush};
use horizon_client::ambience::{Ambience, AmbientContext, AmbientSounds, Mood, bubble_column_in};
use horizon_client::sound_engine::{Bound, SoundEngine, TickContext, Ticking};
@@ -47,8 +46,8 @@ fn sounds(a: &[&str]) -> AmbientSounds {
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
pub fn main() {
let args: Vec<String> = crate::args().collect();
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
engine.listening = true;
let mut backend = Fake::default();
@@ -131,6 +130,7 @@ fn main() {
let tick = TickContext {
player_underwater: ambience.was_underwater(),
player_removed: removed,
..TickContext::default()
};
engine.tick(false, &tick, &mut backend);
}
@@ -0,0 +1,365 @@
//! Arrows over the client's resident scene (`arrows::tick`), for
//! tools/test_arrow_parity.py: one line of state and one of events per operation.
#![forbid(unsafe_code)]
use horizon_client::{
Result,
arrows::{
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, EntityHit, Pickup,
Touch,
},
assets::Assets,
blocks::BlockFacts,
collisions,
items::ItemStack,
mobs::Who,
motion::{LAVA, MotionResources, WATER},
projectiles::{Hit, Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
use serde_json::Value;
use std::{
fs,
io::{BufRead, Write},
path::PathBuf,
};
fn number(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn vector(v: &Value) -> Vec3 {
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
}
fn bits(v: Vec3) -> String {
format!(
"{:016x},{:016x},{:016x}",
v.x.to_bits(),
v.y.to_bits(),
v.z.to_bits()
)
}
fn seed(v: &Value) -> i64 {
v.as_str().unwrap().parse().unwrap()
}
fn particle(k: ArrowParticle) -> &'static str {
match k {
ArrowParticle::Bubble => "bubble",
ArrowParticle::Crit => "crit",
ArrowParticle::Splash => "splash",
}
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: arrow_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
}
let root = PathBuf::from(&args[0]);
let dir = PathBuf::from(&args[1]);
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
let mut scene = World::test_scene(&assets, &render, 1)?;
fs::write(
dir.join("scene.json"),
serde_json::to_vec(
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
)?,
)?;
if args[2] == "--describe" {
return Ok(());
}
let binding = collisions::load(&root, assets.registry)?;
let motion = MotionResources::load(&root, &assets)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let side = &args[2];
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
let mut level_random = LegacyRandom::new(0);
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
let f: Value = serde_json::from_str(&line?)?;
let kind = ProjectileKind::Arrow {
base_damage: 2.0,
critical: f["crit"].as_bool().unwrap(),
effect: None,
};
let mut arrow = Projectile::new(kind, vector(&f["position"]), None)
.with_arrow_random(LegacyRandom::new(seed(&f["seed"])));
arrow.velocity = vector(&f["velocity"]);
arrow.arrow.as_mut().unwrap().no_physics = f["no_physics"].as_bool().unwrap();
// ProjectileWeaponItem.useAmmo's stack, intangible when no ammo was used.
arrow.arrow.as_mut().unwrap().set_item(&arrows::drawn_ammo(
&ItemStack::new("minecraft:arrow", 1),
f["intangible"].as_bool().unwrap(),
));
// The entity in the way: its box and whether it takes the damage.
let mut target: Option<(Aabb, bool)> = None;
for op in f["ops"].as_array().unwrap() {
let mut events = Vec::new();
let mut touched = "-".to_string();
match op["mode"].as_str().unwrap() {
"tick" => {
// The game's part of an entity hit: the damage it deals
// (recorded after the tick's own events).
let mut hurt = None;
if !arrow.arrow.as_ref().unwrap().removed {
let no_rain = |_| false;
let view = ArrowScene {
world: &scene,
registry: assets.registry,
facts: &facts,
collision: &binding,
fluids: &motion.fluids,
raining: &no_rain,
mob_griefing: true,
tnt_explodes: true,
};
let targets: Vec<_> = target.iter().map(|t| (Who::Mob(2), t.0)).collect();
let (_, hit) = arrows::tick(&mut arrow, &view, &targets);
if let (Hit::Entity { .. }, Some((_, takes))) = (hit, target) {
let mut st = arrow.arrow.take().unwrap();
let damage = arrow.arrow_damage(&mut st.random);
hurt = Some((st.events.len(), damage));
arrow.arrow = Some(st);
let outcome = if takes {
EntityHit::Hurt
} else {
EntityHit::Refused
};
arrows::finish_entity_hit(&mut arrow, &view, outcome);
}
}
let a = arrow.arrow.as_mut().unwrap();
let (drop_at, dropped) = (arrow.position, a.pickup_item.count);
let raised = std::mem::take(&mut a.events);
let count = raised.len();
for (i, event) in raised.into_iter().map(Some).chain([None]).enumerate() {
if let Some((at, damage)) = hurt
&& at == i
{
events.push(format!("hurt {:08x}", damage.to_bits()));
}
let Some(event) = event else {
debug_assert_eq!(i, count);
break;
};
match event {
ArrowEvent::Particle {
kind,
position,
velocity,
} => events.push(format!(
"particle {} {} {}",
particle(kind),
bits(position),
bits(velocity)
)),
ArrowEvent::Sound {
sound,
volume,
pitch,
} => events.push(format!(
"sound {} {:08x} {:08x}",
match sound {
ArrowSound::HitGround => "hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
},
volume.to_bits(),
pitch.to_bits()
)),
ArrowEvent::BubbleColumnParticles { pos } => {
for _ in 0..2 {
let x = f64::from(pos.x) + level_random.next_double();
let z = f64::from(pos.z) + level_random.next_double();
let at = Vec3::new(x, f64::from(pos.y + 1), z);
events.push(format!(
"sent splash {} {} {:016x}",
bits(at),
bits(Vec3::ZERO),
1.0f64.to_bits()
));
let x = f64::from(pos.x) + level_random.next_double();
let z = f64::from(pos.z) + level_random.next_double();
let at = Vec3::new(x, f64::from(pos.y + 1), z);
events.push(format!(
"sent bubble {} {} {:016x}",
bits(at),
bits(Vec3::new(0.0, 0.01, 0.0)),
0.2f64.to_bits()
));
}
}
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
// TntBlock.prime: PrimedTnt draws its push from the
// level random, then removeBlock(pos, false).
let _ = level_random.next_double();
let at = Vec3::new(
f64::from(pos.x) + 0.5,
f64::from(pos.y),
f64::from(pos.z) + 0.5,
);
events.push(format!("spawn tnt {} 80", bits(at)));
events.push(format!(
"sound tnt_primed {:08x} {:08x}",
1.0f32.to_bits(),
1.0f32.to_bits()
));
events.push("prime-event".into());
let air =
assets.registry.default_state("minecraft:air").unwrap();
scene.set(pos.x, pos.y, pos.z, air)?;
events.push(format!(
"write {},{},{} {} 3",
pos.x, pos.y, pos.z, air.0
));
}
BlockReaction::Light(lit) => {
scene.set(pos.x, pos.y, pos.z, lit)?;
events.push(format!(
"write {},{},{} {} 11",
pos.x, pos.y, pos.z, lit.0
));
}
},
ArrowEvent::SplashEvent => events.push("splash-event".into()),
ArrowEvent::ShootEvent => events.push("shoot-event".into()),
ArrowEvent::Drop => events.push(format!(
"drop {} {dropped}",
bits(Vec3::new(
drop_at.x,
drop_at.y + f64::from(0.1f32),
drop_at.z
))
)),
}
}
}
"target" => {
// EntityDimensions.scalable(0.6, 1.8).makeBoundingBox.
let at = vector(&op["position"]);
let w = f64::from(0.6f32) / 2.0;
let h = f64::from(1.8f32);
let b = Aabb::new(
Vec3::new(at.x - w, at.y, at.z - w),
Vec3::new(at.x + w, at.y + h, at.z + w),
);
target = Some((b, op["takes"].as_bool().unwrap()));
}
"block" => {
let p = &op["position"];
let mut state = assets
.registry
.default_state(op["name"].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = op["properties"].as_object() {
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
scene.set(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
state,
)?;
}
"velocity" => arrow.velocity = vector(&op["value"]),
"ignite" => arrow
.arrow
.as_mut()
.unwrap()
.ignite_for_seconds(f32::from_bits(
u32::from_str_radix(op["value"].as_str().unwrap(), 16).unwrap(),
)),
"level_seed" => level_random.set_seed(seed(&op["value"])),
"owner" => arrow
.arrow
.as_mut()
.unwrap()
.set_owner(match op["value"].as_str() {
Some("player") => Some(Who::Player),
Some("mob") => Some(Who::Mob(1)),
_ => None,
}),
"pickup" => {
arrow.arrow.as_mut().unwrap().pickup =
Pickup::from_byte(op["value"].as_i64().unwrap())
}
// The game's touch: the inventory takes the arrow unless full,
// and a creative player takes it anyway.
"touch" => {
let creative = op["creative"].as_bool().unwrap();
let full = op["inventory"] == "full";
let a = arrow.arrow.as_mut().unwrap();
let (taken, arrows) = match a.player_touch(creative) {
Touch::Ignore => (false, 0),
Touch::Take => (true, 0),
Touch::Give(_) => (!full || creative, u32::from(!full)),
};
if taken {
a.removed = true;
}
touched = format!("{taken} {arrows}");
}
other => return Err(format!("unknown operation {other}").into()),
}
let a = arrow.arrow.as_ref().unwrap();
writeln!(
out,
"arrow {} {} {:08x} {:08x} {} {} {} {} {} {} {} {} {} {:016x} {:016x} {} {} {:016x} {}",
bits(arrow.position),
bits(arrow.velocity),
arrow.pitch.to_bits(),
arrow.yaw.to_bits(),
a.in_ground,
a.in_ground_time,
a.life,
a.shake,
a.last_state.map_or("none".into(), |s| s.0.to_string()),
a.fire_ticks,
a.was_touching_water,
a.was_eye_in_water,
a.first_tick,
a.fluids.fluid_height(&Tags, WATER).to_bits(),
a.fluids.fluid_height(&Tags, LAVA).to_bits(),
a.removed,
arrow.critical(),
a.fall_distance.to_bits(),
bits(arrow.previous)
)?;
writeln!(out, "events {}", events.join(" | "))?;
let a = arrow.arrow.as_ref().unwrap();
let intangible = a
.pickup_item
.data()
.and_then(|d| d.other(arrows::INTANGIBLE))
.is_some();
writeln!(out, "pickup {} {intangible} {touched}", a.pickup.byte())?;
}
writeln!(out, "end")?;
}
Ok(())
}
struct Tags;
impl horizon_world::entity_fluid::FluidMembership for Tags {
fn fluid_in_tag(
&self,
kind: horizon_world::fluid::FluidKind,
tag: horizon_world::entity_fluid::FluidTagId,
) -> bool {
use horizon_world::fluid::FluidKind;
if tag == WATER {
matches!(kind, FluidKind::Water | FluidKind::FlowingWater)
} else {
matches!(kind, FluidKind::Lava | FluidKind::FlowingLava)
}
}
}
@@ -18,8 +18,8 @@ fn music(m: Option<&Music>) -> String {
})
}
fn main() {
let args: Vec<String> = std::env::args().collect();
pub fn main() {
let args: Vec<String> = crate::args().collect();
let root = std::path::Path::new(&args[1]);
let tables = MusicTables::load(root);
let ambient = AmbientTables::load(root);
@@ -24,20 +24,21 @@ fn db(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().skip(1).collect();
let [root, input, output, scene_json] = &args[..] else {
return Err("usage: auto_jump_probe RESOURCES INPUT OUTPUT SCENE_JSON".into());
};
let root = PathBuf::from(root);
let assets = Assets::load(&root)?;
let mut scene = World::test_scene(&assets, 1)?;
let render = assets.reload(&root)?;
let mut scene = World::test_scene(&assets, &render, 1)?;
let binding = collisions::load(&root, assets.registry)?;
let resources = MotionResources::load(&root, &assets)?;
fs::write(
scene_json,
serde_json::to_vec(&serde_json::json!({
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": assets.model_gallery,
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": render.model_gallery,
}))?,
)?;
let mut out = BufWriter::new(fs::File::create(output)?);
@@ -100,6 +101,7 @@ fn main() -> Result<()> {
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
@@ -118,8 +118,8 @@ fn status(s: ShulkerStatus) -> &'static str {
}
}
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
pub fn main() -> Result<()> {
let args: Vec<PathBuf> = crate::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, output] = args.as_slice() else {
return Err("usage: block_entity_anim_fixture RESOURCES INPUTS OUTPUT".into());
};
@@ -110,8 +110,8 @@ fn intern(s: &str) -> &'static str {
}
}
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
pub fn main() -> Result<()> {
let args: Vec<PathBuf> = crate::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, textures, output] = args.as_slice() else {
return Err("usage: block_entity_render_fixture RESOURCES INPUTS TEXTURES OUTPUT".into());
};
@@ -0,0 +1,99 @@
//! The Rust side of tools/test_block_entity_tickers_parity.py: the ops of
//! `VanillaBlockEntityTickerOracle` on `BlockEntities`, as `World` drives it
//! (a state write calls `state_changed`), printing the tick order.
use horizon_client::{
block_entities::{BlockEntities, Pos, Ticking},
containers::BlockEntity,
};
use std::collections::HashMap;
/// State ids of the fixture: 0 air, 1 a block whose block entity ticks, 2 a
/// jukebox without a record, 3 a block entity that never ticks (a chest).
fn state(name: &str) -> u32 {
match name {
"chest" | "barrel" => 3,
"jukebox" => 2,
_ => 1,
}
}
fn entity(name: &str) -> BlockEntity {
if name.starts_with("jukebox") {
// The game makes a jukebox's block entity when a record goes in.
return BlockEntity::Jukebox(Default::default());
}
BlockEntity::for_block(&format!("minecraft:{name}"))
.unwrap_or_else(|| panic!("no block entity for {name}"))
}
pub fn main() {
let args: Vec<String> = crate::args().collect();
let input = std::fs::read_to_string(&args[1]).expect("input");
let mut out = String::new();
let mut entities = BlockEntities::default();
let mut states: HashMap<Pos, u32> = HashMap::new();
let mut kinds: HashMap<Pos, String> = HashMap::new();
let table = vec![Ticking::No, Ticking::Yes, Ticking::Idle, Ticking::No];
for line in input.lines() {
let w: Vec<&str> = line.split_whitespace().collect();
let pos = |i: usize| -> Pos {
let n = |j: usize| w[i + j].parse::<i32>().expect("coordinate");
(n(0), n(1), n(2))
};
match w[0] {
"reset" => {
entities = BlockEntities::default();
entities.set_states(table.clone());
states.clear();
kinds.clear();
}
"load" => {
let mut chunk = Vec::new();
for i in (3..w.len()).step_by(4) {
let p = pos(i + 1);
states.insert(p, state(w[i]));
kinds.insert(p, w[i].to_owned());
chunk.push((p, entity(w[i])));
}
entities.load_chunk(chunk);
}
"place" => {
let p = pos(2);
// World::set, then the game adds the block entity.
let old = states.insert(p, state(w[1])).unwrap_or(0);
kinds.insert(p, w[1].to_owned());
entities.state_changed(p, old, state(w[1]));
entities.insert(p, entity(w[1]));
}
"replace" => {
let p = pos(1);
entities.insert(p, entity(&kinds[&p]));
}
"remove" => {
let p = pos(1);
entities.remove(&p);
let old = states.insert(p, 0).unwrap_or(0);
entities.state_changed(p, old, 0);
}
"record" => {
let p = pos(1);
let s = if w[4] == "1" { 1 } else { 2 };
let old = states.insert(p, s).unwrap_or(0);
entities.state_changed(p, old, s);
}
"unload" => {
let c = (w[1].parse::<i32>().unwrap(), w[2].parse::<i32>().unwrap());
entities.retain(|p, _| (p.0 >> 4, p.2 >> 4) != c);
}
"tick" => {
out.push_str("tick");
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
}
out.push('\n');
}
other => panic!("unknown op {other}"),
}
}
std::fs::write(&args[2], out).expect("output");
}
@@ -0,0 +1,63 @@
//! Transport for ClientLevel shape-aware terrain-particle placement.
use horizon_client::{assets::Assets, blocks::BlockFacts, mesher::MeshTables, models, particles};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::registry::BlockStateId;
use serde_json::{Value, json};
use std::{
fs,
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
};
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(&args[1]);
let assets = Assets::load(root)?;
let render = assets.reload(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let tables = MeshTables::new(
&assets,
&render,
&facts,
models::load_visibility(root, assets.registry)?,
)?;
let mut output = BufWriter::new(fs::File::create(&args[3])?);
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
let pos: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
let offset = if facts.get(state).outline_offset {
tables.offset(state, pos[0], pos[2]).unwrap_or([0.0; 3])
} else {
[0.0; 3]
};
let boxes: Vec<[f64; 6]> = facts
.get(state)
.outline
.iter()
.map(|b| std::array::from_fn(|i| b[i] + offset[i % 3]))
.collect();
let mut hits = Vec::new();
let mut next = Vec::new();
for face in [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
] {
let mut random = LegacyRandom::new(row["seed"].as_i64().unwrap());
hits.push(particles::breaking_position(&mut random, pos, face, &boxes));
next.push(random.next_long());
}
let mut destroy = Vec::new();
particles::destroy_positions(pos, &boxes, |p, _| destroy.push(p));
serde_json::to_writer(
&mut output,
&json!({"case":row["case"],"hits":hits,"next":next,"destroy":destroy}),
)?;
output.write_all(&[10])?;
}
output.flush()?;
Ok(())
}
@@ -51,8 +51,8 @@ impl Around for Column {
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: block_transform_fixture RESOURCES INPUT OUTPUT")?,
@@ -0,0 +1,78 @@
//! Headless transport for real model-shaped breaking overlays.
use horizon_client::{
assets::Assets,
blocks::{BlockFacts, RenderShape},
breaking,
mesher::MeshTables,
models,
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
use serde_json::{Value, json};
use std::{
fs,
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];
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = crate::args().collect();
let root = Path::new(&args[1]);
let assets = Assets::load(root)?;
let render = assets.reload(root)?;
let facts = BlockFacts::load(root, assets.registry)?;
let tables = MeshTables::new(
&assets,
&render,
&facts,
models::load_visibility(root, assets.registry)?,
)?;
let mut output = BufWriter::new(fs::File::create(&args[3])?);
for line in BufReader::new(fs::File::open(&args[2])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let state = BlockStateId(row["state"].as_u64().unwrap() as u32);
let p: [i32; 3] = serde_json::from_value(row["pos"].clone())?;
let camera: [f64; 3] = serde_json::from_value(row["camera"].clone())?;
let mut vertices = Vec::new();
let mut translucent = false;
if facts.get(state).render_shape == RenderShape::Model {
let models = render
.model_catalog
.collect(state, BlockPos::new(p[0], p[1], p[2]))?;
let offset = tables.offset(state, p[0], p[2]).unwrap_or([0.0; 3]);
let origin = (glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
- glam::DVec3::from(camera))
.as_vec3()
+ glam::DVec3::from(offset).as_vec3();
translucent = models
.iter()
.any(|(_, m)| m.faces.iter().any(|f| f.translucent));
for (_, m) in models {
breaking::geometry(m, origin, translucent, STAGE, &mut vertices);
}
}
let quads: Vec<_> = vertices
.as_chunks::<4>()
.0
.iter()
.map(|q| {
q.iter()
.map(|v| {
assert_eq!(v.color, 0xffff_ffff);
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]]
})
.collect::<Vec<_>>()
})
.collect();
serde_json::to_writer(
&mut output,
&json!({"case":row["case"],"translucent":translucent,"quads":quads}),
)?;
output.write_all(&[10])?;
}
output.flush()?;
Ok(())
}
@@ -323,8 +323,8 @@ fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
Ok(())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
Loaded 100 of 2018 files, more files were not shown because too many files have changed in this diff. Show more