Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 2818c89f24 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]>
2026-10-17 20:54:13 +02:00
Ludovic MarieandClaude Opus 5.5 9821bb7ef5 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]>
2026-10-17 19:34:46 +02:00
Ludovic MarieandClaude Opus 5.5 b9f3821f24 Make agents, the host window and LAN guests work on the server thread
With single player on its server thread, the window is a client of the
server, so what agents and harnesses did on the window's own state no
longer reached the world:

- control `give`, `time` and the agent's `command` run on the server as the
  world's owner (owner_console_command), in the owner's level;
- `defaultgamemode` run by a guest or the owner reaches the hub, so new
  guests join in the right mode (the hub is out while a guest acts);
- the remote client sends its held slot before using or attacking
  (MultiPlayerGameMode.ensureHasSentCarriedItem);
- remote chat lines feed the agent's event log (`wait-for chat`);
- the remote client applies another player's crouch flag, and the entity
  tracker sends Entity's shared flags and pose back at 0 (a player standing
  up again was never sent);
- a respawned player is removed from and paired again for the other
  players (their clients kept the dead body);
- a teleport drops the guest's moves sent before it, so a fall in them no
  longer hurts the guest where it lands;
- flight summaries report the server's tick time (`tick_ms`).

lan_e2e finds the guest by name in `lan status` (the owner is listed too
on the server thread), reads what the host window draws from its remote
view, respawns a dead guest by its death screen, and lets natural
regeneration heal during the pvp-off hit. agent_control_e2e waits two ticks
where the server answers a click.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 18:54:08 +02:00
Ludovic MarieandClaude Opus 5.5 4ff23b686d 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]>
2026-10-17 16:17:12 +02:00
Ludovic Marie 30f0c715b2 Merge remote-tracking branch 'origin/feat/remote-recipe-book' into pt5/integration
# Conflicts:
#	tools/lan_e2e.py
2026-10-17 16:12:11 +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 5f12705a29 Merge remote-tracking branch 'origin/feat/server-command-tree' into pt5/integration
# Conflicts:
#	tools/lan_e2e.py
2026-10-17 16:07:31 +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 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 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 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 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 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 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 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 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
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 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 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
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
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 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 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 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 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 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 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
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
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
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
rdbtCVS 0c161a0336 Cache random-tick section eligibility for unchanged chunks 2026-10-15 10:37:46 +01: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
586 changed files with 81759 additions and 5183 deletions

No files matched your search

+13 -2
View File
@@ -27,6 +27,13 @@ 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
@@ -34,19 +41,23 @@ jobs:
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- 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 libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
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
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: cargo +stable test --workspace
parity:
if: >-
@@ -72,7 +83,7 @@ jobs:
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
run: python3 tools/parity.py --changed ${{ github.event.pull_request.base.sha }} --timeout 1500
run: python3 tools/parity.py --changed ${{ github.event.pull_request.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
+3 -1
View File
@@ -1,6 +1,7 @@
name: launcher
# 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 what it builds in (crates/horizon-prepare, crates/horizon-theme),
# push to main that changes the launcher or what it builds in (crates/horizon-prepare, crates/horizon-theme,
# crates/horizon-telemetry),
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
# for the game.
# A release is launcher-vX.Y.Z: the patch after the last launcher tag, or the next minor when a merged pull
@@ -16,6 +17,7 @@ on:
- 'launcher/**'
- 'crates/horizon-prepare/**'
- 'crates/horizon-theme/**'
- 'crates/horizon-telemetry/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
+6 -5
View File
@@ -109,18 +109,18 @@ jobs:
- 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 and horizon-prepare
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare; do
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: 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; do
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)
@@ -137,6 +137,7 @@ jobs:
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --version
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
"$bin/horizon-server${{ matrix.exe }}" --version
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
@@ -154,7 +155,7 @@ jobs:
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 --frames 1500 --timeout-seconds 180 \
--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
+3
View File
@@ -24,7 +24,10 @@
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
/horizon-extras.txt
/horizon-player.txt
/servers.dat
/screenshots/
/.horizon-cache/
/crash-reports/
/debug/
+3
View File
@@ -4,6 +4,9 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
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`).
Generated
+1434 -10
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server"]
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "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-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
+2
View File
@@ -199,6 +199,8 @@ It runs rustfmt, clippy with warnings denied, the tests of the crates you touche
[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)).
+31
View File
@@ -0,0 +1,31 @@
# 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.
```
@@ -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);
+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"));
}
}
+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
+589
View File
@@ -0,0 +1,589 @@
//! `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`.
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::{
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
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--report FILE a JSON summary at the end
";
#[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,
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,
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"))?,
"--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,
/// 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)?;
}
}
/// 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);
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);
Ok(())
}
cb::CHUNK_BATCH_FINISHED => send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 25.0,
})
.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);
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;
}
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 fast in creative: about 22 blocks a second, which keeps new chunks coming.
pos[0] += heading.cos() * 1.1;
pos[2] += heading.sin() * 1.1;
}
}
}
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 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,
})
);
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,
"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>(),
"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),
"error": *b.error.lock().unwrap(),
})).collect::<Vec<_>>(),
});
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 });
}
+11 -4
View File
@@ -12,7 +12,11 @@ horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
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).
horizon-mods = { path = "../horizon-mods" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
winit = "0.30.13"
pollster = "0.4"
@@ -22,6 +26,11 @@ png = "0.18"
sha2 = "0.10.9"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
toml = "1.1"
naga = { version = "30.0.1", features = ["wgsl-in"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
@@ -37,6 +46,8 @@ arboard = { version = "3.6.1", default-features = false, features = ["wayland-da
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input).
gilrs = "0.11"
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense" }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
@@ -50,7 +61,3 @@ rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
[dev-dependencies]
# Parses and validates the composed entity shader (already built for wgpu).
naga = { version = "30.0.1", features = ["wgsl-in"] }
@@ -131,6 +131,7 @@ fn main() {
let tick = TickContext {
player_underwater: ambience.was_underwater(),
player_removed: removed,
..TickContext::default()
};
engine.tick(false, &tick, &mut backend);
}
+56 -5
View File
@@ -4,7 +4,8 @@
use horizon_client::{
Result,
arrows::{
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, Pickup, Touch,
self, ArrowEvent, ArrowParticle, ArrowScene, ArrowSound, BlockReaction, EntityHit, Pickup,
Touch,
},
assets::Assets,
blocks::BlockFacts,
@@ -12,11 +13,11 @@ use horizon_client::{
items::ItemStack,
mobs::Who,
motion::{LAVA, MotionResources, WATER},
projectiles::{Projectile, ProjectileKind},
projectiles::{Hit, Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
geometry::Vec3,
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
use serde_json::Value;
@@ -89,11 +90,16 @@ fn main() -> Result<()> {
&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 {
@@ -106,9 +112,35 @@ fn main() -> Result<()> {
mob_griefing: true,
tnt_explodes: true,
};
arrows::tick(&mut arrow, &view, &[]);
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);
}
}
for event in arrow.arrow.as_mut().unwrap().events.drain(..) {
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,
@@ -191,9 +223,28 @@ fn main() -> Result<()> {
},
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
@@ -100,6 +100,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,
@@ -0,0 +1,104 @@
//! Full production fact loading, including file reads, interning and temporary destruction.
//! cargo run --profile fast -p horizon-client --example block_facts_bench -- ROOT [SAMPLES]
//! Use SAMPLES=1 with an OS peak-RSS wrapper; `digest` checks all facts
//! and the shape sharing partition (including sharing between outlines and edges).
use horizon_client::blocks::BlockFacts;
use horizon_world::{DATA_VERSION, nbt::Tag, registry::BlockRegistry};
use serde_json::Value;
use std::{
collections::{HashMap, hash_map::DefaultHasher},
fs,
hash::{Hash, Hasher},
hint::black_box,
path::Path,
time::Instant,
};
type Result<T> = horizon_client::Result<T>;
fn tag(value: &Value) -> Result<Tag> {
Ok(match value {
Value::String(s) => Tag::String(s.as_str().into()),
Value::Number(n) => Tag::Int(i32::try_from(n.as_i64().ok_or("registry number")?)?),
Value::Array(a) => Tag::List(a.iter().map(tag).collect::<Result<_>>()?),
Value::Object(o) => Tag::Compound(
o.iter()
.map(|(k, v)| Ok((k.as_str().into(), tag(v)?)))
.collect::<Result<_>>()?,
),
_ => return Err("registry value".into()),
})
}
fn registry(root: &Path) -> Result<BlockRegistry> {
let facts: Value = serde_json::from_slice(&fs::read(root.join("facts.json"))?)?;
Ok(BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(
facts["states"]
.as_array()
.ok_or("registry states")?
.iter()
.map(|s| tag(&s[4]))
.collect::<Result<_>>()?,
),
),
("defaults", tag(&facts["defaults"])?),
]))?)
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let root = Path::new(
args.first()
.ok_or("usage: block_facts_bench ROOT [SAMPLES|digest]")?,
);
let setup = Instant::now();
let registry = registry(root)?;
println!(
"registry_setup_ms={:.3}",
setup.elapsed().as_secs_f64() * 1000.0
);
if args.get(1).is_some_and(|s| s == "digest") {
let facts = BlockFacts::load(root, &registry)?;
let mut hash = DefaultHasher::new();
let mut shapes = HashMap::new();
facts.air.hash(&mut hash);
for state in &facts.states {
format!("{state:?}").hash(&mut hash);
for shape in [&state.outline, &state.outline_edges] {
let next = shapes.len();
shapes
.entry((shape.as_ptr(), shape.len()))
.or_insert(next)
.hash(&mut hash);
}
}
println!(
"states={} shared_shapes={} digest={:016x}",
facts.states.len(),
shapes.len(),
hash.finish()
);
return Ok(());
}
let samples: usize = args.get(1).map(|s| s.parse()).transpose()?.unwrap_or(9);
assert!(samples > 0);
// First load is reported separately; OS file caches are not forcibly evicted.
let load = || -> Result<f64> {
let start = Instant::now();
let facts = black_box(BlockFacts::load(black_box(root), black_box(&registry))?);
let ms = start.elapsed().as_secs_f64() * 1000.0;
drop(facts);
Ok(ms)
};
println!("first_load_ms={:.3}", load()?);
if samples > 1 {
load()?;
load()?;
let mut times = (0..samples).map(|_| load()).collect::<Result<Vec<_>>>()?;
println!("samples_ms={times:.3?}");
times.sort_by(f64::total_cmp);
println!("median_ms={:.3}", times[times.len() / 2]);
}
Ok(())
}
@@ -139,6 +139,7 @@ fn main() -> Result<()> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
cull_leaves: false,
};
let mut best = f64::MAX;
let mut quads = 0usize;
@@ -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(())
}
@@ -88,6 +88,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
seed: 0,
max_players: 8,
dedicated: false,
integrated: false,
},
);
let mut next_entity = 100u32;
+11 -2
View File
@@ -103,8 +103,17 @@ fn main() -> horizon_client::Result<()> {
}
_ => {}
}
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
let clicked = menu.click(&env, slot, button, kind);
let clicked = if op[2] == "QUICK_MOVE_ALL" {
// A Shift double click: lastQuickMoved is the op's fourth field.
let moved = parse_stack(op[3].as_str().unwrap_or("-"))?;
match moved {
Some(moved) => menu.quick_move_all(&env, slot as usize, &moved),
None => Ok(()),
}
} else {
let kind = Input::parse(op[2].as_str().unwrap_or_default()).ok_or("input")?;
menu.click(&env, slot, button, kind)
};
if let Some(f) = furnace.as_mut() {
let before = f.items[0].clone();
f.items = [0, 1, 2].map(|i| menu.slot(i).cloned());
@@ -31,14 +31,7 @@ fn main() -> horizon_client::Result<()> {
};
let s = sprites.mips[layer as usize];
writeln!(out, "== {name}")?;
for mip in horizon_client::mipmap::generate(
base,
size.trailing_zeros(),
s.strategy,
s.alpha_cutoff_bias,
s.has_transparent,
s.item,
) {
for mip in horizon_client::mipmap::generate(base, size.trailing_zeros(), s) {
write!(out, "{}x{}", mip.width, mip.height)?;
for row in mip.pixels.chunks(mip.width as usize) {
out.push(' ');
+16 -5
View File
@@ -19,7 +19,7 @@ use horizon_client::{
},
mobs::{
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
control::Operation, spawning::LocalDifficulty,
control::Operation, seen_grid::SeenGrid, spawning::LocalDifficulty,
},
world::World,
};
@@ -902,6 +902,10 @@ fn main() -> horizon_client::Result<()> {
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"ridden" => {
m.extra.head = Some("minecraft:red_harness".into());
m.rider = Some(horizon_client::mobs::equine::Rider::default());
}
"noai" => m.no_ai = true,
// Warden.finalizeSpawn's own part.
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
@@ -1007,6 +1011,9 @@ fn main() -> horizon_client::Result<()> {
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
mob_world!($snapshot, None)
};
($snapshot:expr, $grid:expr) => {
MobWorld {
teams: Default::default(),
world: &world,
@@ -1017,6 +1024,7 @@ fn main() -> horizon_client::Result<()> {
data: &data,
player: player.clone(),
others: $snapshot,
others_grid: $grid,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
game_time: world.game_time,
day_time: world.day_time,
@@ -1061,6 +1069,8 @@ fn main() -> horizon_client::Result<()> {
p.yaw = yaw;
}
let mut snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
// The snapshot by position, kept as the game keeps it.
let mut grid = SeenGrid::new(snapshot.iter().map(|e| e.bounds));
let w = mob_world!(&snapshot);
let mut events = Vec::new();
let mut heart_events = Vec::new();
@@ -1126,7 +1136,7 @@ fn main() -> horizon_client::Result<()> {
if mobs[i].removed {
continue;
}
let w = mob_world!(&snapshot);
let w = mob_world!(&snapshot, Some(&grid));
let m = &mut mobs[i];
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
@@ -1242,9 +1252,10 @@ fn main() -> horizon_client::Result<()> {
}
// ServerLevel ticks entities one after another: the ones
// after this mob see it as it is now.
if let Some(e) = snapshot.iter_mut().find(|e| e.id == mobs[i].id) {
*e = seen(&mobs[i]);
e.alive &= !mobs[i].removed;
if let Some(k) = snapshot.iter().position(|e| e.id == mobs[i].id) {
snapshot[k] = seen(&mobs[i]);
snapshot[k].alive &= !mobs[i].removed;
grid.update(k, snapshot[k].bounds);
}
}
for m in &mut mobs {
@@ -0,0 +1,271 @@
//! 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.id == 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,129 @@
//! 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 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, assets.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) = assets.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(())
}
@@ -17,13 +17,16 @@
//! a `mobs::trial_spawner::TrialSpawner` with the game time advancing and
//! print its states, events, spawns and ejected items.
//!
//! Vault cases (`mode=vault`, tools/test_vault_parity.py) tick a
//! `vault::Vault` the same way, with players moving and using keys on it.
//!
//! 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,
items::{ItemStack, Items},
mobs::{
MobTypes, Species,
data::MobData,
@@ -35,8 +38,12 @@ use horizon_client::{
},
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
species::{self, Baby},
trial_spawner::{TrialEvent, TrialPlayer, TrialSpawner, TrialSpawnerLevel, TrialState},
trial_spawner::{
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
TrialSpawnerLevel, TrialState,
},
},
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
};
use horizon_core::{
geometry::Aabb,
@@ -115,8 +122,14 @@ struct World<'a> {
placed: RefCell<Vec<([i32; 3], BlockStateId)>>,
/// The game time (trial cases advance it).
time: Cell<i64>,
/// The mobs a trial spawner added: UUID, position and box.
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], Aabb)>>,
/// The mobs a trial spawner added: UUID, position, height and box.
trial_mobs: RefCell<Vec<([i32; 4], [f64; 3], f32, Aabb)>>,
/// How many mobs a trial spawner added (their UUIDs count up).
trial_count: Cell<i32>,
/// Each player's omen and its amplifier (`/trial_omen:A`, `/bad_omen:A`).
omens: RefCell<Vec<Option<(Omen, i32)>>>,
/// Loot tables (trial cases).
loot: Option<std::sync::Arc<horizon_world::loot::LootData>>,
}
impl World<'_> {
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
@@ -378,6 +391,16 @@ impl TrialSpawnerLevel for World<'_> {
eye: [p[0], p[1] + f64::from(1.62f32), p[2]],
creative: mode == "creative",
spectator: mode == "spectator",
position: *p,
height: 1.8,
alive: true,
omen: self
.omens
.borrow()
.get(i)
.copied()
.flatten()
.map(|(o, _)| o),
})
.collect()
}
@@ -400,9 +423,38 @@ impl TrialSpawnerLevel for World<'_> {
}
fn entity_block(&self, uuid: [i32; 4]) -> Option<[i32; 3]> {
let mobs = self.trial_mobs.borrow();
let (_, p, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
let (_, p, ..) = mobs.iter().find(|(u, ..)| *u == uuid)?;
Some(p.map(|v| v.floor() as i32))
}
fn entity_box(&self, uuid: [i32; 4]) -> Option<([f64; 3], f32)> {
let mobs = self.trial_mobs.borrow();
let (_, p, h, _) = mobs.iter().find(|(u, ..)| *u == uuid)?;
Some((*p, *h))
}
fn no_block_collision(&self, block: [i32; 3]) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
let mut scene = LookupCollisions {
block: &lookup,
binding: self.s.collision,
};
let state = self.state(block[0], block[1], block[2]);
self.s
.collision
.collision_shape(&mut scene, state, block)
.map_or(true, |shape| shape.is_empty())
}
fn loot_items(&self, table: &str, seed: i64) -> Vec<horizon_client::items::ItemStack> {
let Some(loot) = &self.loot else {
return Vec::new();
};
loot.roll_split(table, &mut LegacyRandom::new(seed))
.iter()
.map(horizon_client::structure_blocks::loot_item_with_components)
.collect()
}
fn seed(&self) -> i64 {
self.seed
}
}
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
@@ -472,7 +524,7 @@ fn main() -> horizon_client::Result<()> {
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
// Trial spawner rewards: the loot tables and the configs.
let loot = if input.contains("mode=trial") {
let loot = if input.contains("mode=trial") || input.contains("mode=vault") {
horizon_client::mobs::trial_spawner::load_registry(root);
horizon_client::structure_blocks::loot_data(root)
} else {
@@ -581,6 +633,19 @@ fn main() -> horizon_client::Result<()> {
bounds: Bx(b),
});
}
// A player's third field: an omen and its amplifier.
let omens = indexed("p")
.iter()
.map(|p| {
let (effect, amplifier) = p.split('/').nth(2)?.split_once(':')?;
let omen = if effect == "trial_omen" {
Omen::Trial
} else {
Omen::Bad
};
Some((omen, amplifier.parse().ok()?))
})
.collect();
let players = indexed("p")
.into_iter()
.map(|p| {
@@ -667,6 +732,9 @@ fn main() -> horizon_client::Result<()> {
placed: RefCell::new(Vec::new()),
time: Cell::new(0),
trial_mobs: RefCell::new(Vec::new()),
trial_count: Cell::new(0),
omens: RefCell::new(omens),
loot: loot.clone(),
entities: RefCell::new(
case_entities
.iter()
@@ -706,6 +774,9 @@ fn main() -> horizon_client::Result<()> {
} else if mode == "trial" {
let loot = loot.as_ref().ok_or("no loot tables")?;
trial_case(&world, registry, loot, &get, &mut random, &mut log)?;
} else if mode == "vault" {
let loot = loot.as_ref().ok_or("no loot tables")?;
vault_case(world.players.clone(), loot, &get, &mut random, &mut log)?;
} else if mode == "gen" {
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
// The level random of the spawn predicates: no creature's predicate
@@ -972,8 +1043,22 @@ fn trial_case(
let scale = size as f32;
let bounds = spawn_box(x, y, z, t.width as f32 * scale, t.height as f32 * scale);
l.entities.borrow_mut().push((r.id.to_owned(), bounds));
let uuid = [1, 0, 0, l.trial_mobs.borrow().len() as i32];
l.trial_mobs.borrow_mut().push((uuid, r.position, bounds));
let uuid = [1, 0, 0, l.trial_count.get()];
l.trial_count.set(l.trial_count.get() + 1);
// Zombie.readAdditionalSaveData: IsBaby halves the box
// (getAgeScale); finalizeSpawn's fixed random never makes one.
let baby = match r
.entity
.get(&horizon_world::nbt::JavaString::from("IsBaby"))
{
Some(Tag::Byte(v)) => *v != 0,
Some(Tag::Int(v)) => *v != 0,
_ => false,
};
let height = t.height as f32 * scale * if baby { 0.5 } else { 1.0 };
l.trial_mobs
.borrow_mut()
.push((uuid, r.position, height, bounds));
spawned.push_back(format!(
"{tick} spawn {} {} {} {} {}",
r.id,
@@ -1028,7 +1113,7 @@ fn trial_case(
for e in events {
match e {
TrialEvent::BlockUpdated => log.push(format!("{tick} update")),
TrialEvent::LevelEvent(id, _, data) => {
TrialEvent::LevelEvent(id, p, data) => {
// A spawn's events follow its tryAddFreshEntityWithPassengers,
// a reward's its items.
match id {
@@ -1036,7 +1121,13 @@ fn trial_case(
3014 => log.append(&mut items),
_ => {}
}
log.push(format!("{tick} levent {id} {data}"));
match id {
3020 | 3012 => log.push(format!(
"{tick} levent {id} {data} at {},{},{}",
p[0], p[1], p[2]
)),
_ => log.push(format!("{tick} levent {id} {data}")),
}
}
TrialEvent::Sound(name) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} 1.0 1.0",
@@ -1048,6 +1139,44 @@ fn trial_case(
"{tick} gevent minecraft:entity_place {} {} {}",
p[0], p[1], p[2]
)),
TrialEvent::TrialOmen(uuid) => {
let i = (uuid[3] - 1) as usize;
let mut omens = world.omens.borrow_mut();
let amplifier = omens.get(i).copied().flatten().map_or(0, |(_, a)| a);
log.push(format!("{tick} effect remove minecraft:bad_omen"));
log.push(format!(
"{tick} effect add minecraft:trial_omen {} 0",
TRIAL_OMEN_TICKS_PER_LEVEL * (amplifier + 1)
));
if let Some(o) = omens.get_mut(i) {
*o = Some((Omen::Trial, 0));
}
}
TrialEvent::Discard(uuid) => {
let mut mobs = world.trial_mobs.borrow_mut();
if let Some(i) = mobs.iter().position(|(u, ..)| *u == uuid) {
let (.., b) = mobs.remove(i);
world.entities.borrow_mut().retain(|(_, e)| *e != b);
}
}
TrialEvent::SoundAt(name, b, pitch) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} 1.0 {pitch:?}",
bits(f64::from(b[0]) + 0.5),
bits(f64::from(b[1]) + 0.5),
bits(f64::from(b[2]) + 0.5)
)),
TrialEvent::ItemSpawner {
item,
position,
delay,
} => log.push(format!(
"{tick} ispawner {} {} {} {} {} {delay}",
item.item,
item.count,
bits(position[0]),
bits(position[1]),
bits(position[2])
)),
TrialEvent::OminousSet(set) => {
ominous = set;
world
@@ -1101,3 +1230,195 @@ fn trial_case(
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
Ok(())
}
/// What a vault reads: the game time and the case's players, moved by `move`.
struct VaultWorld {
time: i64,
players: Vec<([f64; 3], String)>,
}
impl VaultLevel for VaultWorld {
fn game_time(&self) -> i64 {
self.time
}
fn players(&self) -> Vec<VaultPlayer> {
self.players
.iter()
.enumerate()
.map(|(i, (p, mode))| VaultPlayer {
uuid: [0, 0, 0, i as i32 + 1],
block: p.map(|v| v.floor() as i32),
spectator: mode == "spectator",
})
.collect()
}
}
/// A UUID's low 64 bits, as the oracle prints players.
fn uuid_low(u: &[i32; 4]) -> String {
((i64::from(u[2]) << 32) | i64::from(u[3] as u32)).to_string()
}
/// The display item and the connected players as the oracle prints them.
fn vault_shared(v: &Vault) -> String {
let mut connected: Vec<String> = v.connected_players.iter().map(uuid_low).collect();
connected.sort();
format!(
"{} [{}]",
v.display_item
.as_ref()
.map_or_else(|| "-".to_owned(), |s| format!("{}*{}", s.item, s.count)),
connected.join(",")
)
}
/// `VaultBlockEntity.Server.tick` `ticks` times from game time `t0` at `spos`
/// on a vault block in state `state` (ominous with `ominous=1`) loaded from
/// `snbt` (base64 SNBT), with the case's key uses (`use`) and player moves
/// (`move`) before each tick, as the oracle logs it.
fn vault_case(
players: Vec<([f64; 3], String)>,
loot: &horizon_world::loot::LootData,
get: &dyn Fn(&str, &'static str) -> String,
random: &mut LegacyRandom,
log: &mut Vec<String>,
) -> horizon_client::Result<()> {
let v: Vec<i32> = get("spos", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
let pos = [v[0], v[1], v[2]];
let mut state = VaultState::from_name(&get("state", "inactive")).ok_or("bad state")?;
let ominous = get("ominous", "0") == "1";
let text = horizon_client::skins::base64_decode(&get("snbt", "")).ok_or("bad snbt")?;
let tag = horizon_world::nbt::parse_snbt(&String::from_utf8(text)?)?.0;
let mut vault = Vault::load(&tag);
let schedule = |key: &str| -> Vec<Vec<String>> {
get(key, "")
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.split('/').map(str::to_owned).collect())
.collect()
};
let (uses, moves) = (schedule("use"), schedule("move"));
let ticks: i64 = get("ticks", "1").parse()?;
let t0: i64 = get("t0", "0").parse()?;
let seed: i64 = get("wseed", "0").parse()?;
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
let mut world = VaultWorld { time: t0, players };
let center = pos.map(|v| f64::from(v) + 0.5);
for tick in 0..ticks {
world.time = t0 + tick;
let at = |s: &[String]| s[0].parse() == Ok(tick);
for m in moves.iter().filter(|m| at(m)) {
let v: Vec<f64> = m[2].split(',').map(|n| n.parse().unwrap_or(0.0)).collect();
world.players[m[1].parse::<usize>()?].0 = [v[0], v[1], v[2]];
}
let mut events = Vec::new();
let mut roll = |table: &str, random: &mut LegacyRandom, sequence: bool| {
let drops = match loot.random_sequence(table).filter(|_| sequence) {
Some(s) => loot.roll_split(table, sequences.get(s, seed)),
None => loot.roll_split(table, random),
};
drops
.iter()
.map(horizon_client::structure_blocks::loot_item_with_components)
.collect()
};
for u in uses.iter().filter(|u| at(u)) {
let player: usize = u[1].parse()?;
let mut stack = ItemStack::new(&u[2], u[3].parse()?);
let used = vault.use_item_on(
&world,
random,
pos,
state,
ominous,
[0, 0, 0, player as i32 + 1],
Some(&stack),
&mut roll,
&mut events,
);
if world.players[player].1 != "creative" {
stack.count -= used.unwrap_or(0);
}
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
log.push(format!(
"{tick} use {} {}",
if used.is_some() { "ok" } else { "pass" },
stack.count
));
}
vault.server_tick(&world, random, pos, state, ominous, &mut roll, &mut events);
vault_events(&mut events, &mut state, ominous, tick, center, &vault, log);
}
let saved = vault.save();
let config = saved
.iter()
.find(|(k, _)| *k == "config")
.and_then(|(_, t)| match t {
Tag::Compound(c) => {
let mut keys: Vec<String> = c.keys().map(|k| k.to_string_lossy()).collect();
keys.sort();
Some(keys.join(","))
}
_ => None,
})
.unwrap_or_default();
let eject: Vec<String> = vault
.items_to_eject
.iter()
.map(|s| format!("{}*{}", s.item, s.count))
.collect();
log.push(format!(
"end state={} rewarded={} resumes={} eject={} total={} shared={} config={config}",
state.name(),
vault
.rewarded_players
.iter()
.map(uuid_low)
.collect::<Vec<_>>()
.join(","),
vault.state_updating_resumes_at,
eject.join(","),
vault.total_ejections_needed,
vault_shared(&vault),
));
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
Ok(())
}
/// Logs a vault's events and applies its state changes.
fn vault_events(
events: &mut Vec<VaultEvent>,
state: &mut VaultState,
ominous: bool,
tick: i64,
center: [f64; 3],
vault: &Vault,
log: &mut Vec<String>,
) {
for e in events.drain(..) {
match e {
VaultEvent::State(s) => {
*state = s;
log.push(format!("{tick} state {} {ominous}", s.name()));
}
VaultEvent::BlockUpdated => log.push(format!("{tick} update {}", vault_shared(vault))),
VaultEvent::LevelEvent(id, data) => log.push(format!("{tick} levent {id} {data}")),
VaultEvent::Sound(name, volume, pitch) => log.push(format!(
"{tick} sound minecraft:{name} {} {} {} {volume:?} {pitch:?}",
bits(center[0]),
bits(center[1]),
bits(center[2])
)),
VaultEvent::Eject(stack, v) => log.push(format!(
"{tick} item {} {} {} {} {} {} {} {}",
stack.item,
stack.count,
bits(center[0]),
bits(center[1] + 0.7 - 0.125),
bits(center[2]),
bits(v[0]),
bits(v[1]),
bits(v[2])
)),
}
}
}
@@ -1,7 +1,10 @@
#![forbid(unsafe_code)]
//! Loads and saves option files like `Options.load`/`save`, for the
//! differential harness (tools/test_options_parity.py).
use horizon_client::{Result, keybinds, options_file::OptionsFile};
use horizon_client::{
Result, keybinds,
options_file::{OptionsFile, V},
};
use std::{fs, io::Write};
fn main() -> Result<()> {
@@ -14,11 +17,16 @@ fn main() -> Result<()> {
let mut first = true;
for path in names {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let file = if name == "absent.txt" {
let mut file = if name == "absent.txt" {
OptionsFile::default()
} else {
OptionsFile::parse(&String::from_utf8_lossy(&fs::read(&path)?))
};
// Horizon extends the slider to 64; vanilla rejects 33..64 to its default.
// Compare that deliberate extension as vanilla would serialize it.
if file.int("renderDistance") > 32 {
file.set("renderDistance", V::Int(12));
}
write!(out, "== {name}\n{}", file.serialize())?;
if first {
first = false;
@@ -0,0 +1,96 @@
//! Block entity data on items for tools/test_pick_block_data_parity.py: reads
//! one JSON case per line (see VanillaPickBlockDataOracle) and prints the
//! picked item's component patch, a tab, then the placed block entity.
//!
//! usage: pick_block_data_fixture INPUT OUTPUT
use horizon_client::block_entity_item::{pick, place};
use horizon_client::block_entity_nbt::canonical;
use horizon_client::java_text::{double_to_string, float_to_string};
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::fs;
/// ParityData.canon: sorted keys, typed numbers, Java's float text.
fn canon(tag: &Tag, out: &mut String) {
let join = |out: &mut String, prefix: &str, items: Vec<String>| {
out.push_str(prefix);
out.push_str(&items.join(","));
out.push(']');
};
match tag {
Tag::Float(v) => out.push_str(&format!("{}f", float_to_string(f32::from_bits(*v)))),
Tag::Double(v) => out.push_str(&format!("{}d", double_to_string(f64::from_bits(*v)))),
Tag::List(items) => join(
out,
"[",
items
.iter()
.map(|t| {
let mut s = String::new();
canon(t, &mut s);
s
})
.collect(),
),
Tag::Compound(map) => {
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
keys.sort_by(|a, b| a.0.cmp(&b.0));
out.push('{');
for (i, (k, v)) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&k);
out.push(':');
canon(v, out);
}
out.push('}');
}
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
}
}
fn snbt(value: &Value) -> Option<Tag> {
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let saved = canonical(&snbt(&case["nbt"])?)?;
let picked = pick(&saved);
let patch = match case.get("patch") {
Some(p) => match snbt(p)? {
Tag::Compound(c) => c,
_ => return None,
},
None => picked.clone(),
};
let target = canonical(&snbt(&case["target"])?)?;
let Tag::Compound(mut placed) = place(&target, &patch, case["op"].as_bool()?) else {
return None;
};
// keepPacked is the chunk's, not saveWithFullMetadata's.
placed.remove(&"keepPacked".into());
let mut out = String::new();
canon(&Tag::Compound(picked), &mut out);
out.push('\t');
canon(&Tag::Compound(placed), &mut out);
Some(out)
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: pick_block_data_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -0,0 +1,149 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
fast_hash::FxHashMap,
random_tick_sections::RandomTickSections,
};
use horizon_world::registry::BlockStateId;
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
fn select_baseline(chunks: &FxHashMap<(i32, i32), Arc<Chunk>>, facts: &BlockFacts) -> usize {
let mut ticking = Vec::new();
for (&(x, z), chunk) in chunks {
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
continue;
}
let sections: Vec<i32> = chunk
.sections
.iter()
.enumerate()
.filter(|(_, s)| {
s.blocks
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
})
.map(|(i, _)| MIN_Y + i as i32 * 16)
.collect();
ticking.push((x, z, sections));
}
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
}
fn select_cached(
chunks: &FxHashMap<(i32, i32), Arc<Chunk>>,
facts: &BlockFacts,
cache: &mut RandomTickSections,
) -> usize {
let mut ticking = Vec::new();
for (&(x, z), chunk) in chunks {
if x.abs() > 8 || z.abs() > 8 || !chunk.lit {
continue;
}
ticking.push((x, z, cache.get((x, z), chunk, facts)));
}
black_box(ticking).iter().map(|(_, _, s)| s.len()).sum()
}
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 = BlockFacts::load(Path::new(&root), &registry)?;
println!(
"resource_load_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
let inactive: Vec<u16> = facts
.states
.iter()
.enumerate()
.filter(|(_, f)| !f.random_ticks)
.take(128)
.map(|(i, _)| i as u16)
.collect();
let active = registry.default_state("minecraft:grass_block").unwrap().0 as u16;
assert!(facts.get(BlockStateId(u32::from(active))).random_ticks);
for (name, palette) in [("sparse", 1), ("dense", 128)] {
let start = Instant::now();
let mut chunks = FxHashMap::default();
for z in -8..=8 {
for x in -8..=8 {
let mut chunk = Chunk::empty(x, z);
chunk.lit = true;
for (i, section) in chunk.sections.iter_mut().enumerate() {
let mut values = std::array::from_fn(|j| inactive[j % palette]);
if i % 3 == 0 {
values[4095] = active;
}
Arc::make_mut(section).blocks = Section::from_blocks(&values);
}
chunks.insert((x, z), Arc::new(chunk));
}
}
println!(
"{name} fixture_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
for (workload, changed) in [
("steady", 0),
("cold", 0),
("reload", 0),
("edit_1pct", 3),
("edit_all", 289),
] {
let keys: Vec<_> = chunks.keys().copied().collect();
let mut baseline = Vec::new();
let mut candidate = Vec::new();
let mut cache = RandomTickSections::default();
for sample in 0..8 {
// Alternate order; first pair warms both paths and is discarded.
for cached in if sample % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let start = Instant::now();
for tick in 0..300 {
if cached && workload == "cold" {
cache = RandomTickSections::default();
}
if cached && workload == "reload" {
cache.clear();
}
for key in keys.iter().take(changed) {
Arc::make_mut(chunks.get_mut(key).unwrap()).set(
0,
MIN_Y,
0,
inactive[tick % 2],
);
}
black_box(if cached {
select_cached(&chunks, &facts, &mut cache)
} else {
select_baseline(&chunks, &facts)
});
}
// Include teardown, and remove weak handles before baseline edits.
if cached {
cache = RandomTickSections::default();
}
if sample > 0 {
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
if cached {
candidate.push(us);
} else {
baseline.push(us);
}
}
}
}
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
}
}
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
Ok(())
}
@@ -2,6 +2,11 @@
#![forbid(unsafe_code)]
use horizon_client::{assets, collisions, world};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::{
collision_context::CollisionContext,
entity_obstruction::EntityIdentity,
world_movement::{self, CollisionCheckRequest, MovementRequest},
};
use serde_json::Value;
use std::{
fs,
@@ -57,21 +62,55 @@ fn main() -> Result<()> {
f["step"].as_str().ok_or("expected step bits")?,
16,
)?);
let movement = scene.resolve(
bounds,
delta,
step,
f["grounded"].as_bool().ok_or("expected grounded flag")?,
let entities = f["entities"]
.as_array()
.into_iter()
.flatten()
.map(|v| {
let a = v.as_array().ok_or("expected entity box")?;
Ok(Aabb::new(
vector(&Value::Array(a[..3].to_vec()))?,
vector(&Value::Array(a[3..].to_vec()))?,
))
})
.collect::<Result<Vec<_>>>()?;
let mut selected = collisions::EntityCollisions {
blocks: scene,
entities: &entities,
};
let movement = world_movement::resolve_world_movement(
&mut selected,
MovementRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement: delta,
max_step: step,
on_ground: f["grounded"].as_bool().ok_or("expected grounded flag")?,
candidate_budget: 1_000_000,
},
)?;
let mut unobstructed = |bounds: Aabb| {
world_movement::no_world_collision(
&mut selected,
CollisionCheckRequest {
entity: EntityIdentity(0),
context: CollisionContext::position(bounds.min_position().y),
bounds,
candidate_budget: 1_000_000,
},
)
};
writeln!(
output,
"move {:016x} {:016x} {:016x} clear {} {}",
movement.x.to_bits(),
movement.y.to_bits(),
movement.z.to_bits(),
scene.unobstructed(bounds)?,
scene.unobstructed(bounds.move_by(movement))?
unobstructed(bounds)?,
unobstructed(bounds.move_by(movement))?
)?;
scene = selected.blocks;
}
Ok(())
}
@@ -298,6 +298,7 @@ fn main() -> Result<()> {
let mut travel: Option<horizon_core::position::BlockPos> = None;
for op in f["ops"].as_array().unwrap() {
let mut world = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
@@ -134,6 +134,7 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
cull_leaves: false,
};
let section_y = int(&row["section_y"])?;
let started = Instant::now();
@@ -0,0 +1,74 @@
//! Profile the fact and behaviour table phase of client startup.
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
fn resident_kib() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let line = status.lines().find(|line| line.starts_with("VmRSS:"))?;
line.split_whitespace().nth(1)?.parse().ok()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let root = args.next().ok_or("expected resource directory")?;
let mode = args
.next()
.ok_or("expected baseline, pr607, shared, deferred or deferred-held")?;
if !["baseline", "pr607", "shared", "deferred", "deferred-held"].contains(&mode.as_str()) {
return Err("expected baseline, pr607, shared, deferred or deferred-held".into());
}
let root = Path::new(&root);
let total = Instant::now();
let assets = Assets::load(root)?;
let started = Instant::now();
let mut retained_inputs = None;
let mut subphases = None;
let (facts, behaviours) = if mode == "baseline" {
(
BlockFacts::load(root, assets.registry)?,
Behaviours::load(assets.registry, root)?,
)
} else if mode == "pr607" {
// Original PR #607 sequence at a01d47f2, retained as a same-build comparator.
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
BlockFacts::load_with_behaviours(root, assets.registry, &json)?
} else if mode == "shared" {
BlockFacts::load_pair(root, assets.registry)?
} else {
let parse_started = Instant::now();
let json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
let facts_started = Instant::now();
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
let behaviours_started = Instant::now();
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
let behaviours_ms = behaviours_started.elapsed().as_secs_f64() * 1000.0;
subphases = Some((parse_ms, facts_ms, behaviours_ms));
if mode == "deferred-held" {
retained_inputs = Some((json, gameplay));
}
(facts, behaviours)
};
println!("phase_ms={:.3}", started.elapsed().as_secs_f64() * 1000.0);
println!("total_ms={:.3}", total.elapsed().as_secs_f64() * 1000.0);
println!("states={}", facts.states.len());
if let Some((parse, facts, behaviours)) = subphases {
println!("behaviour_json_ms={parse:.3}");
println!("facts_ms={facts:.3}");
println!("behaviours_ms={behaviours:.3}");
}
if let Some(rss) = resident_kib() {
println!("post_rss_kib={rss}");
}
std::hint::black_box((facts, behaviours, retained_inputs));
Ok(())
}
@@ -5,7 +5,7 @@
mod common;
use common::{Fake, d, describe, f, flush, result_name};
use horizon_client::sound_engine::{
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
};
use horizon_client::sound_events::SoundRegistry;
use horizon_core::random::LegacyRandom;
@@ -19,6 +19,9 @@ fn main() {
let mut backend = Fake::default();
let mut ctx = TickContext::default();
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
// Script cart names to entity ids (a removed cart leaves the level).
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
@@ -49,6 +52,21 @@ fn main() {
"loop" => SoundInstance::biome_loop(&event, random),
"uloop" => SoundInstance::under_liquid_loop(&event, random),
"usub" => SoundInstance::under_liquid_sub(&event, random),
"cart" | "rcart" | "rcartu" => {
let id = cart_ids[a[3]];
let cart = ctx
.carts
.iter()
.find(|c| c.id == id)
.or_else(|| removed.get(&id))
.copied()
.expect("cart");
if a[2] == "cart" {
SoundInstance::minecart(&cart, random)
} else {
SoundInstance::riding_minecart(&cart, a[2] == "rcartu", random)
}
}
_ => panic!("{line}"),
};
if let Some(old) = instances.remove(a[1]) {
@@ -92,6 +110,26 @@ fn main() {
ctx.player_underwater = a[1] == "1";
ctx.player_removed = a[2] == "1";
}
"cart" => {
let next = cart_ids.len() as u32 + 1;
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
ctx.carts.retain(|c| c.id != id);
let cart = CartSound {
id,
position: [d(a[2]), d(a[3]), d(a[4])],
// Vec3.horizontalDistance.
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
};
if a[8] == "1" {
// Out of the level, still there for a later play.
removed.insert(id, cart);
} else {
removed.remove(&id);
ctx.carts.push(cart);
}
}
"ride" => ctx.vehicle = cart_ids.get(a[1]).copied(),
"freeze" => ctx.frozen = a[1] == "1",
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
"forget" => {
if let Some(id) = instances.remove(a[1]) {
@@ -0,0 +1,227 @@
//! The chunk pipeline (generation, decoration, lighting, meshing) on its worker
//! pool, without a window: how fast a render distance fills with N workers, and
//! what the render thread pays for it in `Streaming::update`.
//!
//! streaming_bench RESOURCES [--threads N] [--radius R] [--seed S] [--fly CHUNKS_PER_S] [--seconds T] [--hz F]
//!
//! First the area around (0, 0) fills; then, with `--fly`, the centre moves along +x
//! at that speed for T seconds while frames run at F per second. Each phase reports
//! its wall time, chunks generated and sections meshed, and the time `update`
//! took on the calling thread (the render thread in the game): total, median,
//! p99 and slowest. Compile with HORIZON_BENCH_COUNT_ALLOCATIONS=1 to count the
//! allocations made on the calling thread during updates and by everything.
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
light::LightTables,
mesher::MeshTables,
models,
streaming::{Streaming, WorkerContext},
vanillagen::VanillaTerrain,
world::World,
};
use std::{
alloc::{GlobalAlloc, Layout, System},
cell::Cell,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicU64, Ordering::Relaxed},
},
time::{Duration, Instant},
};
const TRACK: bool = option_env!("HORIZON_BENCH_COUNT_ALLOCATIONS").is_some();
static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
static BYTES: AtomicU64 = AtomicU64::new(0);
thread_local! {
static LOCAL: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
}
fn count(size: usize) {
if TRACK {
ALLOCATIONS.fetch_add(1, Relaxed);
BYTES.fetch_add(size as u64, Relaxed);
let _ = LOCAL.try_with(|c| {
let (n, b) = c.get();
c.set((n + 1, b + size as u64));
});
}
}
struct CountingAllocator;
// Delegates every operation and layout unchanged to System.
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
count(layout.size());
unsafe { System.alloc(layout) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
count(layout.size());
unsafe { System.alloc_zeroed(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) };
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
count(size);
unsafe { System.realloc(ptr, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[derive(Default)]
struct Phase {
updates: Vec<f64>,
local: (u64, u64),
uploads: usize,
}
impl Phase {
fn update(&mut self, streaming: &mut Streaming, world: &mut World) {
let before = LOCAL.with(Cell::get);
let started = Instant::now();
let (uploads, _) = streaming.update(world);
self.updates.push(started.elapsed().as_secs_f64() * 1e3);
let after = LOCAL.with(Cell::get);
self.local.0 += after.0 - before.0;
self.local.1 += after.1 - before.1;
self.uploads += uploads.len();
drop(uploads);
}
fn report(&self, name: &str, wall: Duration, streaming: &Streaming, generated: u64) {
let mut sorted = self.updates.clone();
sorted.sort_by(f64::total_cmp);
let at = |p: f64| sorted[((sorted.len() as f64 * p) as usize).min(sorted.len() - 1)];
let total: f64 = sorted.iter().sum();
println!(
"{name}: {:.2} s, {} chunks generated, {} sections meshed; update {:.1} ms total over {} calls, p50 {:.3} p99 {:.3} max {:.2} ms",
wall.as_secs_f64(),
streaming.generated_total - generated,
self.uploads,
total,
sorted.len(),
at(0.5),
at(0.99),
sorted.last().copied().unwrap_or(0.0),
);
if TRACK {
println!(
" update allocations: {} ({:.1} MiB)",
self.local.0,
self.local.1 as f64 / 1048576.0
);
}
}
}
/// Nothing queued or in flight. Sections at the edge of the area stay dirty
/// (their outer neighbours are never lit), so they don't count.
fn settled(streaming: &Streaming) -> bool {
streaming
.stages()
.iter()
.all(|&(name, n)| name == "protos" || n == 0)
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let root = PathBuf::from(args.first().ok_or("usage: streaming_bench RESOURCES")?);
let flag = |name: &str, default: f64| -> f64 {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.and_then(|v| v.parse().ok())
.unwrap_or(default)
};
let auto = std::thread::available_parallelism().map_or(2, |n| n.get() - 1);
let threads = flag("--threads", auto as f64) as usize;
let radius = flag("--radius", 8.0) as i32;
let seed = flag("--seed", 1234.0) as i64;
let fly = flag("--fly", 0.0);
let seconds = flag("--seconds", 10.0);
let hz = flag("--hz", 240.0);
let assets = Arc::new(Assets::load(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let tables = Arc::new(MeshTables::new(
&assets,
&facts,
models::load_visibility(&root, assets.registry)?,
)?);
let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?);
let terrain = Arc::new(VanillaTerrain::new(
&root.join("worldgen"),
assets.registry,
&biomes,
seed,
)?);
let context = Arc::new(WorkerContext {
assets,
tables,
biomes,
light,
});
let mut world = World::new(facts.air, seed);
let mut streaming = Streaming::new(terrain, context, threads);
streaming.radius = radius;
streaming.center = (0, 0);
println!("{threads} workers, radius {radius}, seed {seed}");
let (allocations, bytes) = (ALLOCATIONS.load(Relaxed), BYTES.load(Relaxed));
// Fill: update as often as the game would uncapped, until settled.
let mut phase = Phase::default();
let started = Instant::now();
let mut quiet = 0;
while quiet < 20 {
let uploads = phase.uploads;
phase.update(&mut streaming, &mut world);
quiet = if settled(&streaming) && phase.uploads == uploads {
quiet + 1
} else {
0
};
std::thread::sleep(Duration::from_micros(500));
}
phase.report("fill", started.elapsed(), &streaming, 0);
if fly > 0.0 {
let mut phase = Phase::default();
let generated = streaming.generated_total;
let frame = Duration::from_secs_f64(1.0 / hz);
let started = Instant::now();
let mut behind = Vec::new();
while started.elapsed().as_secs_f64() < seconds {
let next = Instant::now() + frame;
let x = (started.elapsed().as_secs_f64() * fly) as i32;
streaming.center = (x, 0);
phase.update(&mut streaming, &mut world);
// Columns within 2 chunks of the player still missing their meshes.
let near = (-2..=2)
.flat_map(|dz| (-2..=2).map(move |dx| (x + dx, dz)))
.filter(|&(cx, cz)| !streaming.is_lit(cx, cz))
.count();
behind.push(near);
if let Some(wait) = next.checked_duration_since(Instant::now()) {
std::thread::sleep(wait);
}
}
phase.report("fly", started.elapsed(), &streaming, generated);
let unlit = behind.iter().filter(|&&n| n > 0).count();
println!(
" frames with an unlit column within 2 chunks of the player: {unlit} of {} ({:.1}%)",
behind.len(),
unlit as f64 * 100.0 / behind.len().max(1) as f64
);
}
if TRACK {
println!(
"all threads: {} allocations ({:.1} MiB)",
ALLOCATIONS.load(Relaxed) - allocations,
(BYTES.load(Relaxed) - bytes) as f64 / 1048576.0
);
}
Ok(())
}
@@ -0,0 +1,47 @@
//! Transport for the vanilla quad sorting / viewpoint parity gate.
use horizon_client::gfx::translucency::{SortState, Sorter, quad_center};
use serde::Deserialize;
use serde_json::json;
use std::{
fs,
io::{BufWriter, Write},
};
#[derive(Deserialize)]
struct Case {
origin: [i32; 3],
quads: Vec<[[f32; 3]; 4]>,
cameras: Vec<[f64; 3]>,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let [input, output] = args.as_slice() else {
return Err("usage: translucency_fixture INPUT OUTPUT".into());
};
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(input)?.lines() {
let case: Case = serde_json::from_str(line)?;
let centers: Vec<_> = case.quads.iter().map(|q| quad_center(q[0], q[2])).collect();
let mut state = SortState::new(centers.clone());
let mut sorter = Sorter::default();
let mut cache_sorter = Sorter::default();
let frames: Vec<_> = case
.cameras
.iter()
.map(|&camera| {
let update = state
.update(camera, case.origin, &mut cache_sorter)
.is_some();
let local = std::array::from_fn(|i| (camera[i] - f64::from(case.origin[i])) as f32);
json!({"indices": sorter.sort(&centers, local), "update": update})
})
.collect();
let bits: Vec<_> = centers
.iter()
.map(|c| c.map(|f| f.to_bits() as i32))
.collect();
writeln!(out, "{}", json!({"centers": bits, "frames": frames}))?;
}
Ok(())
}
@@ -0,0 +1,40 @@
//! Packet payload oracle companion for tools/test_world_options_parity.py.
use horizon_protocol::{buf::Reader, packets::play::*};
use std::{error::Error, fmt::Write};
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let mut output = String::new();
for line in std::fs::read_to_string(&args[1])?.lines() {
let fields: Vec<&str> = line.split('\t').collect();
let entries = if fields.len() == 3 {
vec![GameRuleEntry {
name: format!("minecraft:{}", fields[1]),
value: fields[2].into(),
}]
} else {
vec![]
};
let bytes = match fields[0] {
"difficulty" => Serverbound::ChangeDifficulty(ChangeDifficultyRequest {
difficulty: fields[1].parse()?,
})
.encode(),
"lock" => Serverbound::LockDifficulty(LockDifficulty {
locked: fields[1].parse()?,
})
.encode(),
"set" => Serverbound::SetGameRule(SetGameRule { entries }).encode(),
"values" => Clientbound::GameRuleValues(GameRuleValues { entries }).encode(),
_ => return Err("unknown case".into()),
};
let mut reader = Reader::new(&bytes);
reader.varint()?;
for byte in reader.rest() {
write!(output, "{byte:02x}")?;
}
output.push('\n');
}
std::fs::write(&args[2], output)?;
Ok(())
}
+58
View File
@@ -175,6 +175,53 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
})
}
/// `CriterionProgress`'s date as saved, "yyyy-MM-dd HH:mm:ss Z", in UTC.
pub fn date_text(unix_secs: i64) -> String {
let days = unix_secs.div_euclid(86400);
let rem = unix_secs.rem_euclid(86400);
// Civil from days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} +0000",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
/// A saved date back to milliseconds since the epoch (the network form of
/// `CriterionProgress`), the zone offset applied.
pub fn date_millis(text: &str) -> Option<i64> {
let (date, rest) = text.trim().split_once(' ')?;
let (time, zone) = rest.split_once(' ').unwrap_or((rest, "+0000"));
let mut d = date.split('-').map(|v| v.parse::<i64>().ok());
let (y, m, day) = (d.next()??, d.next()??, d.next()??);
let mut t = time.split(':').map(|v| v.parse::<i64>().ok());
let (h, min, sec) = (t.next()??, t.next()??, t.next()??);
let sign = if zone.starts_with('-') { -1 } else { 1 };
let zone = zone.trim_start_matches(['+', '-']);
let offset =
zone.get(0..2)?.parse::<i64>().ok()? * 3600 + zone.get(2..4)?.parse::<i64>().ok()? * 60;
// Days from civil (Howard Hinnant's algorithm).
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some((days * 86400 + h * 3600 + min * 60 + sec - sign * offset) * 1000)
}
/// The player's progress: advancement -> criterion -> obtained date.
#[derive(Default, Clone)]
pub struct Progress {
@@ -1059,4 +1106,15 @@ mod tests {
assert_eq!(l[4], Some((1, 2.0)));
assert_eq!(l[1], Some((2, 2.0)));
}
#[test]
fn saved_dates_read_back_to_their_instant() {
for secs in [0, 951_782_400, 1_792_216_800, 4_107_542_399] {
assert_eq!(date_millis(&date_text(secs)), Some(secs * 1000));
}
assert_eq!(
date_millis("2026-10-17 07:30:00 +0200"),
date_millis("2026-10-17 05:30:00 +0000")
);
assert_eq!(date_millis("not a date"), None);
}
}
+111 -18
View File
@@ -1,8 +1,9 @@
//! `AbstractArrow.tick` for arrows, tipped and spectral arrows: the flight with
//! its water inertia and bubbles, the base tick's fluids (currents, splash),
//! bubble columns and water along the path, sticking in blocks, falling out
//! when the block goes, and despawning. Entity hits and their effects stay
//! with the game (`projectile_hit`).
//! when the block goes, and despawning. The tick stops at an entity it hits:
//! the game hurts the entity (`projectile_hit`), then `finish_entity_hit`
//! spends the arrow or turns it back and ends the tick.
use crate::{
blocks::BlockFacts,
collisions::SceneCollisions,
@@ -113,6 +114,9 @@ pub enum ArrowEvent {
/// The SPLASH and PROJECTILE_SHOOT game events.
SplashEvent,
ShootEvent,
/// `spawnAtLocation(pickupItem, 0.1)`: a turned-back arrow too slow to fly
/// on drops what picking it up gives.
Drop,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArrowParticle {
@@ -124,7 +128,7 @@ pub enum ArrowParticle {
pub enum ArrowSound {
/// `entity.generic.splash` (an arrow's splash and high-speed splash).
Splash,
/// The arrow's hit-ground sound (`entity.arrow.hit`).
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
HitGround,
/// `entity.generic.extinguish_fire`.
Extinguish,
@@ -382,14 +386,17 @@ fn signum(v: f64) -> f64 {
v
}
}
/// One arrow tick: whether to keep it and what it hit.
/// One arrow tick: whether to keep it and what it hit. An arrow that hit an
/// entity stops there and is not kept: the game hands it to
/// `finish_entity_hit` once it has hurt the entity.
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
let Some(mut st) = p.arrow.take() else {
return (false, Hit::None);
};
// Entity.setOldPosAndRot: the renderer interpolates from here, in flight
// and stuck alike.
// and stuck alike; ServerLevel.tickNonPassenger counts the tick.
p.previous = p.position;
p.age += 1;
let hit = Ticker {
p,
st: &mut st,
@@ -397,10 +404,44 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
}
.tick(targets);
p.stuck = st.in_ground;
let keep = !st.removed;
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
p.arrow = Some(st);
(keep, hit)
}
/// What became of the entity an arrow hit (`AbstractArrow.onHitEntity`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntityHit {
/// `hurtOrSimulate` succeeded: the arrow is spent, with its hit sound.
Hurt,
/// It failed (a raised shield, invulnerability): the arrow turns back.
Refused,
/// The entity turned it first (`Entity.deflection`: breezes).
Deflected,
}
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
let Some(mut st) = p.arrow.take() else {
return false;
};
let mut ticker = Ticker {
p,
st: &mut st,
scene,
};
match outcome {
EntityHit::Hurt => {
ticker.hit_sound();
ticker.st.removed = true;
}
EntityHit::Refused => ticker.turn_back(),
EntityHit::Deflected => {}
}
ticker.finish();
p.stuck = st.in_ground;
let keep = !st.removed;
p.arrow = Some(st);
keep
}
struct Ticker<'p, 's, 'a, 'r> {
p: &'p mut Projectile,
st: &'s mut ArrowState,
@@ -517,10 +558,20 @@ impl Ticker<'_, '_, '_, '_> {
);
if physics {
hit = self.step_move_and_hit(original, end, targets);
if matches!(hit, Hit::Entity { .. }) {
return hit;
}
} else {
self.p.position = end;
self.apply_effects_from_blocks(original, end);
}
self.finish();
hit
}
/// The end of a tick in flight: drag and gravity, then `Projectile.tick`
/// and `Entity.baseTick`'s fluid and fire phases.
fn finish(&mut self) {
let physics = !self.st.no_physics;
if !self.st.was_touching_water {
self.p.velocity = self.p.velocity.scale(f64::from(0.99f32));
}
@@ -547,7 +598,6 @@ impl Ticker<'_, '_, '_, '_> {
self.st.removed = true;
}
self.st.first_tick = false;
hit
}
/// `getDeltaMovement().add(0, y, 0)`: the zeros added to x and z also
/// turn a negative zero positive.
@@ -589,16 +639,23 @@ impl Ticker<'_, '_, '_, '_> {
self.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
/// block (not piercing: the game removes an arrow that hits an entity).
/// block (piercing is not modelled).
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
// findHitEntities: ProjectileUtil.computeMargin and the search box.
let margin = f64::from(((self.p.age as i32 - 2) as f32 / 20.0).clamp(0.0, 0.3));
let area = bounds(from)
.expand_towards(self.p.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
let mut best: Option<(Who, Vec3, f64)> = None;
for (who, b) in targets {
if Some(*who) == self.p.owner && !self.p.left_owner {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
continue;
}
if let Some(at) = b.inflate(Vec3::new(0.3, 0.3, 0.3)).clip(from, end) {
if let Some(at) = self.entity_hit(b, from, end, margin) {
// Sorted by the distance to the entity's position (the
// stable sort keeps the first of equals).
let c = b.center();
let d = from.distance_squared(Vec3::new(c.x, b.min_position().y, c.z));
if best.is_none_or(|bb| d < bb.2) {
@@ -610,8 +667,6 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
// The game hurts it; the arrow is spent (piercing is not modelled).
self.st.removed = true;
return Hit::Entity { who, at };
}
let Some((at, pos, face)) = block else {
@@ -631,6 +686,49 @@ impl Ticker<'_, '_, '_, '_> {
face,
}
}
/// `ProjectileUtil.getManyEntityHitResult` for one entity box: where the
/// path enters it, else where it enters the box grown by `margin`, if a
/// line from there towards the centre (stopped by blocks) reaches the box.
fn entity_hit(&self, b: &Aabb, from: Vec3, to: Vec3, margin: f64) -> Option<Vec3> {
if let Some(at) = b.clip(from, to) {
return Some(at);
}
if margin <= 0.0 {
return None;
}
let outside = b
.inflate(Vec3::new(margin, margin, margin))
.clip(from, to)?;
let centre = b.center();
let towards = self.scene.clip(outside, centre).map_or(centre, |h| h.0);
b.clip(outside, towards).map(|_| outside)
}
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
volume: 1.0,
pitch,
});
}
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
/// a half turn give or take ten degrees at a tenth of the speed; one
/// left almost still drops its pickup (ALLOWED) and is discarded.
fn turn_back(&mut self) {
let power = 0.2;
let rotation = 170.0 + self.st.random.next_float() * 20.0;
let v = self.p.velocity;
let k = -power * 0.5;
self.p.velocity = Vec3::new(v.x * k, v.y * k, v.z * k);
self.p.yaw += rotation;
if self.p.velocity.length_squared() < 1.0e-7 {
if self.st.pickup == Pickup::Allowed {
self.st.events.push(ArrowEvent::Drop);
}
self.st.removed = true;
}
}
/// `AbstractArrow.onHitBlock`: back off the face by 0.05 along each axis
/// it moved on, stop, and stick.
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
@@ -649,12 +747,7 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position.z - signum(v.z) * offset,
);
self.p.velocity = Vec3::ZERO;
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
volume: 1.0,
pitch,
});
self.hit_sound();
self.st.in_ground = true;
self.st.shake = 7;
self.p.set_critical(false);
+7 -2
View File
@@ -59,6 +59,8 @@ pub struct Assets {
pub pixels: Vec<u8>,
pub texture_layers: u32,
pub model_catalog: crate::models::StateModels,
/// Shared parsed resources; item-only validation errors surface at item preparation.
pub model_library: std::result::Result<Arc<horizon_assets::resolve::ModelLibrary>, String>,
pub model_placements: Vec<(horizon_core::position::BlockPos, BlockStateId)>,
pub model_gallery: Vec<Value>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
@@ -219,7 +221,9 @@ impl Assets {
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let models = crate::models::prepare(root, registry, &mut sprites)?;
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -241,9 +245,10 @@ impl Assets {
material_states,
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
texture_layers: u32::try_from(sprites.layers.len())?,
texture_layers: u32::try_from(sprites.count())?,
pixels: sprites.pixels,
model_catalog: models.catalog,
model_library,
model_placements: models.placements,
model_gallery: models.states,
animations: sprites.animations,
+52 -2
View File
@@ -44,6 +44,7 @@ struct Mixer {
channels: HashMap<ChannelId, Channel>,
listener: [f64; 3],
listener_yaw: f32,
limiter_peak: f32,
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
@@ -397,8 +398,17 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
for s in data.iter_mut() {
*s = s.clamp(-1.0, 1.0);
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 50 ms.
let peak = data.iter().fold(1.0f32, |peak, s| peak.max(s.abs()));
mixer.limiter_peak = mixer.limiter_peak.max(peak);
let release = (-1.0 / (0.05 * rate as f32)).exp();
for frame in data.chunks_mut(channels) {
let gain = mixer.limiter_peak.recip();
for s in frame {
*s *= gain;
}
mixer.limiter_peak = (mixer.limiter_peak * release).max(peak);
}
}
/// Why the game should not open an output, if it should not:
@@ -862,6 +872,46 @@ mod tests {
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
}
#[test]
fn overlapping_voices_are_limited_without_flattening_or_changing_stereo_balance() {
let mut m = Mixer::default();
let wave = vec![0.25, 0.5, 0.75, 1.0, -1.0, -0.75, -0.5, -0.25, 0.0];
for id in 1..=4 {
m.channels
.insert(id, channel(wave.clone(), None, [-1.0, 0.0, 0.0], false));
}
let mut data = [0.0; 16];
mix(&mut m, &mut data, 2, 48_000);
for (frame, sample) in data.as_chunks::<2>().0.iter().zip(wave) {
assert!((frame[0] - sample * 0.4).abs() < 1e-6, "{frame:?}");
assert!((frame[1] - sample).abs() < 1e-6, "{frame:?}");
}
assert!(data.iter().all(|s| s.abs() <= 1.0));
}
#[test]
fn limiter_leaves_quiet_audio_alone_and_recovers_across_callbacks() {
let mut m = Mixer::default();
m.channels
.insert(1, channel(vec![0.25; 2], None, [0.0; 3], true));
let mut data = [0.0; 16];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.25; 16]);
for id in 2..=5 {
m.channels
.insert(id, channel(vec![1.0; 2], None, [0.0; 3], true));
}
mix(&mut m, &mut data, 2, 48_000);
assert!(data.iter().all(|s| s.abs() <= 1.0));
m.channels.retain(|&id, _| id == 1);
let mut recovery = vec![0.0; 2 * 48_000];
mix(&mut m, &mut recovery, 2, 48_000);
assert!(recovery[0] > 0.0 && recovery[0] < 0.1);
assert!(recovery.windows(2).all(|w| w[1] >= w[0]));
assert_eq!(*recovery.last().unwrap(), 0.25);
}
#[test]
fn sources_play_attenuated_then_stop_or_loop() {
let mut m = Mixer::default();
+95
View File
@@ -232,6 +232,87 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
/// What the session service says about a player joining this server
/// (MinecraftServicesSessionService.hasJoinedServer).
#[derive(Clone, Debug, PartialEq)]
pub struct JoinedProfile {
pub uuid: u128,
pub properties: Vec<horizon_protocol::packets::login::Property>,
}
/// HttpDiscoveryService.buildQuery: `URLEncoder.encode(value, UTF-8)`.
fn url_encode(text: &str) -> String {
let mut out = String::new();
for b in text.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'*' | b'_' => {
out.push(char::from(b));
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// The session service's verify endpoint, asked by an online-mode server
/// whether `name` joined with `server_hash` (and from `ip`, when the server
/// prevents proxy connections). `Ok(None)`: it didn't, or the service said
/// no; `Err(Unavailable)`: no answer.
pub fn has_joined(
name: &str,
server_hash: &str,
ip: Option<std::net::IpAddr>,
) -> Result<Option<JoinedProfile>, JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "verify").ok_or(JoinError::Unavailable)?;
let mut query = format!(
"username={}&serverId={}",
url_encode(name),
url_encode(server_hash)
);
if let Some(ip) = ip {
query.push_str(&format!("&ip={}", url_encode(&ip.to_string())));
}
let separator = if url.contains('?') { '&' } else { '?' };
let body = match agent().get(&format!("{url}{separator}{query}")).call() {
Ok(response) => response.into_string().unwrap_or_default(),
Err(ureq::Error::Status(status, response)) => {
let error =
JoinError::from_response(status, &response.into_string().unwrap_or_default());
return match error {
JoinError::Unavailable => Err(error),
_ => Ok(None),
};
}
Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
};
Ok(parse_joined(&body))
}
/// HasJoinedMinecraftServerResponse: `id` and `properties`; no id is no profile.
pub fn parse_joined(body: &str) -> Option<JoinedProfile> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let uuid = parse_uuid(v.get("id")?.as_str()?)?;
let properties = v
.get("properties")
.and_then(|p| p.as_array())
.into_iter()
.flatten()
.filter_map(|p| {
Some(horizon_protocol::packets::login::Property {
name: p.get("name")?.as_str()?.to_owned(),
value: p.get("value")?.as_str()?.to_owned(),
signature: p
.get("signature")
.and_then(|s| s.as_str())
.map(str::to_owned),
})
})
.collect();
Some(JoinedProfile { uuid, properties })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -250,6 +331,20 @@ mod tests {
path
}
#[test]
fn verify_answers() {
assert_eq!(url_encode("Tester_1"), "Tester_1");
assert_eq!(url_encode("-5a f"), "-5a+f");
assert_eq!(url_encode("::1"), "%3A%3A1");
let body = r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch","properties":[{"name":"textures","value":"e30=","signature":"c2ln"}]}"#;
let p = parse_joined(body).unwrap();
assert_eq!(p.uuid, UUID);
assert_eq!(p.properties.len(), 1);
assert_eq!(p.properties[0].signature.as_deref(), Some("c2ln"));
assert_eq!(parse_joined(""), None);
assert_eq!(parse_joined(r#"{"name":"Notch"}"#), None);
}
#[test]
fn session_files_are_read_once_and_must_be_private() {
let dir = std::env::temp_dir().join(format!("horizon-auth-test-{}", std::process::id()));
+26
View File
@@ -199,6 +199,14 @@ pub fn dye_rgb(name: &str) -> u32 {
pub fn layers(stack: &ItemStack) -> &[BannerLayer] {
stack.data().map_or(&[], |d| &d.banner_patterns)
}
/// The ominous banner's component patch hides its pattern tooltip.
pub fn tooltip_layers(stack: &ItemStack) -> &[BannerLayer] {
if stack.data().is_some_and(|d| d.ominous_banner) {
&[]
} else {
layers(stack)
}
}
/// `LoomMenu.setupResultSlot`: one banner with the pattern added in the dye colour.
pub fn loom_result(banner: &ItemStack, dye: &str, pattern: &str) -> ItemStack {
let mut result = ItemStack {
@@ -302,6 +310,24 @@ mod tests {
}
assert!(has_max_layers(&banner));
}
#[test]
fn ominous_banner_hides_tooltip_layers_but_keeps_its_patterns() {
let mut banner = crate::mobs::raider::ominous_banner();
(banner.damage, banner.extra) = crate::item_data::decode_components(
crate::item_data::encode_components(&banner).as_ref(),
);
assert_eq!(
layers(&banner).len(),
crate::item_data::OMINOUS_LAYERS.len()
);
assert!(tooltip_layers(&banner).is_empty());
banner.data_mut().custom_name = Some("Captain's Standard".into());
assert!(tooltip_layers(&banner).is_empty());
banner.data_mut().ominous_banner = false;
assert_eq!(tooltip_layers(&banner), layers(&banner));
assert!(!tooltip_layers(&banner).is_empty());
}
#[test]
fn layers_round_trip_through_nbt() {
let l = vec![
@@ -145,8 +145,16 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl launch --world Survival --seed 42 --wait", "horizon-ctl launch --world Flat --creative --render-distance 8"],
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)")],
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
mcp: true, local: true),
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
["horizon-ctl wait-for screen inventory", "horizon-ctl wait-for loaded --timeout 120", "horizon-ctl wait-for chat joined the game", "horizon-ctl wait-for event death --timeout 600"],
&[flag("timeout", Ty::Num, "seconds (default 30)"), flag("since", Ty::Int, "chat and event: count from this event seq"), p("condition", Ty::Enum(&["screen", "widget", "loaded", "chat", "event"]), "what to wait for"), opt("value", Ty::Rest, "the screen name, widget id, chat text or event kind")], mcp: true, local: false),
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
@@ -248,8 +256,9 @@ pub const COMMANDS: &[Cmd] = &[
&[opt("all", Ty::Bool, "drop the whole stack")], mcp: true, local: false),
cmd!("hold", Act, "hold KEY SECONDS", "press a key, wait, release it", "Keys as for key.", ["horizon-ctl hold w 2"],
&[p("key", Ty::Str, "key name"), p("seconds", Ty::Num, "how long")], mcp: false, local: true),
cmd!("key", Act, "key KEY down|up|tap", "press or release a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)", "",
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap"]), "what to do")], mcp: true, local: false),
cmd!("key", Act, "key KEY down|up|tap|repeat", "press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...)",
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
@@ -266,12 +275,34 @@ pub const COMMANDS: &[Cmd] = &[
["horizon-ctl click-slot 36", "horizon-ctl click-slot 100 right", "horizon-ctl click-slot 200 shift"],
&[p("slot", Ty::Int, "slot id"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click")], mcp: true, local: false),
cmd!("close", Screens, "close", "close the open menu and return to the game", "", ["horizon-ctl close"], &[], mcp: true, local: false),
cmd!("ui", Screens, "ui", "every widget of the open screen: id, kind, label, bounds, value",
"Kinds: button, slider (value 0..1), field, row (worlds, servers, packs, languages), tab (selected), slot (slot-ID, with its stack). Ids come from labels (\"FOV: 70\" is fov, a repeat gets -2) or kind-N without one, and stay put when a value changes. Reflects the last drawn frame (frame counts them).",
["horizon-ctl ui"], &[], mcp: true, local: false),
cmd!("click", Screens, "click ID [right] [shift]", "click a widget of ui by its id, as the mouse would",
"A field takes focus (then type); slot-ID clicks a slot like click-slot.",
["horizon-ctl click done", "horizon-ctl click video-settings", "horizon-ctl click row-0", "horizon-ctl click slot-0 shift"],
&[p("id", Ty::Str, "widget id from ui"), opt("right", Ty::Bool, "right-click"), opt("shift", Ty::Bool, "shift-click (slots)")], mcp: true, local: false),
cmd!("set", Screens, "set ID VALUE", "move a slider of ui to VALUE (0..1), as a click on its track",
"The option behind it rounds to its own steps; read the new label with ui.",
["horizon-ctl set fov 0.5", "horizon-ctl set render-distance 0"],
&[p("id", Ty::Str, "slider id from ui"), p("value", Ty::Num, "0..1 along the track")], mcp: true, local: false),
cmd!("options-get", Screens, "options-get [KEY...]", "the game's options (options.txt) by key, with their type and range or choices",
"Keys are vanilla's: fov, renderDistance, guiScale, gamma, maxFps, lang, resourcePacks, soundCategory_music, key_key.jump... Enums are given and set by name, key bindings by key name (key.keyboard.w).",
["horizon-ctl options-get", "horizon-ctl options-get fov renderDistance"], &[opt("keys", Ty::Rest, "option keys (default: all)")], mcp: true, local: false),
cmd!("options-set", Screens, "options-set KEY VALUE", "set one option, apply it at once and save options.txt, as the options screen does",
"A value the option refuses is an error saying what it accepts. Lists are JSON: options-set resourcePacks [\"vanilla\"].",
["horizon-ctl options-set fov 90", "horizon-ctl options-set renderClouds fast", "horizon-ctl options-set key_key.jump key.keyboard.j"],
&[p("key", Ty::Str, "option key from options-get"), p("value", Ty::Rest, "the new value")], mcp: true, local: false),
cmd!("click-button", Screens, "click-button LABEL", "click a button of the open screen or title menu by its label",
"Case-insensitive; a unique part of the label is enough. screen lists the buttons.",
["horizon-ctl click-button Singleplayer", "horizon-ctl click-button \"Create New World\"", "horizon-ctl click-button respawn"],
&[p("label", Ty::Rest, "button label")], mcp: true, local: false),
cmd!("click-at", Screens, "click-at X Y [right]", "click at GUI pixel X Y of the window (screen gives button rectangles)", "",
["horizon-ctl click-at 200 120"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), opt("right", Ty::Bool, "right-click")], mcp: true, local: false),
cmd!("drag-at", Screens, "drag-at [right] X Y X Y...", "press at the first GUI point, move through the others, release at the last", "",
["horizon-ctl drag-at 130 100 148 100 166 100", "horizon-ctl drag-at right 130 100 148 100"], &[p("points", Ty::Rest, "right, then GUI x y pairs")], mcp: true, local: false),
cmd!("scroll-at", Screens, "scroll-at X Y STEPS", "turn the mouse wheel at GUI pixel X Y (positive scrolls up)", "",
["horizon-ctl scroll-at 130 100 -1"], &[p("x", Ty::Num, "GUI x"), p("y", Ty::Num, "GUI y"), p("steps", Ty::Num, "wheel steps")], mcp: true, local: false),
cmd!("type", Screens, "type [--field name|seed] [--replace] TEXT", "type into the focused text field (chat box, world name, anvil, sign...)",
"On the title's create-world screen, --field name|seed picks the field and --replace clears it first; type \\b deletes one character.",
["horizon-ctl type hello everyone", "horizon-ctl type --field name --replace Agent World", "horizon-ctl type --field seed 12345"], &[flag("field", Ty::Enum(&["name", "seed"]), "create-world field to type into"), flag("replace", Ty::Bool, "clear the field first"), p("text", Ty::Rest, "text to type")], mcp: true, local: false),
@@ -293,7 +324,9 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)", "", ["horizon-ctl wait 40"],
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
&[p("ticks", Ty::Int, "game ticks")], mcp: true, local: false),
// Scripting
cmd!("script", Scripting, "script [FILE] [--keep-going]", "run commands from FILE (or stdin), one per line",
@@ -0,0 +1,485 @@
//! Block entities carried by items, vanilla 26.3: Ctrl + Pick Block in creative
//! (`ServerGamePacketListenerImpl.addBlockDataToItem`) and placing such an item
//! (`BlockItem.updateCustomBlockEntityTag`, then
//! `BlockEntity.applyComponentsFromItemStack`). Both work on the block entity's
//! saved NBT (`block_entity_nbt::canonical`) and the item's component patch.
//!
//! What a type turns into item components (`removeComponentsFromTag`,
//! `collectImplicitComponents`) leaves its NBT; the rest goes to
//! `minecraft:block_entity_data`, tagged with the type's id.
use crate::block_entity_nbt::{self, canonical};
use horizon_world::nbt::{Compound, JavaString, Tag};
pub const BLOCK_ENTITY_DATA: &str = "minecraft:block_entity_data";
/// `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`: the types whose data only a player
/// who can use game master blocks places (`BlockEntityType.onlyOpCanSetNbt`).
pub const OP_ONLY: [&str; 6] = [
"minecraft:command_block",
"minecraft:lectern",
"minecraft:sign",
"minecraft:hanging_sign",
"minecraft:mob_spawner",
"minecraft:trial_spawner",
];
/// The `minecraft:block_entity_data` of `stack`: its type id and its data.
pub fn data(stack: &crate::items::ItemStack) -> Option<(String, &Tag)> {
let data = stack.data()?.other(BLOCK_ENTITY_DATA)?;
Some((block_entity_nbt::id(data)?, data))
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
pub fn spawner_entity(stack: &crate::items::ItemStack) -> Option<String> {
let (_, data) = data(stack)?;
match data.get("SpawnData")?.get("entity")?.get("id")? {
Tag::String(id) => Some(id.to_string_lossy()),
_ => None,
}
}
/// A part of a block entity's NBT that its item holds as a component.
#[derive(Clone, Copy)]
enum Part {
/// The NBT key and the component hold the same value.
Same(&'static str, &'static str),
/// `Items` as `minecraft:container`, in a container of this many slots.
Items(usize),
/// `LootTable` and `LootTableSeed` as `minecraft:container_loot`.
Loot,
/// `is_waxed` as `minecraft:waxed`.
Waxed,
/// A decorated pot's `item` as `minecraft:container`.
PotItem,
/// Left out of the item's data, with no component.
Drop(&'static str),
}
const NAME: Part = Part::Same("CustomName", "minecraft:custom_name");
const LOCK: Part = Part::Same("lock", "minecraft:lock");
const CONTAINER: &str = "minecraft:container";
/// Each type's `removeComponentsFromTag`, `collectImplicitComponents` and
/// `applyImplicitComponents`.
fn parts(id: &str) -> &'static [Part] {
match id {
"minecraft:chest"
| "minecraft:trapped_chest"
| "minecraft:barrel"
| "minecraft:shulker_box" => &[NAME, LOCK, Part::Items(27), Part::Loot],
"minecraft:dispenser" | "minecraft:dropper" | "minecraft:crafter" => {
&[NAME, LOCK, Part::Items(9), Part::Loot]
}
"minecraft:hopper" => &[NAME, LOCK, Part::Items(5), Part::Loot],
"minecraft:furnace" | "minecraft:smoker" | "minecraft:blast_furnace" => {
&[NAME, LOCK, Part::Items(3)]
}
"minecraft:brewing_stand" => &[NAME, LOCK, Part::Items(5)],
"minecraft:beacon" => &[NAME, LOCK],
"minecraft:enchanting_table" => &[NAME],
"minecraft:command_block" => &[NAME, Part::Drop("conditionMet"), Part::Drop("powered")],
"minecraft:campfire" => &[Part::Items(4)],
"minecraft:chiseled_bookshelf" => &[Part::Items(6)],
"minecraft:shelf" => &[Part::Items(3)],
"minecraft:beehive" => &[Part::Same("bees", "minecraft:bees")],
"minecraft:banner" => &[Part::Same("patterns", "minecraft:banner_patterns"), NAME],
"minecraft:decorated_pot" => &[
Part::Same("sherds", "minecraft:pot_decorations"),
Part::PotItem,
],
"minecraft:sign" | "minecraft:hanging_sign" => &[
Part::Same("front_text", "minecraft:sign_text_front"),
Part::Same("back_text", "minecraft:sign_text_back"),
Part::Waxed,
],
"minecraft:skull" => &[
Part::Same("profile", "minecraft:profile"),
Part::Same("note_block_sound", "minecraft:note_block_sound"),
Part::Same("custom_name", "minecraft:custom_name"),
],
_ => &[],
}
}
/// The component a part reads, and what it holds for `tag` (None: unset).
fn collect(part: Part, tag: &Tag) -> Option<(&'static str, Option<Tag>)> {
Some(match part {
Part::Same(key, component) => (
component,
tag.get(key).cloned().or_else(|| unset(component)),
),
Part::Items(_) => {
let slots = match tag.get("Items") {
Some(Tag::List(items)) => items.iter().filter_map(slot_entry).collect(),
_ => Vec::new(),
};
(CONTAINER, Some(Tag::List(slots)))
}
Part::Loot => {
let table = tag.get("LootTable").cloned();
let loot = table.map(|t| {
let mut entries = vec![("loot_table", t)];
if let Some(seed) = tag.get("LootTableSeed") {
entries.push(("seed", seed.clone()));
}
Tag::compound(entries)
});
("minecraft:container_loot", loot)
}
Part::Waxed => (
"minecraft:waxed",
matches!(tag.get("is_waxed"), Some(Tag::Byte(1)))
.then(|| Tag::Compound(Compound::new())),
),
Part::PotItem => {
let slots = match tag.get("item") {
Some(Tag::Compound(item)) => Some(container_slot(0, item.clone())),
_ => None,
};
(CONTAINER, Some(Tag::List(slots.into_iter().collect())))
}
Part::Drop(_) => return None,
})
}
/// What a type that always collects `component` gives when its NBT leaves the
/// value out (its empty value, which the NBT omits).
fn unset(component: &str) -> Option<Tag> {
Some(match component {
"minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
"minecraft:pot_decorations" => Tag::Compound(Compound::new()),
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
block_entity_nbt::sign_side(None)
}
_ => return None,
})
}
/// An `Items` entry as a `minecraft:container` slot.
fn slot_entry(entry: &Tag) -> Option<Tag> {
let Tag::Compound(entry) = entry else {
return None;
};
let mut item = entry.clone();
let slot = match item.remove(&JavaString::from("Slot"))? {
Tag::Byte(s) => i32::from(s),
_ => return None,
};
Some(container_slot(slot, item))
}
/// An `ItemContainerContents` slot: `ItemStackTemplate` leaves out a count of 1.
fn container_slot(slot: i32, mut item: Compound) -> Tag {
if item.get(&JavaString::from("count")) == Some(&Tag::Int(1)) {
item.remove(&JavaString::from("count"));
}
Tag::compound([("slot", Tag::Int(slot)), ("item", Tag::Compound(item))])
}
/// The value of `component` on the clone item of a type that collects it: a
/// picked item leaves it out of its patch, and a placed item without it in its
/// patch gives it to the block entity.
fn item_default(component: &str) -> Option<Tag> {
Some(match component {
CONTAINER | "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()),
"minecraft:pot_decorations" => {
let brick = || Tag::compound([("id", Tag::String("minecraft:brick".into()))]);
Tag::compound([
("back", brick()),
("front", brick()),
("left", brick()),
("right", brick()),
])
}
"minecraft:sign_text_front" | "minecraft:sign_text_back" => {
block_entity_nbt::sign_side(None)
}
_ => return None,
})
}
const METADATA: [&str; 6] = ["id", "x", "y", "z", "keepPacked", "components"];
/// `saveCustomOnly`: the saved NBT without its id, position and components.
fn custom_only(saved: &Tag) -> Compound {
match saved {
Tag::Compound(c) => c
.iter()
.filter(|(k, _)| !METADATA.contains(&k.to_string_lossy().as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
_ => Compound::new(),
}
}
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
/// item of the block entity saved as `saved` (a full, canonical compound).
pub fn pick(saved: &Tag) -> Compound {
let id = block_entity_nbt::id(saved).unwrap_or_default();
let mut patch = Compound::new();
// BlockItem.setBlockEntityData after removeComponentsFromTag.
let mut data = custom_only(saved);
for part in parts(&id) {
let key = match *part {
Part::Same(key, _) | Part::Drop(key) => key,
Part::Items(_) => "Items",
Part::Loot => {
data.remove(&JavaString::from("LootTableSeed"));
"LootTable"
}
Part::Waxed => "is_waxed",
Part::PotItem => "item",
};
data.remove(&JavaString::from(key));
}
if !data.is_empty() {
data.insert("id".into(), Tag::String(id.as_str().into()));
patch.insert(BLOCK_ENTITY_DATA.into(), Tag::Compound(data));
}
// ItemStack.applyComponents(collectComponents()): the block entity's own
// components, then the implicit ones; a value equal to the item's own is
// no change.
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
for (component, value) in collected {
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
patch.remove(&component);
} else {
patch.insert(component, value);
}
}
patch
}
/// The block entity saved as `saved` after an item with component patch
/// `components` is placed as it: `BlockItem.updateCustomBlockEntityTag` (the
/// item's data of the same type, op-only types only when `game_master`, the
/// player can use game master blocks), then `applyComponentsFromItemStack`.
pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
let Some(id) = block_entity_nbt::id(saved) else {
return saved.clone();
};
let Tag::Compound(mut tag) = saved.clone() else {
return saved.clone();
};
// TypedEntityData.loadInto: saveCustomOnly, merge, loadCustomOnly when changed.
if let Some(Tag::Compound(data)) = components.get(&JavaString::from(BLOCK_ENTITY_DATA))
&& data
.get(&JavaString::from("id"))
.is_some_and(|t| matches!(t, Tag::String(s) if s.to_string_lossy() == id))
&& (game_master || !OP_ONLY.contains(&id.as_str()))
{
let custom = custom_only(saved);
let mut merged = custom.clone();
let mut data = data.clone();
data.remove(&JavaString::from("id"));
crate::data_cmd::merge_compound(&mut merged, &data);
if merged != custom {
for key in METADATA {
if let Some(v) = tag.get(&JavaString::from(key)) {
merged.insert(key.into(), v.clone());
}
}
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
tag = loaded;
}
}
}
// applyImplicitComponents, then the rest of the patch as the block
// entity's own components.
let mut read = vec![BLOCK_ENTITY_DATA, "minecraft:block_state"];
// The item's components: its patch over its own defaults.
let get = |c: &str| match components.get(&JavaString::from(c)) {
Some(v) => Some(v.clone()),
None if components.contains_key(&JavaString::from(format!("!{c}").as_str())) => None,
None => item_default(c),
};
let mut set = |key: &str, value: Option<Tag>| match value {
Some(v) => tag.insert(key.into(), v),
None => tag.remove(&JavaString::from(key)),
};
for part in parts(&id) {
match *part {
Part::Same(key, component) => {
read.push(component);
set(key, get(component));
}
Part::Items(size) => {
read.push(CONTAINER);
let items = container_slots(get(CONTAINER).as_ref())
.filter(|(slot, _)| (*slot as usize) < size)
.map(|(slot, mut item)| {
item.insert("Slot".into(), Tag::Byte(slot as i8));
Tag::Compound(item)
})
.collect();
set("Items", Some(Tag::List(items)));
}
Part::Loot => {
read.push("minecraft:container_loot");
if let Some(loot) = get("minecraft:container_loot") {
set("LootTable", loot.get("loot_table").cloned());
set("LootTableSeed", loot.get("seed").cloned());
}
}
Part::Waxed => {
read.push("minecraft:waxed");
set(
"is_waxed",
Some(Tag::Byte(i8::from(get("minecraft:waxed").is_some()))),
);
}
Part::PotItem => {
read.push(CONTAINER);
let first = container_slots(get(CONTAINER).as_ref()).find(|(slot, _)| *slot == 0);
set("item", first.map(|(_, item)| Tag::Compound(item)));
}
Part::Drop(_) => {}
}
}
let own: Compound = components
.iter()
.filter(|(k, _)| {
let k = k.to_string_lossy();
!k.starts_with('!') && !read.contains(&k.as_str())
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
tag.insert("components".into(), Tag::Compound(own));
let tag = Tag::Compound(tag);
canonical(&tag).unwrap_or(tag)
}
/// The slots of a `minecraft:container` component.
fn container_slots(container: Option<&Tag>) -> impl Iterator<Item = (i32, Compound)> + '_ {
let list = match container {
Some(Tag::List(list)) => list.as_slice(),
_ => &[],
};
list.iter().filter_map(|entry| {
let slot = match entry.get("slot")? {
Tag::Int(s) => *s,
_ => return None,
};
match entry.get("item")? {
Tag::Compound(item) => Some((slot, item.clone())),
_ => None,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn snbt(text: &str) -> Tag {
horizon_world::nbt::parse_snbt(text).expect("snbt").0
}
fn saved(text: &str) -> Tag {
canonical(&snbt(text)).expect("known type")
}
fn canon(patch: &Compound) -> String {
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
}
#[test]
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
let chest = saved(
"{id:\"minecraft:chest\",x:1,y:2,z:3,CustomName:\"Loot\",\
Items:[{Slot:4b,id:\"minecraft:stone\",count:5}]}",
);
let patch = pick(&chest);
assert_eq!(
canon(&patch),
crate::item_data::canon_tag(&snbt(
"{\"minecraft:container\":[{slot:4,item:{id:\"minecraft:stone\",count:5}}],\
\"minecraft:custom_name\":\"Loot\"}"
))
);
// Placing it back gives the same chest.
let empty = saved("{id:\"minecraft:chest\",x:1,y:2,z:3}");
assert_eq!(place(&empty, &patch, false), chest);
// An empty chest has nothing to carry.
assert!(pick(&empty).is_empty());
}
#[test]
fn a_command_block_keeps_its_command_in_block_entity_data() {
let block = saved(
"{id:\"minecraft:command_block\",x:0,y:0,z:0,Command:\"say hi\",auto:1b,powered:1b}",
);
let patch = pick(&block);
let data = patch
.get(&JavaString::from(BLOCK_ENTITY_DATA))
.expect("data");
assert_eq!(data.get("Command"), Some(&Tag::String("say hi".into())));
assert_eq!(data.get("powered"), None);
assert_eq!(
data.get("id"),
Some(&Tag::String("minecraft:command_block".into()))
);
// Only a player who can use game master blocks places it.
let fresh = saved("{id:\"minecraft:command_block\",x:0,y:0,z:0}");
assert_eq!(place(&fresh, &patch, false), fresh);
assert_eq!(
place(&fresh, &patch, true).get("Command"),
Some(&Tag::String("say hi".into()))
);
// Data of another type is ignored.
let chest = saved("{id:\"minecraft:chest\",x:0,y:0,z:0}");
assert_eq!(place(&chest, &patch, true), chest);
}
#[test]
fn item_data_names_its_spawn_and_op_only_types() {
let item = |text: &str| {
let components = snbt(&format!("{{\"minecraft:block_entity_data\":{text}}}"));
let (damage, extra) = crate::item_data::decode_components(Some(&components));
crate::items::ItemStack {
damage,
extra,
..crate::items::ItemStack::new("minecraft:spawner", 1)
}
};
let spawner =
item("{id:\"minecraft:mob_spawner\",SpawnData:{entity:{id:\"minecraft:zombie\"}}}");
assert_eq!(
spawner_entity(&spawner).as_deref(),
Some("minecraft:zombie")
);
assert!(op_warning(&spawner));
let chest = item("{id:\"minecraft:chest\",LootTable:\"x\"}");
assert_eq!(spawner_entity(&chest), None);
assert!(!op_warning(&chest));
}
#[test]
fn a_written_sign_gives_its_text_and_wax() {
let sign = saved(
"{id:\"minecraft:sign\",x:0,y:0,z:0,is_waxed:1b,\
front_text:{messages:[\"a\",\"b\",\"\",\"\"]}}",
);
let patch = pick(&sign);
let keys: Vec<String> = patch.keys().map(|k| k.to_string_lossy()).collect();
assert_eq!(keys, ["minecraft:sign_text_front", "minecraft:waxed"]);
let fresh = saved("{id:\"minecraft:sign\",x:0,y:0,z:0}");
assert_eq!(place(&fresh, &patch, false), sign);
}
}
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The loaded `SignText` of one side, or the empty text.
fn sign_side(tag: Option<&Tag>) -> Tag {
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
@@ -876,7 +876,7 @@ impl BlockEntityRenderer {
.as_vec3();
let base = Mat4::from_translation(origin);
let packed = scene.world.light(pos[0], pos[1], pos[2]);
self.render_one(scene, *pos, kind, base, packed, lights, out);
self.render_one(scene, *pos, kind, base, packed, lights, false, out);
}
}
/// One block entity at `pos` drawn under `base` with the given packed light
@@ -892,12 +892,33 @@ impl BlockEntityRenderer {
) {
let kind = self.kind(state);
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, out);
self.render_one(scene, pos, kind, base, packed, lights, false, out);
}
/// Whether this block state has a block-entity renderer here.
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
}
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
pub fn render_breaking(&self, scene: &Scene, pos: [i32; 3], state: u32, out: &mut Vec<Vertex>) {
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
return;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let start = out.len();
self.render_one(
scene,
pos,
self.kind(state),
Mat4::from_translation(origin),
0xff,
diffuse_lights(scene.nether),
true,
out,
);
crate::breaking::entity_geometry(&mut out[start..], origin);
}
/// `ChestRenderer.extractRenderState`'s openness for the chest of `state`
/// at `pos`: `ChestBlock.combine` (`DoubleBlockCombiner.combineWithNeigbour`)
/// pairs a double half with its neighbour only when that is the same
@@ -970,7 +991,7 @@ impl BlockEntityRenderer {
let kind = self.kind(state);
if !matches!(kind, Kind::None) {
let lights = diffuse_lights(scene.nether);
self.render_one(scene, [i32::MAX; 3], kind, base, packed, lights, out);
self.render_one(scene, [i32::MAX; 3], kind, base, packed, lights, false, out);
}
}
#[allow(clippy::too_many_arguments)]
@@ -982,11 +1003,24 @@ impl BlockEntityRenderer {
base: Mat4,
packed: u8,
lights: (Vec3, Vec3),
crumbling: bool,
out: &mut Vec<Vertex>,
) {
let sprite = |name: &str| scene.sprites.get(name).copied();
let paint = |name: &str, tint: u32, packed: u8| {
sprite(name).map(|s| Paint {
let rect = if crumbling {
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
})
} else {
sprite(name)
};
rect.map(|s| Paint {
sprite: s,
tint,
light: vertex_light(packed),
@@ -1058,7 +1092,7 @@ impl BlockEntityRenderer {
ChestPart::Left => "double_chest_left",
ChestPart::Right => "double_chest_right",
};
let (Some(model), Some(_)) = (self.models.get(model), sprite(texture)) else {
let Some(model) = self.models.get(model) else {
return;
};
let (open, other) = self.chest_openness(
@@ -1145,6 +1179,10 @@ impl BlockEntityRenderer {
draw_part(main, "root", m, &none, paint, out);
draw_part(flag, "root", m, &wave, paint, out);
}
// Pattern passes must not multiply the crumbling decal.
if crumbling {
return;
}
// BannerRenderer.submitPatterns: the base pattern layer in the dye colour,
// then up to 16 pattern layers in order.
if let Some(paint) = paint("entity/banner/base", *color, packed) {
@@ -1673,6 +1711,138 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn entity_block_particles_and_breaking_use_exported_models() -> crate::Result<()> {
use crate::{models::catalog::StateModels, sprites::Sprites};
use horizon_world::{DATA_VERSION, chunk::BlockState, nbt::Tag};
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let names = [
"white_banner",
"white_wall_banner",
"chest",
"trapped_chest",
"ender_chest",
"copper_chest",
];
let mut facts: std::collections::BTreeMap<String, Value> =
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
let states = names.map(|name| {
let name = format!("minecraft:{name}");
let mut first = facts[&name]["states"][0].clone();
if first.get("type").is_some() {
first["type"] = serde_json::json!("single");
}
facts.get_mut(&name).unwrap()["states"] = serde_json::json!([first.clone()]);
BlockState {
name,
properties: serde_json::from_value(first).unwrap(),
}
.to_nbt()
});
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("states", Tag::List(states.into())),
(
"defaults",
Tag::compound(names.iter().enumerate().map(|(id, name)| {
(
horizon_world::nbt::JavaString::from(format!("minecraft:{name}").as_str()),
Tag::Int(id as i32),
)
})),
),
]))?;
let mut sprites = Sprites::default();
let catalog = StateModels::from_facts(&root, &registry, &names, &mut sprites, &facts)?;
for (id, name) in names.iter().enumerate() {
let models = catalog.collect(
BlockStateId(id as u32),
horizon_core::position::BlockPos::default(),
)?;
assert!(models.iter().all(|(_, model)| model.faces.is_empty()));
let particle = match *name {
"ender_chest" => "minecraft:block/obsidian",
"copper_chest" => "minecraft:block/copper_block",
_ => "minecraft:block/oak_planks",
};
assert_eq!(models[0].1.particle, Some(sprites.layers[particle]));
}
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
let renderer = BlockEntityRenderer::new(
MODELS
.iter()
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
.collect(),
&registry,
);
let world = World::new(BlockStateId(0), 1);
let base_sprites = [
"entity/banner/banner_base",
"entity/chest/normal",
"entity/chest/trapped",
"entity/chest/ender",
"entity/chest/copper",
]
.into_iter()
.map(|name| {
(
name.to_owned(),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
)
})
.collect();
let mut scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.5,
game_time: 0,
max_y: 320,
world: &world,
sprites: &base_sprites,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
glyph: &|_| (None, 0.0),
};
for state in 0..names.len() as u32 {
let mut base = Vec::new();
renderer.render_block(&scene, [0; 3], state, Mat4::IDENTITY, 0xff, &mut base);
let mut cracks = Vec::new();
renderer.render_breaking(&scene, [0; 3], state, &mut cracks);
assert!(!cracks.is_empty());
assert_eq!(
base.iter().map(|v| v.pos).collect::<Vec<_>>(),
cracks.iter().map(|v| v.pos).collect::<Vec<_>>()
);
assert!(cracks.iter().all(|v| v.layer == 11
&& v.color == u32::MAX
&& v.uv.iter().all(|u| u.is_finite())));
scene.game_time = 20;
if state >= 2 {
scene.lid = &|_| 1.0;
}
let mut animated = Vec::new();
renderer.render_breaking(&scene, [0; 3], state, &mut animated);
assert!(cracks.iter().zip(&animated).any(|(a, b)| a.pos != b.pos));
scene.game_time = 0;
scene.lid = &|_| 0.0;
}
Ok(())
}
#[test]
fn classifies_vanilla_block_names() {
let props = |pairs: &'static [(&'static str, &'static str)]| {
+278 -70
View File
@@ -1,7 +1,11 @@
//! Per-state gameplay facts exported from vanilla's own block-state accessors
//! (`gameplay-facts.json`), indexed by registry state ID.
mod decode;
use crate::Result;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use horizon_world::{
level::{Behaviours, GameplayStateSource},
registry::{BlockRegistry, BlockStateId},
};
use serde_json::Value;
use std::{
collections::HashMap,
@@ -106,23 +110,190 @@ impl StateFacts {
self.classes.iter().any(|c| &**c == name)
}
}
/// Compact, owned behaviour inputs retained until the normal behaviour startup stage.
/// No borrowed gameplay JSON or process-wide cache survives the fact loader.
pub struct GameplayProjection {
flags: u16,
emission: u64,
dampening: u64,
hardness: f64,
push: Arc<str>,
volume: f64,
pitch: f64,
place: Arc<str>,
break_sound: Arc<str>,
fall: Arc<str>,
}
impl GameplayProjection {
fn from_entry(entry: &decode::Entry<'_>, strings: &mut HashMap<String, Arc<str>>) -> Self {
fn intern(strings: &mut HashMap<String, Arc<str>>, value: &str) -> Arc<str> {
if let Some(existing) = strings.get(value) {
return Arc::clone(existing);
}
let value_arc: Arc<str> = Arc::from(value);
strings.insert(value.to_owned(), Arc::clone(&value_arc));
value_arc
}
const FLAGS: [&str; 11] = [
"replaceable",
"random_ticks",
"solid_render",
"liquid",
"block_entity",
"ignited_by_lava",
"can_occlude",
"full_collision",
"skylight_down",
"requires_tool",
"shape_occlusion",
];
Self {
flags: FLAGS.iter().enumerate().fold(0, |flags, (i, name)| {
flags | ((u16::from(entry.flag(name))) << i)
}),
emission: entry.unsigned("emission"),
dampening: entry.unsigned("dampening"),
hardness: entry.number("hardness"),
push: intern(strings, entry.text("push")),
volume: entry.sound_number("volume"),
pitch: entry.sound_number("pitch"),
place: intern(strings, entry.sound_id("place")),
break_sound: intern(strings, entry.sound_id("break")),
fall: intern(strings, entry.sound_id("fall")),
}
}
}
impl GameplayStateSource for GameplayProjection {
fn flag(&self, name: &str) -> bool {
const FLAGS: [&str; 11] = [
"replaceable",
"random_ticks",
"solid_render",
"liquid",
"block_entity",
"ignited_by_lava",
"can_occlude",
"full_collision",
"skylight_down",
"requires_tool",
"shape_occlusion",
];
FLAGS
.iter()
.position(|field| *field == name)
.is_some_and(|index| self.flags & (1 << index) != 0)
}
fn unsigned(&self, name: &str) -> u64 {
match name {
"emission" => self.emission,
"dampening" => self.dampening,
_ => 0,
}
}
fn number(&self, name: &str) -> f64 {
if name == "hardness" {
self.hardness
} else {
0.0
}
}
fn text(&self, name: &str) -> &str {
if name == "push" { &self.push } else { "" }
}
fn sound_number(&self, name: &str) -> f64 {
match name {
"volume" => self.volume,
"pitch" => self.pitch,
_ => 1.0,
}
}
fn sound_id(&self, name: &str) -> &str {
match name {
"place" => &self.place,
"break" => &self.break_sound,
"fall" => &self.fall,
_ => "",
}
}
}
pub struct BlockFacts {
pub states: Vec<StateFacts>,
pub air: BlockStateId,
}
impl BlockFacts {
pub fn load(root: &Path, registry: &BlockRegistry) -> Result<Self> {
let raw: Value = serde_json::from_slice(&fs::read(root.join("gameplay-facts.json"))?)?;
let entries = raw["states"]
.as_array()
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("block-behaviours.json"),
)?))?;
Self::load_with_behaviour_json(root, registry, &behaviours)
}
/// Load both tables from one resource root, sharing each JSON decode.
/// No data is cached across calls: changes to prepared packs or mods are read again.
pub fn load_pair(root: &Path, registry: &BlockRegistry) -> Result<(Self, Behaviours)> {
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("block-behaviours.json"),
)?))?;
Self::load_with_behaviours(root, registry, &behaviours)
}
/// Decode state facts once, retaining only the small owned fields needed later.
pub fn load_with_projection(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<(Self, Vec<GameplayProjection>)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
let mut strings = HashMap::new();
Ok(entries
.iter()
.map(|entry| GameplayProjection::from_entry(entry, &mut strings))
.collect())
})
}
pub fn load_with_behaviour_json(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<Self> {
Ok(Self::load_with_callback(root, registry, behaviours, |_| Ok(()))?.0)
}
/// Build both tables from the same behaviour document and gameplay decoder.
pub fn load_with_behaviours(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<(Self, Behaviours)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
Ok(Behaviours::load_with_gameplay(
registry, root, behaviours, entries,
)?)
})
}
fn load_with_callback<T>(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
) -> Result<(Self, T)> {
let bytes = fs::read(root.join("gameplay-facts.json"))?;
serde_json::from_slice::<decode::Validate>(&bytes)?;
let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
let entries = raw
.states
.0
.as_ref()
.ok_or("missing gameplay state facts")?;
if entries.len() != registry.len() {
return Err("gameplay facts do not match the block registry".into());
}
// Class chains and per-state behaviour flags exported from the actual block objects.
let behaviours: Value = serde_json::from_reader(BufReader::new(File::open(
root.join("block-behaviours.json"),
)?))?;
let behaviour_states = behaviours["states"]
.as_array()
.filter(|s| s.len() == registry.len())
@@ -148,40 +319,22 @@ impl BlockFacts {
.collect();
let mut names: HashMap<String, Arc<str>> = HashMap::new();
let mut intern = |s: &str| -> Arc<str> {
names
.entry(s.to_owned())
.or_insert_with(|| Arc::from(s))
.clone()
};
let mut shapes: HashMap<String, Arc<[[f64; 6]]>> = HashMap::new();
let mut shape = |v: &Value, fallback: &dyn Fn() -> Vec<[f64; 6]>| -> Arc<[[f64; 6]]> {
let Some(list) = v.as_array() else {
return fallback().into();
};
shapes
.entry(v.to_string())
.or_insert_with(|| {
list.iter()
.filter_map(|b| {
let b = b.as_array()?;
let n = |i: usize| b.get(i).and_then(Value::as_f64).unwrap_or(0.0);
Some([n(0), n(1), n(2), n(3), n(4), n(5)])
})
.collect()
})
.clone()
if let Some(name) = names.get(s) {
return name.clone();
}
let name: Arc<str> = Arc::from(s);
names.insert(s.to_owned(), name.clone());
name
};
let mut shapes = decode::Shapes::default();
let mut states = Vec::with_capacity(entries.len());
for (index, entry) in entries.iter().enumerate() {
let id = BlockStateId(index as u32);
if entry["id"].as_u64() != Some(index as u64) {
if entry.id != Some(index as u64) {
return Err("gameplay facts are not in registry order".into());
}
let state = registry.state(id).ok_or("missing registry state")?;
let num = |k: &str| entry[k].as_f64().unwrap_or(0.0);
let flag = |k: &str| entry[k].as_bool().unwrap_or(false);
let sound = &entry["sound"];
let sound_id = |k: &str| sound[k]["id"].as_str().unwrap_or("").to_owned();
let sound = &entry.sound;
let prop = |k: &str| state.properties.get(k).map(String::as_str);
let (fluid, fluid_level, fluid_falling) = match state.name.as_str() {
"minecraft:water" | "minecraft:lava" => {
@@ -220,10 +373,10 @@ impl BlockFacts {
}
};
let edges = move || if air { Vec::new() } else { cube_edges() };
let outline = shape(&entry["outline"], &cube);
let outline_edges = shape(&entry["outline_edges"], &edges);
let outline = shapes.intern(entry.outline, cube)?;
let outline_edges = shapes.intern(entry.outline_edges, edges)?;
// The export holds MapColor.col; ids are positions in MAP_COLORS.
let col = entry["map_color"].as_u64().unwrap_or(0) as u32;
let col = entry.map_color.unwrap_or(0) as u32;
let map_color = if col == 0 {
0
} else {
@@ -234,43 +387,41 @@ impl BlockFacts {
outline,
outline_edges,
name: intern(&state.name),
emission: num("emission") as u8,
dampening: num("dampening") as u8,
skylight_down: flag("skylight_down"),
shape_occlusion: flag("shape_occlusion"),
can_occlude: flag("can_occlude"),
solid_render: flag("solid_render"),
hardness: num("hardness") as f32,
requires_tool: flag("requires_tool"),
replaceable: flag("replaceable"),
random_ticks: flag("random_ticks"),
block_entity: flag("block_entity"),
render_shape: match entry["render_shape"].as_str() {
emission: entry.emission.as_f64().unwrap_or(0.0) as u8,
dampening: entry.dampening.as_f64().unwrap_or(0.0) as u8,
skylight_down: entry.skylight_down,
shape_occlusion: entry.shape_occlusion,
can_occlude: entry.can_occlude,
solid_render: entry.solid_render,
hardness: entry.hardness.as_f64().unwrap_or(0.0) as f32,
requires_tool: entry.requires_tool,
replaceable: entry.replaceable,
random_ticks: entry.random_ticks,
block_entity: entry.block_entity,
render_shape: match entry.render_shape.as_deref() {
Some("INVISIBLE") => RenderShape::Invisible,
Some("ENTITYBLOCK_ANIMATED") => RenderShape::Animated,
_ => RenderShape::Model,
},
full_collision: flag("full_collision"),
friction: num("friction") as f32,
explosion_resistance: num("explosion_resistance") as f32,
item: intern(entry["item"].as_str().unwrap_or("minecraft:air")),
full_collision: entry.full_collision,
friction: entry.friction as f32,
explosion_resistance: entry.explosion_resistance as f32,
item: intern(entry.item.as_deref().unwrap_or("minecraft:air")),
sounds: Sounds {
volume: sound["volume"].as_f64().unwrap_or(1.0) as f32,
pitch: sound["pitch"].as_f64().unwrap_or(1.0) as f32,
break_sound: intern(&sound_id("break")),
step: intern(&sound_id("step")),
place: intern(&sound_id("place")),
hit: intern(&sound_id("hit")),
fall: intern(&sound_id("fall")),
volume: sound.volume as f32,
pitch: sound.pitch as f32,
break_sound: intern(sound.r#break.id.as_deref().unwrap_or("")),
step: intern(sound.step.id.as_deref().unwrap_or("")),
place: intern(sound.place.id.as_deref().unwrap_or("")),
hit: intern(sound.hit.id.as_deref().unwrap_or("")),
fall: intern(sound.fall.id.as_deref().unwrap_or("")),
},
fluid,
fluid_level,
fluid_falling,
waterlogged: prop("waterlogged") == Some("true"),
random_offset: flag("offset"),
outline_offset: entry["outline_offset"]
.as_bool()
.unwrap_or_else(|| flag("offset")),
random_offset: entry.offset,
outline_offset: entry.outline_offset.unwrap_or(entry.offset),
legacy_solid: behaviour_states[index][1].as_u64().unwrap_or(0) & 1 != 0,
sturdy_full: (behaviour_states[index][0].as_u64().unwrap_or(0) & 63) as u8,
classes: classes
@@ -279,12 +430,16 @@ impl BlockFacts {
.ok_or("missing block behaviour class chain")?,
});
}
Ok(Self {
states,
air: registry
.default_state("minecraft:air")
.ok_or("missing air")?,
})
let extra = callback(entries)?;
Ok((
Self {
states,
air: registry
.default_state("minecraft:air")
.ok_or("missing air")?,
},
extra,
))
}
pub fn get(&self, id: BlockStateId) -> &StateFacts {
&self.states[id.0 as usize]
@@ -296,3 +451,56 @@ impl BlockFacts {
)
}
}
impl GameplayStateSource for decode::Entry<'_> {
fn flag(&self, name: &str) -> bool {
match name {
"replaceable" => self.replaceable,
"random_ticks" => self.random_ticks,
"solid_render" => self.solid_render,
"liquid" => self.liquid,
"block_entity" => self.block_entity,
"ignited_by_lava" => self.ignited_by_lava,
"can_occlude" => self.can_occlude,
"full_collision" => self.full_collision,
"skylight_down" => self.skylight_down,
"requires_tool" => self.requires_tool,
"shape_occlusion" => self.shape_occlusion,
_ => false,
}
}
fn unsigned(&self, name: &str) -> u64 {
match name {
"emission" => self.emission.as_u64().unwrap_or(0),
"dampening" => self.dampening.as_u64().unwrap_or(0),
_ => 0,
}
}
fn number(&self, name: &str) -> f64 {
match name {
"hardness" => self.hardness.as_f64().unwrap_or(0.0),
_ => 0.0,
}
}
fn text(&self, name: &str) -> &str {
match name {
"push" => self.push.as_deref().unwrap_or(""),
_ => "",
}
}
fn sound_number(&self, name: &str) -> f64 {
match name {
"volume" => self.sound.volume,
"pitch" => self.sound.pitch,
_ => 1.0,
}
}
fn sound_id(&self, name: &str) -> &str {
match name {
"place" => self.sound.place.id.as_deref().unwrap_or(""),
"break" => self.sound.r#break.id.as_deref().unwrap_or(""),
"fall" => self.sound.fall.id.as_deref().unwrap_or(""),
_ => "",
}
}
}
+293
View File
@@ -0,0 +1,293 @@
//! Typed projection of gameplay facts. Scalar conversions deliberately retain Value's
//! permissive defaults; maps retain its last-key-wins behavior. Repeated geometry stays
//! borrowed until it is interned, rather than allocating a JSON tree for every state.
use serde::{
Deserialize, Deserializer,
de::{MapAccess, SeqAccess, Visitor},
};
use serde_json::{Value, value::RawValue};
use std::{
collections::{HashMap, hash_map::Entry as MapEntry},
fmt,
hash::{Hash, Hasher},
marker::PhantomData,
sync::Arc,
};
// RawValue skips numeric range and depth checks. A non-allocating validation pass
// keeps the original Value loader's JSON errors, even in overwritten/unused fields.
#[derive(Default)]
pub(super) struct Validate;
impl<'de> Container<'de> for Validate {}
impl<'de> Deserialize<'de> for Validate {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
container(d)
}
}
trait Container<'de>: Default {
fn map<A: MapAccess<'de>>(mut map: A) -> Result<Self, A::Error> {
while map.next_key::<serde::de::IgnoredAny>()?.is_some() {
map.next_value::<Validate>()?;
}
Ok(Self::default())
}
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
while seq.next_element::<Validate>()?.is_some() {}
Ok(Self::default())
}
}
struct ContainerVisitor<T>(PhantomData<T>);
impl<'de, T: Container<'de>> Visitor<'de> for ContainerVisitor<T> {
type Value = T;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("JSON facts")
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<T, A::Error> {
T::map(map)
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<T, A::Error> {
T::seq(seq)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<T, E> {
Ok(T::default())
}
fn visit_bool<E: serde::de::Error>(self, _: bool) -> Result<T, E> {
Ok(T::default())
}
fn visit_i64<E: serde::de::Error>(self, _: i64) -> Result<T, E> {
Ok(T::default())
}
fn visit_u64<E: serde::de::Error>(self, _: u64) -> Result<T, E> {
Ok(T::default())
}
fn visit_f64<E: serde::de::Error>(self, _: f64) -> Result<T, E> {
Ok(T::default())
}
fn visit_str<E: serde::de::Error>(self, _: &str) -> Result<T, E> {
Ok(T::default())
}
}
fn container<'de, D: Deserializer<'de>, T: Container<'de>>(d: D) -> Result<T, D::Error> {
d.deserialize_any(ContainerVisitor(PhantomData))
}
macro_rules! read_field {
($map:ident, typed) => {
$map.next_value()?
};
($map:ident, value($convert:expr)) => {
($convert)($map.next_value::<Value>()?)
};
}
macro_rules! object {
($name:ident<$life:lifetime> { $($field:ident: $ty:ty = $default:expr => $kind:ident $(($convert:expr))?),* $(,)? }) => {
pub(super) struct $name<$life> {
$(pub $field: $ty,)*
marker: PhantomData<&$life ()>,
}
impl Default for $name<'_> {
fn default() -> Self { Self { $($field: $default,)* marker: PhantomData } }
}
impl<$life> Container<$life> for $name<$life> {
fn map<A: MapAccess<$life>>(mut map: A) -> Result<Self, A::Error> {
#[derive(Deserialize)]
#[allow(non_camel_case_types)]
#[serde(field_identifier)]
enum Field { $($field,)* #[serde(other)] Unknown }
let mut result = Self::default();
while let Some(field) = map.next_key::<Field>()? {
match field {
$(Field::$field => result.$field = read_field!(map, $kind $(($convert))?),)*
Field::Unknown => { map.next_value::<Validate>()?; }
}
}
Ok(result)
}
}
impl<$life> Deserialize<$life> for $name<$life> {
fn deserialize<D: Deserializer<$life>>(d: D) -> Result<Self, D::Error> { container(d) }
}
};
}
fn number(v: Value) -> f64 {
v.as_f64().unwrap_or(0.0)
}
fn flag(v: Value) -> bool {
v.as_bool().unwrap_or(false)
}
fn unsigned(v: Value) -> Option<u64> {
v.as_u64()
}
fn text(v: Value) -> Option<String> {
if let Value::String(s) = v {
Some(s)
} else {
None
}
}
#[derive(Default)]
pub(super) struct Entries<'a>(pub Option<Vec<Entry<'a>>>);
impl<'de> Container<'de> for Entries<'de> {
fn seq<A: SeqAccess<'de>>(mut seq: A) -> Result<Self, A::Error> {
let mut entries = Vec::new();
while let Some(entry) = seq.next_element()? {
entries.push(entry);
}
Ok(Self(Some(entries)))
}
}
impl<'de> Deserialize<'de> for Entries<'de> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
container(d)
}
}
object!(Gameplay<'a> { states: Entries<'a> = Entries::default() => typed });
object!(SoundId<'a> { id: Option<String> = None => value(text) });
object!(Sound<'a> {
volume: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
pitch: f64 = 1.0 => value(|v: Value| v.as_f64().unwrap_or(1.0)),
r#break: SoundId<'a> = SoundId::default() => typed,
step: SoundId<'a> = SoundId::default() => typed,
place: SoundId<'a> = SoundId::default() => typed,
hit: SoundId<'a> = SoundId::default() => typed,
fall: SoundId<'a> = SoundId::default() => typed,
});
object!(Entry<'a> {
id: Option<u64> = None => value(unsigned),
emission: Value = Value::Null => typed,
dampening: Value = Value::Null => typed,
skylight_down: bool = false => value(flag),
shape_occlusion: bool = false => value(flag),
can_occlude: bool = false => value(flag),
solid_render: bool = false => value(flag),
hardness: Value = Value::Null => typed,
requires_tool: bool = false => value(flag),
replaceable: bool = false => value(flag),
random_ticks: bool = false => value(flag),
block_entity: bool = false => value(flag),
liquid: bool = false => value(flag),
ignited_by_lava: bool = false => value(flag),
push: Option<String> = None => value(text),
render_shape: Option<String> = None => value(text),
full_collision: bool = false => value(flag),
friction: f64 = 0.0 => value(number),
explosion_resistance: f64 = 0.0 => value(number),
item: Option<String> = None => value(text),
sound: Sound<'a> = Sound::default() => typed,
offset: bool = false => value(flag),
outline_offset: Option<bool> = None => value(|v: Value| v.as_bool()),
map_color: Option<u64> = None => value(unsigned),
outline: Option<&'a RawValue> = None => typed,
outline_edges: Option<&'a RawValue> = None => typed,
});
// Match Value::to_string identity without serializing: integer/float spelling is
// distinct, as is the sign of floating zero. Object keys are already sorted by Value.
struct ShapeKey(Value);
fn same(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(a), Value::Number(b)) => {
a == b && a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| same(a, b))
}
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|((ak, av), (bk, bv))| ak == bk && same(av, bv))
}
_ => a == b,
}
}
impl PartialEq for ShapeKey {
fn eq(&self, other: &Self) -> bool {
same(&self.0, &other.0)
}
}
impl Eq for ShapeKey {}
fn shape_hash<H: Hasher>(v: &Value, h: &mut H) {
std::mem::discriminant(v).hash(h);
match v {
Value::Null => {}
Value::Bool(b) => b.hash(h),
Value::String(s) => s.hash(h),
Value::Number(n) => {
n.is_f64().hash(h);
n.hash(h);
n.as_f64().map(f64::to_bits).hash(h);
}
Value::Array(a) => {
a.len().hash(h);
for v in a {
shape_hash(v, h);
}
}
Value::Object(o) => {
o.len().hash(h);
for (k, v) in o {
k.hash(h);
shape_hash(v, h);
}
}
}
}
impl Hash for ShapeKey {
fn hash<H: Hasher>(&self, h: &mut H) {
shape_hash(&self.0, h);
}
}
#[derive(Default)]
pub(super) struct Shapes<'a> {
raw: HashMap<&'a str, usize>,
canonical: HashMap<ShapeKey, usize>,
values: Vec<Arc<[[f64; 6]]>>,
}
impl<'a> Shapes<'a> {
pub fn intern(
&mut self,
raw: Option<&'a RawValue>,
fallback: impl FnOnce() -> Vec<[f64; 6]>,
) -> crate::Result<Arc<[[f64; 6]]>> {
let Some(raw) = raw else {
return Ok(fallback().into());
};
if let Some(&id) = self.raw.get(raw.get()) {
return Ok(self.values[id].clone());
}
let value: Value = serde_json::from_str(raw.get())?;
if !value.is_array() {
return Ok(fallback().into());
}
let id = match self.canonical.entry(ShapeKey(value)) {
MapEntry::Occupied(entry) => *entry.get(),
MapEntry::Vacant(entry) => {
let shape = entry
.key()
.0
.as_array()
.unwrap()
.iter()
.filter_map(|b| {
let b = b.as_array()?;
Some(std::array::from_fn(|i| {
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
}))
})
.collect::<Arc<[[f64; 6]]>>();
let id = self.values.len();
self.values.push(shape);
entry.insert(id);
id
}
};
self.raw.insert(raw.get(), id);
Ok(self.values[id].clone())
}
}
#[cfg(test)]
#[path = "decode_tests.rs"]
mod tests;
@@ -0,0 +1,137 @@
use super::*;
#[test]
fn shape_identity_and_geometry_match_the_value_loader() -> crate::Result<()> {
let inputs = [
"[]",
"[ ]",
"[[0,0,0,1,1,1]]",
"[ [0, 0, 0, 1, 1, 1] ]",
"[[0.0,0,0,1,1,1]]",
"[[0e0,0,0,1,1,1]]",
"[[-0.0,0,0,1,1,1]]",
"[[0,0,0,1,1,1,7]]",
"[[0,0,0,1,1,1,8]]",
"[[1,null,\"2\"],true,{}]",
"[[1,null,\"\\u0032\"],true,{}]",
"[{\"z\":0,\"a\":1}]",
"[{\"a\":1,\"z\":0}]",
"[[18446744073709551615,-9223372036854775808,1e20]]",
"[{\"a\":2,\"a\":1,\"z\":0}]",
];
let values = inputs
.iter()
.map(|s| serde_json::from_str::<Value>(s))
.collect::<Result<Vec<_>, _>>()?;
// Value equality merges signed zero; the legacy string keys do not.
let keys: Vec<_> = values.iter().map(Value::to_string).collect();
let mut shapes = Shapes::default();
let actual = inputs
.iter()
.map(|s| {
shapes.intern(Some(serde_json::from_str::<&RawValue>(s)?), || {
panic!("array fallback")
})
})
.collect::<crate::Result<Vec<_>>>()?;
for (i, value) in values.iter().enumerate() {
let expected: Vec<[f64; 6]> = value
.as_array()
.unwrap()
.iter()
.filter_map(|b| {
let b = b.as_array()?;
Some(std::array::from_fn(|i| {
b.get(i).and_then(Value::as_f64).unwrap_or(0.0)
}))
})
.collect();
// Bit comparisons include negative zero.
assert_eq!(
actual[i]
.iter()
.flatten()
.map(|v| v.to_bits())
.collect::<Vec<_>>(),
expected
.iter()
.flatten()
.map(|v| v.to_bits())
.collect::<Vec<_>>()
);
for j in 0..values.len() {
assert_eq!(
Arc::ptr_eq(&actual[i], &actual[j]),
keys[i] == keys[j],
"{i}, {j}"
);
}
}
for input in ["null", "false", "7", "{}", "\"shape\""] {
let raw = serde_json::from_str(input)?;
let a = shapes.intern(Some(raw), || vec![[7.0; 6]])?;
let b = shapes.intern(Some(raw), || vec![[7.0; 6]])?;
assert_eq!(&*a, &[[7.0; 6]]);
assert!(!Arc::ptr_eq(&a, &b)); // Fallbacks were never interned.
}
Ok(())
}
#[test]
fn typed_fields_keep_defaults_duplicates_and_wrong_type_behavior() -> crate::Result<()> {
let input = r#"{"states":[null,[],{"id":0,"id":2,"emission":4.5,"dampening":"15",
"hardness":-0.0,"skylight_down":true,"offset":true,"outline_offset":"true",
"item":42,"render_shape":false,"sound":{"volume":null,"pitch":2,"break":{"id":"a\u0062"},
"step":false,"hit":{"id":2}},"outline":[[0,1]],"unknown":{"nested":true}}]}"#;
let parsed: Gameplay<'_> = serde_json::from_str(input)?;
let entries = parsed.states.0.unwrap();
assert_eq!(entries[0].id, None);
assert_eq!(entries[1].id, None);
let e = &entries[2];
assert_eq!(e.id, Some(2));
assert_eq!(e.emission.as_f64(), Some(4.5));
assert_eq!(e.dampening.as_f64().unwrap_or(0.0), 0.0);
assert_eq!(e.hardness.as_f64().unwrap().to_bits(), (-0.0f64).to_bits());
assert!(e.skylight_down && e.outline_offset.unwrap_or(e.offset));
assert_eq!(e.item, None);
assert_eq!(e.render_shape, None);
assert_eq!((e.sound.volume, e.sound.pitch), (1.0, 2.0));
assert_eq!(e.sound.r#break.id.as_deref(), Some("ab"));
assert_eq!(e.sound.step.id, None);
assert_eq!(e.sound.hit.id, None);
assert_eq!(e.outline.unwrap().get(), "[[0,1]]");
for input in ["null", "[]", "{}", r#"{"states":null}"#, r#"{"states":{}}"#] {
let parsed: Gameplay<'_> = serde_json::from_str(input)?;
assert!(parsed.states.0.is_none());
}
for input in [
r#"{"states":["#,
r#"{"states":[{"id":1e400}]}"#,
"{} trailing",
r#"{"unknown":1e400}"#,
] {
let before = serde_json::from_str::<Value>(input).err().unwrap();
let after = serde_json::from_str::<Gameplay<'_>>(input).err().unwrap();
assert_eq!(before.classify(), after.classify());
}
Ok(())
}
#[test]
fn raw_shape_capture_keeps_original_json_errors() {
let deep = format!("{}0{}", "[".repeat(130), "]".repeat(130));
let inputs = [
br#"{"states":[{"outline":[[1e400]],"outline":[]}]}"#.to_vec(),
format!("{{\"states\":[{{\"outline\":{deep}}}]}}").into_bytes(),
br#"{"states":[{"outline":[[0]]}]} trailing"#.to_vec(),
b"{\"states\":[{\"outline\":[[\"\xff\"]]}]}".to_vec(),
];
for input in inputs {
let old = serde_json::from_slice::<Value>(&input).err().unwrap();
let new = serde_json::from_slice::<Validate>(&input).err().unwrap();
assert_eq!(
(old.classify(), old.line(), old.column()),
(new.classify(), new.line(), new.column())
);
}
}
+48
View File
@@ -41,6 +41,35 @@ pub fn geometry(model: &BakedModel, origin: glam::Vec3, translucent: bool, out:
}
}
/// Project a block entity's transformed quads onto the same destroy-stage pass.
pub fn entity_geometry(vertices: &mut [Vertex], origin: glam::Vec3) {
for quad in vertices.as_chunks_mut::<4>().0 {
let positions = [0, 1, 2].map(|i| glam::Vec3::from(quad[i].pos));
let normal = (positions[1] - positions[0]).cross(positions[2] - positions[0]);
let mut facing = Direction::North;
let mut best = f32::from_bits(1);
for (direction, dot) in [
(Direction::Down, -normal.y),
(Direction::Up, normal.y),
(Direction::North, -normal.z),
(Direction::South, normal.z),
(Direction::West, -normal.x),
(Direction::East, normal.x),
] {
if dot > best {
facing = direction;
best = dot;
}
}
for vertex in quad {
vertex.uv = decal_uv((glam::Vec3::from(vertex.pos) - origin).to_array(), facing);
vertex.layer = 11;
vertex.color = 0xffff_ffff;
vertex.light = 240 | (240 << 8);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -49,6 +78,7 @@ mod tests {
fn slab_top() -> BakedModel {
BakedModel {
ambient_occlusion: true,
particle: None,
cube: None,
faces: vec![BakedFace {
layer: 0,
@@ -86,6 +116,24 @@ mod tests {
assert_eq!(v.color, 0xffff_ffff);
}
}
#[test]
fn entity_overlay_uses_block_local_projected_uvs() {
let origin = glam::Vec3::new(12.0, -3.0, 8.0);
let mut expected = Vec::new();
geometry(&slab_top(), origin, false, &mut expected);
let mut actual = expected.clone();
for vertex in &mut actual {
vertex.uv = [0.0; 2];
vertex.layer = 0;
vertex.color = 0;
}
entity_geometry(&mut actual, origin);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&actual),
bytemuck::cast_slice::<_, u8>(&expected)
);
}
#[test]
fn small_faces_use_projected_not_stretched_uvs() {
let a = decal_uv([0.0, 0.0, 0.0], Direction::North);
+6 -1
View File
@@ -99,6 +99,8 @@ pub struct Chat {
pub options: ChatOptions,
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
pub log: bool,
/// Messages added since the start, for the control socket's events.
pub received: u64,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -130,6 +132,7 @@ impl Chat {
self.add_lines(atlas, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
let width = self.max_width();
@@ -515,7 +518,9 @@ impl ChatScreen {
}
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
let parse = self.parse.as_ref().expect("parsed above");
let (range, list) = c.dispatcher.suggestions(parse, cursor_b, c.registries);
let (range, list) = c
.dispatcher
.client_suggestions(parse, cursor_b, c.registries);
self.pending = Some((range.0, list));
self.update_usage_info(m, c);
}
+67
View File
@@ -163,3 +163,70 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
);
}
}
#[test]
fn history_arrows_recall_messages_and_restore_the_draft() {
use winit::keyboard::KeyCode::{ArrowDown, ArrowUp};
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let commands = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let history = vec!["first message".to_owned(), "latest message".to_owned()];
let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
for (key, expected) in [
(ArrowUp, "latest message"),
(ArrowUp, "first message"),
(ArrowUp, "first message"),
(ArrowDown, "latest message"),
(ArrowDown, "unfinished draft"),
(ArrowDown, "unfinished draft"),
] {
assert_eq!(
screen.key(&m, &commands, &history, key, false, false),
ChatAction::None
);
assert_eq!(screen.value(), expected);
}
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
let m = Measure(&font);
// Allow Commands off: a mod's command needs no level.
let registries = Registries::default();
let vanilla = crate::command::vanilla::game_dispatcher();
let modded = vanilla.with_mod_commands(["where", "coords"]);
let red = |screen: &ChatScreen| {
screen
.format(screen.value(), 0)
.spans
.iter()
.any(|s| s.style.color == Some(text::RED))
};
let typed = |dispatcher: &crate::command::Dispatcher, line: &str| {
let c = Commands {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
screen.type_text(&m, &c, line);
screen
};
// Unknown without the mod's tree (#456).
assert!(red(&typed(vanilla, "/coords")));
for line in ["/coords", "/where", "/where me and you"] {
assert!(!red(&typed(&modded, line)), "{line}");
}
let c = Commands {
dispatcher: &modded,
registries: &registries,
};
let mut screen = typed(&modded, "/coo");
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
assert_eq!(screen.value(), "/coords");
}
+50
View File
@@ -158,6 +158,19 @@ impl Chunk {
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
BlockStateId(u32::from(self.get(x, y, z)))
}
/// The highest y where `matches`, scanning down. Sections holding only
/// `skip` are passed over whole: `matches` must be false for it.
pub fn highest(&self, skip: u16, mut matches: impl FnMut(i32) -> bool) -> Option<i32> {
self.sections
.iter()
.enumerate()
.rev()
.filter(|(_, section)| !section.is_uniform(skip))
.find_map(|(s, _)| {
let base = MIN_Y + s as i32 * 16;
(base..base + 16).rev().find(|&y| matches(y))
})
}
pub fn set(&mut self, x: i32, y: i32, z: i32, v: u16) -> u16 {
let s = (y - MIN_Y) >> 4;
if !(0..SECTIONS as i32).contains(&s) {
@@ -198,3 +211,40 @@ impl Chunk {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highest_skips_only_sections_that_cannot_match() {
let (air, cave_air, stone) = (0, 7, 1);
let mut chunk = Chunk::empty(0, 0);
// Stone low down, cave air (uniform in its section) above it, and a
// section of air with one stone block higher up.
for y in MIN_Y..MIN_Y + 40 {
chunk.set(3, y, 4, stone);
}
for i in 0..4096 {
Arc::make_mut(&mut chunk.sections[5]).set(i, cave_air);
}
chunk.set(3, 150, 4, stone);
let full = |c: &Chunk, matches: &dyn Fn(u16) -> bool| {
(MIN_Y..MIN_Y + HEIGHT)
.rev()
.find(|&y| matches(c.get(3, y, 4)))
};
for matches in [
&(|v: u16| v != air) as &dyn Fn(u16) -> bool,
&|v: u16| v == stone,
&|v: u16| v == cave_air,
] {
let fast = chunk.highest(air, |y| matches(chunk.get(3, y, 4)));
assert_eq!(fast, full(&chunk, matches));
}
assert_eq!(
chunk.highest(air, |y| chunk.get(3, y, 4) == stone),
Some(150)
);
assert_eq!(Chunk::empty(0, 0).highest(air, |_| unreachable!()), None);
}
}
+5 -1
View File
@@ -223,6 +223,10 @@ impl AppendSelection {
pub fn covers(&self, id: FieldId, value: &str) -> bool {
self.0.as_ref().is_some_and(|(i, v)| *i == id && v == value)
}
/// Select the whole `value` of field `id`, as Select All does.
pub fn select(&mut self, id: FieldId, value: &str) {
self.0 = Some((id, value.to_owned()));
}
/// Before typing or a backspace: an all-selected value is cleared (true).
pub fn take(&mut self, id: FieldId, value: &mut String) -> bool {
let covered = self.covers(id, value);
@@ -247,7 +251,7 @@ impl AppendSelection {
let covered = self.covers(id, value);
match shortcut {
EditShortcut::SelectAll => {
self.0 = Some((id, value.clone()));
self.select(id, value);
false
}
EditShortcut::Copy => {
+43 -1
View File
@@ -298,6 +298,9 @@ pub struct FixedStep {
tick_times: [i64; SAMPLES],
aggregated: i64,
tick_count: i64,
/// A server's client: its level's tick rate only slows the timer down
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
client: bool,
}
impl Default for FixedStep {
@@ -312,11 +315,20 @@ impl Default for FixedStep {
tick_times: [0; SAMPLES],
aggregated: 0,
tick_count: 0,
client: false,
}
}
}
impl FixedStep {
/// The clock of a client connected to a server: the `ClientLevel`'s
/// `TickRateManager`, set by the server's ticking packets.
pub fn client() -> Self {
Self {
client: true,
..Self::default()
}
}
fn now_ms(&self) -> i64 {
self.now.as_millis() as i64
}
@@ -341,9 +353,14 @@ impl FixedStep {
}
let runs = self.manager.base.runs_normally();
let mspt = self.manager.base.milliseconds_per_tick();
let client = self.client;
let ticks = self
.timer
.advance_game_time(now_ms, |default| if runs { mspt } else { default });
.advance_game_time(now_ms, |default| match (runs, client) {
(true, true) => default.max(mspt),
(true, false) => mspt,
(false, _) => default,
});
ticks.clamp(0, 10) as u32
}
/// Restart pacing from now (after loading, so no backlog replays).
@@ -393,9 +410,17 @@ impl FixedStep {
pub fn frozen(&self) -> bool {
self.manager.base.is_frozen()
}
/// `runsNormally` as of the last `world_runs`.
pub fn runs_normally(&self) -> bool {
self.manager.base.runs_normally()
}
pub fn set_frozen(&mut self, frozen: bool) {
self.manager.base.set_frozen(frozen);
}
/// `TickRateManager.setFrozenTicksToRun` (ClientboundTickingStepPacket).
pub fn set_frozen_ticks_to_run(&mut self, ticks: i32) {
self.manager.base.set_frozen_ticks_to_run(ticks);
}
pub fn sprinting(&self) -> bool {
self.manager.is_sprinting()
}
@@ -480,4 +505,21 @@ mod tests {
m.base.tick();
assert!(!m.base.runs_normally());
}
#[test]
fn a_servers_tick_rate_only_slows_its_client_down() {
let quarter = std::time::Duration::from_millis(250);
let mut client = FixedStep::client();
client.set_rate(40.0);
assert_eq!(client.advance(quarter), 5, "still 20 a second");
let mut slow = FixedStep::client();
slow.set_rate(10.0);
assert_eq!(slow.advance(quarter * 2), 5, "10 a second");
let mut local = FixedStep::default();
local.set_rate(40.0);
assert_eq!(
local.advance(quarter),
10,
"the integrated loop runs at the rate"
);
}
}
+217
View File
@@ -68,9 +68,226 @@ pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
})
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
/// resource-pack reloads replace the renderer and its cache together.
pub struct Input<'a> {
pub texture: &'a (u32, u32, Vec<bool>),
pub uv: [f32; 2],
pub camera: glam::DVec3,
pub time: i64,
pub partial_tick: f64,
pub range: i32,
pub fast: bool,
pub color: [f32; 4],
}
#[derive(Debug, PartialEq, Eq)]
struct MeshKey {
cell_x: i64,
cell_z: i64,
radius: i32,
fast: bool,
// The two vertical face tests include both surfaces at the boundaries.
top: bool,
bottom: bool,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct Uniform {
offset: [f32; 4],
colors: [[f32; 4]; 4],
}
impl Input<'_> {
fn state(&self) -> (MeshKey, Uniform) {
let (w, h, _) = self.texture;
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
let mut x = self.camera.x + offset * 0.030_000_001;
let mut z = self.camera.z + 3.96;
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
x -= (x / tw).floor() * tw;
z -= (z / th).floor() * th;
let cell_x = (x / 12.0).floor() as i64;
let cell_z = (z / 12.0).floor() as i64;
let bottom = (192.33 - self.camera.y) as f32;
let key = MeshKey {
cell_x,
cell_z,
radius: radius_cells(self.range),
fast: self.fast,
top: bottom <= 0.0,
bottom: bottom + 4.0 >= 0.0,
};
let colors = [1.0, 0.7, 0.8, 0.9].map(|shade| {
let [r, g, b, a] = self.color;
[r * shade, g * shade, b * shade, a]
.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8 as f32 / 255.0)
});
(
key,
Uniform {
offset: [
-(x - cell_x as f64 * 12.0) as f32,
bottom,
-(z - cell_z as f64 * 12.0) as f32,
0.0,
],
colors,
},
)
}
}
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
pub vertices: Vec<crate::mesher::Vertex>,
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
pub fn prepare(&mut self, input: &Input<'_>) -> (bool, Uniform) {
let (key, uniform) = input.state();
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
let mut quad = |p: [[f32; 3]; 4], shade: u32| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal: [0; 4],
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0);
}
continue;
}
let quads = [
([[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]], 1),
([[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]], 0),
([[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]], 2),
([[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]], 2),
([[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]], 3),
([[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]], 3),
];
let mask = face_mask(
dx as i32,
dz as i32,
bottom,
[
!filled(cx, cz - 1),
!filled(cx, cz + 1),
!filled(cx - 1, cz),
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade) = quads[usize::from(direction)];
quad(if inside { [p[3], p[2], p[1], p[0]] } else { p }, shade);
}
}
self.key = Some(key);
(true, uniform)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cached_mesh_tracks_geometry_but_not_subcell_motion_or_color() {
let texture = (8, 8, (0..64).map(|i| i % 3 != 0).collect());
let mut input = Input {
texture: &texture,
uv: [0.5; 2],
camera: glam::DVec3::new(1.0, 100.0, 1.0),
time: 0,
partial_tick: 0.0,
range: 2,
fast: false,
color: [1.0; 4],
};
let mut mesh = Mesh::default();
assert!(mesh.prepare(&input).0);
let vertices = bytemuck::cast_slice::<_, u8>(&mesh.vertices).to_vec();
let pointer = mesh.vertices.as_ptr();
for tick in 0..100 {
input.time = tick;
input.partial_tick = 0.5;
input.camera.x = 2.0;
input.camera.y = 101.0;
input.color = [0.8, 0.7, 0.6, 0.5];
let (rebuilt, uniform) = mesh.prepare(&input);
assert!(!rebuilt);
assert_eq!(mesh.vertices.as_ptr(), pointer);
assert_eq!(bytemuck::cast_slice::<_, u8>(&mesh.vertices), vertices);
assert_eq!(uniform.offset[1], (192.33 - input.camera.y) as f32);
assert_eq!(
uniform.colors[1],
[0.8 * 0.7, 0.7 * 0.7, 0.6 * 0.7, 0.5].map(|v| (v * 255.0) as u8 as f32 / 255.0)
);
for color in uniform.colors {
assert_eq!(color[3], 127.0 / 255.0);
}
}
for (x, y, z, range, fast) in [
(13.0, 101.0, 1.0, 2, false),
(13.0, 101.0, -5.0, 2, false),
(13.0, 192.33, -5.0, 2, false),
(13.0, 196.34, -5.0, 2, false),
(13.0, 196.34, -5.0, 3, false),
(13.0, 196.34, -5.0, 3, true),
] {
input.camera = glam::DVec3::new(x, y, z);
input.range = range;
input.fast = fast;
assert!(mesh.prepare(&input).0);
let mut fresh = Mesh::default();
fresh.prepare(&input);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&mesh.vertices),
bytemuck::cast_slice::<_, u8>(&fresh.vertices)
);
assert!(!mesh.prepare(&input).0);
}
// Drift alone crosses a cell, even with a stationary camera.
input.time += 400;
assert!(mesh.prepare(&input).0);
// Resource reload constructs a new renderer and cache, including empty packs.
let mut reloaded = Mesh::default();
let empty = (1, 1, vec![false]);
input.texture = &empty;
assert!(reloaded.prepare(&input).0);
assert!(reloaded.vertices.is_empty());
assert!(!reloaded.prepare(&input).0);
}
#[test]
fn settings_and_packed_alpha_gate_clouds() {
for mode in ["false", "fast", "true"] {
+75 -12
View File
@@ -28,7 +28,7 @@ use std::{fs, io, path::Path, sync::Arc};
pub fn load<'r>(root: &Path, registry: &'r BlockRegistry) -> Result<BlockCollisionRegistry<'r>> {
let data = nbt::read_named(
&fs::read(root.join("cached-collision.nbt"))?,
Limits::default(),
Limits::tables(),
)?
.0
.tag;
@@ -65,6 +65,68 @@ pub struct SceneCollisions<'w, 'r> {
/// The entity colliding, for powder snow; None: no fall, no boots.
pub subject: Option<PowderSnowSubject>,
}
/// Already selected entity boxes alongside the resident block collision world.
pub struct EntityCollisions<'a, W> {
pub blocks: W,
pub entities: &'a [Aabb],
}
impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
type Error = W::Error;
type Chunk = W::Chunk;
fn collision_chunk(
&mut self,
p: ChunkPos,
) -> std::result::Result<Option<Self::Chunk>, Self::Error> {
self.blocks.collision_chunk(p)
}
fn collision_block(
&mut self,
chunk: Self::Chunk,
p: BlockPos,
) -> std::result::Result<CollisionBlock, Self::Error> {
self.blocks.collision_block(chunk, p)
}
fn is_suffocating(
&mut self,
chunk: Self::Chunk,
block: CollisionBlock,
p: BlockPos,
) -> std::result::Result<bool, Self::Error> {
self.blocks.is_suffocating(chunk, block, p)
}
fn context_shape(
&mut self,
block: CollisionBlock,
p: BlockPos,
context: &CollisionContext<'_>,
) -> std::result::Result<VoxelShape, Self::Error> {
self.blocks.context_shape(block, p, context)
}
}
impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCollisions<'_, W> {
type Error = io::Error;
fn entity_collisions(
&mut self,
entity: EntityIdentity,
bounds: Aabb,
) -> io::Result<Vec<VoxelShape>> {
let mut shapes = self.blocks.entity_collisions(entity, bounds)?;
for &aabb in self.entities {
// EntityGetter.getEntityCollisions expands its entity query by 1e-7.
if aabb.intersects(bounds.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))) {
shapes.push(VoxelShape::from_box(aabb).map_err(io::Error::other)?);
}
}
Ok(shapes)
}
fn movement_border(
&mut self,
entity: EntityIdentity,
bounds: Aabb,
) -> io::Result<Option<VoxelShape>> {
self.blocks.movement_border(entity, bounds)
}
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
@@ -147,21 +209,22 @@ where
)
.unwrap_or(false)
}
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
type Error = io::Error;
type Chunk = ChunkPos;
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(self.world.chunks.contains_key(&(p.x, p.z)).then_some(p))
/// The resident column, looked up once per chunk the scan enters.
type Chunk = &'w crate::chunk::Chunk;
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<Self::Chunk>> {
Ok(self.world.chunks.get(&(p.x, p.z)).map(|chunk| &**chunk))
}
fn collision_block(&mut self, chunk: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
let chunk = self
.world
.chunks
.get(&(chunk.x, chunk.z))
.ok_or_else(|| io::Error::other("resident collision chunk disappeared"))?;
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result<CollisionBlock> {
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
}
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
fn is_suffocating(
&mut self,
_: Self::Chunk,
_: CollisionBlock,
_: BlockPos,
) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
+50
View File
@@ -61,6 +61,33 @@ pub fn decode_component(id: &str, t: &Tag) -> Result<(), String> {
_ => Err(not_map(t)),
},
"minecraft:custom_name" | "minecraft:item_name" => super::component::decode(t),
// TypedEntityData.codec: the type is the compound's id.
"minecraft:block_entity_data" => match t {
Tag::Compound(c) => match c.get(&"id".into()) {
None => Err(format!("Expected 'id' field in {}", snbt(t))),
Some(Tag::String(id)) => {
let id = id.to_string_lossy();
let id = if id.contains(':') {
id
} else {
format!("minecraft:{id}")
};
let known = crate::block_entity_nbt::TYPES
.iter()
.chain(&crate::block_entity_nbt::NEWEST)
.any(|k| *k == id);
if known {
Ok(())
} else {
Err(format!(
"Unknown registry key in ResourceKey[minecraft:root / minecraft:block_entity_type]: {id}"
))
}
}
Some(_) => Ok(()),
},
_ => Ok(()),
},
_ => Ok(()),
}
}
@@ -609,3 +636,26 @@ pub fn suggest_predicate(builder: &mut SuggestionsBuilder, reg: &Registries) {
fn _unused(t: &Tag) -> String {
snbt(t)
}
#[cfg(test)]
mod tests {
use super::decode_component;
fn check(snbt: &str) -> Result<(), String> {
let tag = horizon_world::nbt::parse_snbt(snbt).expect("snbt").0;
decode_component("minecraft:block_entity_data", &tag)
}
#[test]
fn block_entity_data_needs_a_known_type() {
assert_eq!(check("{id:\"chest\",Lock:1}"), Ok(()));
assert_eq!(
check("{Command:\"say\"}"),
Err("Expected 'id' field in {Command:\"say\"}".into())
);
assert_eq!(
check("{id:\"minecraft:stone\"}"),
Err("Unknown registry key in ResourceKey[minecraft:root / minecraft:block_entity_type]: minecraft:stone".into())
);
}
}
+41 -2
View File
@@ -8,6 +8,7 @@ pub mod block;
pub mod codec;
pub mod component;
pub mod item;
pub mod network;
pub mod packrat;
pub mod selector;
pub mod snbt;
@@ -23,6 +24,9 @@ mod vanilla_order;
use crate::text::Text;
pub use args::{Arg, ArgType, Registries};
/// The command id of a mod's command (`Dispatcher::with_mod_commands`).
pub const MOD_COMMAND: &str = "mod";
/// A command error (CommandSyntaxException): the message and, for parse
/// errors, the input and cursor it occurred at.
#[derive(Clone, Debug, PartialEq)]
@@ -770,6 +774,19 @@ impl Dispatcher {
self.nodes[node].permission = level;
node
}
/// This tree plus the commands mods own (`docs/mods.md`), so the chat
/// colours and completes them like the others. Every player may use one,
/// and the text after its name goes to the mod as it was typed.
pub fn with_mod_commands<'a>(&self, names: impl IntoIterator<Item = &'a str>) -> Dispatcher {
let mut d = self.clone();
for name in names {
let command = d.literal(0, name);
d.executes(command, MOD_COMMAND);
let args = d.argument(command, "args", ArgType::Greedy);
d.executes(args, MOD_COMMAND);
}
d
}
/// A root literal by name.
pub fn find(&self, name: &str) -> Option<usize> {
self.nodes[0]
@@ -930,19 +947,41 @@ impl Dispatcher {
let context = Context::new(0, start);
self.parse_nodes(0, input, start, &context, reg, reg.permission)
}
/// CommandDispatcher.getCompletionSuggestions at `cursor`.
/// CommandDispatcher.getCompletionSuggestions at `cursor`: brigadier
/// lists every child, whatever its requirement.
pub fn suggestions(
&self,
parse: &ParseResults,
cursor: usize,
reg: &Registries,
) -> (Range, Vec<Suggestion>) {
self.suggestions_within(parse, cursor, reg, u8::MAX)
}
/// The suggestions the player's chat shows: the client's tree only holds
/// the nodes the player may use (Commands.sendCommands prunes by canUse).
pub fn client_suggestions(
&self,
parse: &ParseResults,
cursor: usize,
reg: &Registries,
) -> (Range, Vec<Suggestion>) {
self.suggestions_within(parse, cursor, reg, reg.permission)
}
fn suggestions_within(
&self,
parse: &ParseResults,
cursor: usize,
reg: &Registries,
permission: u8,
) -> (Range, Vec<Suggestion>) {
let (parent, start, context) = parse.context.suggestion_context(cursor);
let start = start.min(cursor);
let truncated = &parse.input[..cursor.min(parse.input.len())];
let mut all = Vec::new();
// Brigadier lists every child, whatever its requirement.
for &child in &self.nodes[parent].children {
if self.nodes[child].permission > permission {
continue;
}
let mut builder = SuggestionsBuilder::new(truncated, start);
match &self.nodes[child].kind {
NodeKind::Literal(l) => {
@@ -0,0 +1,404 @@
//! The command tree a server sends (ClientboundCommandsPacket) as a
//! dispatcher the chat parses, colours and completes with
//! (ClientPacketListener.handleCommands).
//!
//! The packet holds each argument's parser and its network properties. A
//! node the client's own vanilla tree has at the same path takes that node's
//! argument type and suggestion provider (the parsers' suggestion lists and
//! the custom providers are the same code on both sides); any other node
//! (another server's or a mod's) gets the type its parser and properties
//! describe.
use super::{ArgType, Dispatcher, Node, NodeKind, Suggests};
use horizon_protocol::packets::play::{ARGUMENT_TYPES, ArgumentProperties, Commands};
use std::{
collections::{HashSet, VecDeque},
sync::{Mutex, OnceLock},
};
/// The command id of every executable node of a server's tree: the chat
/// sends the command, the server runs it.
pub const SERVER_COMMAND: &str = "server";
/// The dispatcher for a server's tree. `local` is the client's own tree
/// (vanilla's), whose matching nodes lend their types and providers.
pub fn from_packet(packet: &Commands, local: &Dispatcher) -> Dispatcher {
let count = packet.nodes.len();
let root = usize::try_from(packet.root)
.ok()
.filter(|&r| r < count)
.unwrap_or(0);
// The packet's root becomes node 0, the others follow in packet order.
let index = |i: usize| -> usize {
if i == root {
0
} else if i < root {
i + 1
} else {
i
}
};
let valid = |c: &i32| usize::try_from(*c).ok().filter(|&c| c < count);
// The local node at the same path, found breadth first from the roots
// along children (redirects are not paths of their own).
let mut matched: Vec<Option<usize>> = vec![None; count];
matched[root] = Some(0);
let mut queue = VecDeque::from([root]);
let mut seen = HashSet::from([root]);
while let Some(n) = queue.pop_front() {
for c in packet.nodes[n].children.iter().filter_map(valid) {
if !seen.insert(c) {
continue;
}
let node = &packet.nodes[c];
matched[c] = matched[n].and_then(|l| {
local.nodes[l].children.iter().copied().find(|&lc| {
let ln = &local.nodes[lc];
ln.name() == node.name
&& matches!(
(&ln.kind, node.kind),
(NodeKind::Literal(_), 1) | (NodeKind::Argument { .. }, 2)
)
})
});
queue.push_back(c);
}
}
let mut nodes = vec![None; count];
for (i, node) in packet.nodes.iter().enumerate() {
let local_node = matched[i].map(|l| &local.nodes[l]);
let kind = match node.kind {
1 => NodeKind::Literal(node.name.clone()),
2 => {
let ty = match local_node.map(|l| &l.kind) {
Some(NodeKind::Argument { ty, .. }) => ty.clone(),
_ => node
.parser
.as_ref()
.map_or(ArgType::Word, |(id, props)| argument_type(*id, props)),
};
NodeKind::Argument {
name: node.name.clone(),
ty,
}
}
_ => NodeKind::Root,
};
let suggests = match local_node {
Some(l) => l.suggests.clone(),
None => node.suggestions.as_deref().and_then(provider),
};
nodes[index(i)] = Some(Node {
kind: if i == root { NodeKind::Root } else { kind },
children: node.children.iter().filter_map(valid).map(index).collect(),
command: node.executable.then_some(SERVER_COMMAND),
redirect: node.redirect.as_ref().and_then(valid).map(index),
// The server sent only what the player may use.
permission: 0,
suggests,
});
}
let mut d = Dispatcher::new();
if count > 0 {
d.nodes = nodes.into_iter().flatten().collect();
}
d
}
/// SuggestionProviders by id (`ask_server` asks the server; the client's
/// own argument suggestions stand in for it).
fn provider(id: &str) -> Option<Suggests> {
match id {
"minecraft:summonable_entities" => Some(Suggests::SummonableEntities),
_ => None,
}
}
/// A registry name kept for the life of the game (argument types hold
/// `&'static str`): each distinct name is stored once.
fn intern(name: &str) -> &'static str {
static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
let mut names = NAMES
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(&n) = names.get(name) {
return n;
}
let n: &'static str = Box::leak(name.to_owned().into_boxed_str());
names.insert(n);
n
}
/// The argument type a parser and its properties describe
/// (ArgumentTypeInfo.Template.instantiate).
pub fn argument_type(id: i32, props: &[u8]) -> ArgType {
let Some(name) = usize::try_from(id).ok().and_then(|i| ARGUMENT_TYPES.get(i)) else {
return ArgType::Word;
};
let p = ArgumentProperties::decode(id, props).unwrap_or(ArgumentProperties::None);
let registry = || match &p {
ArgumentProperties::Registry(r) => intern(r),
_ => "",
};
match *name {
"brigadier:bool" => ArgType::Bool,
"brigadier:integer" => match p {
ArgumentProperties::Integer { min, max } => ArgType::Integer {
min: min.unwrap_or(i32::MIN),
max: max.unwrap_or(i32::MAX),
},
_ => ArgType::Integer {
min: i32::MIN,
max: i32::MAX,
},
},
// No long type of its own: a double over the same bounds reads
// the same digits.
"brigadier:long" => match p {
ArgumentProperties::Long { min, max } => ArgType::Double {
min: min.map_or(f64::MIN, |v| v as f64),
max: max.map_or(f64::MAX, |v| v as f64),
},
_ => ArgType::Double {
min: f64::MIN,
max: f64::MAX,
},
},
"brigadier:float" => match p {
ArgumentProperties::Float { min, max } => ArgType::Float {
min: min.unwrap_or(f32::MIN),
max: max.unwrap_or(f32::MAX),
},
_ => ArgType::Float {
min: f32::MIN,
max: f32::MAX,
},
},
"brigadier:double" => match p {
ArgumentProperties::Double { min, max } => ArgType::Double {
min: min.unwrap_or(f64::MIN),
max: max.unwrap_or(f64::MAX),
},
_ => ArgType::Double {
min: f64::MIN,
max: f64::MAX,
},
},
"brigadier:string" => match p {
ArgumentProperties::String(1) => ArgType::Str,
ArgumentProperties::String(2) => ArgType::Greedy,
_ => ArgType::Word,
},
"minecraft:entity" => match p {
ArgumentProperties::Entity {
single,
players_only,
} => ArgType::Entity {
single,
players: players_only,
},
_ => ArgType::Entity {
single: false,
players: false,
},
},
"minecraft:game_profile" => ArgType::GameProfile,
"minecraft:block_pos" => ArgType::BlockPos,
"minecraft:column_pos" => ArgType::ColumnPos,
"minecraft:vec3" => ArgType::Vec3 { center: true },
"minecraft:vec2" => ArgType::Vec2,
"minecraft:block_state" => ArgType::BlockState,
"minecraft:block_predicate" => ArgType::BlockPredicate,
"minecraft:item_stack" => ArgType::Item,
"minecraft:item_predicate" => ArgType::ItemPredicate,
"minecraft:team_color" => ArgType::TeamColor,
"minecraft:hex_color" => ArgType::HexColor,
"minecraft:component" => ArgType::Component,
"minecraft:style" => ArgType::Style,
"minecraft:message" => ArgType::Message,
"minecraft:nbt_compound_tag" => ArgType::Nbt,
"minecraft:nbt_tag" => ArgType::NbtTag,
"minecraft:nbt_path" => ArgType::NbtPath,
"minecraft:objective" => ArgType::Objective,
"minecraft:objective_criteria" => ArgType::Criteria,
"minecraft:operation" => ArgType::Operation,
"minecraft:particle" => ArgType::Particle,
"minecraft:angle" => ArgType::Angle,
"minecraft:rotation" => ArgType::Rotation,
"minecraft:scoreboard_slot" => ArgType::ScoreboardSlot,
"minecraft:score_holder" => ArgType::ScoreHolder {
multiple: matches!(p, ArgumentProperties::ScoreHolder { multiple: true }),
},
"minecraft:swizzle" => ArgType::Swizzle,
"minecraft:team" => ArgType::Team,
"minecraft:item_slot" => ArgType::Slot,
"minecraft:item_slots" => ArgType::Slots,
"minecraft:resource_location" => ArgType::Id("none"),
"minecraft:function" => ArgType::Function,
"minecraft:entity_anchor" => ArgType::Anchor,
"minecraft:int_range" => ArgType::IntRange,
"minecraft:float_range" => ArgType::FloatRange,
"minecraft:dimension" => ArgType::Dimension,
"minecraft:gamemode" => ArgType::GameMode,
"minecraft:time" => ArgType::Time {
min: match p {
ArgumentProperties::Time { min } => min,
_ => 0,
},
},
"minecraft:resource_or_tag" => ArgType::ResOrTag(registry()),
"minecraft:resource_or_tag_key" => ArgType::ResOrTagKey(registry()),
"minecraft:resource" => ArgType::Res(registry()),
"minecraft:resource_key" => ArgType::ResKey(registry()),
"minecraft:resource_selector" => ArgType::ResourceSelector(registry()),
"minecraft:template_mirror" => ArgType::Enum(&["none", "front_back", "left_right"]),
"minecraft:template_rotation" => {
ArgType::Enum(&["none", "clockwise_90", "180", "counterclockwise_90"])
}
"minecraft:heightmap" => ArgType::Enum(&[
"world_surface",
"motion_blocking",
"motion_blocking_no_leaves",
"ocean_floor",
]),
"minecraft:swing_animation" => ArgType::Enum(&["none", "whack", "stab"]),
"minecraft:loot_table" => ArgType::ResourceOrId("minecraft:loot_table"),
"minecraft:loot_predicate" => ArgType::ResourceOrId("minecraft:predicate"),
"minecraft:loot_modifier" => ArgType::ResourceOrId("minecraft:item_modifier"),
"minecraft:context_float_provider" => {
ArgType::ResourceOrId("minecraft:context_float_provider")
}
"minecraft:context_int_provider" => ArgType::ResourceOrId("minecraft:context_int_provider"),
"minecraft:slot_source" => ArgType::SlotSource,
"minecraft:dialog" => ArgType::ResourceOrId("minecraft:dialog"),
"minecraft:feature" => ArgType::ResourceOrId("minecraft:worldgen/feature"),
"minecraft:uuid" => ArgType::Uuid,
_ => ArgType::Word,
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::play::CommandNode,
};
fn literal(name: &str, children: Vec<i32>, executable: bool) -> CommandNode {
CommandNode {
kind: 1,
executable,
restricted: false,
children,
redirect: None,
name: name.into(),
parser: None,
suggestions: None,
}
}
fn argument(name: &str, parser: &str, props: Vec<u8>, executable: bool) -> CommandNode {
let id = ARGUMENT_TYPES.iter().position(|p| *p == parser).unwrap() as i32;
CommandNode {
kind: 2,
executable,
restricted: false,
children: Vec::new(),
redirect: None,
name: name.into(),
parser: Some((id, props)),
suggestions: Some("minecraft:ask_server".into()),
}
}
/// A tree with the root last (as vanilla's numbering need not put it
/// first), a vanilla command and one the client does not know.
fn packet() -> Commands {
let mut range = Writer::new();
range.u8(3);
range.i32(1);
range.i32(64);
Commands {
nodes: vec![
literal("give", vec![2], false),
literal("warp", vec![3], false),
argument("targets", "minecraft:entity", vec![2], false),
argument("count", "brigadier:integer", range.data, true),
CommandNode {
kind: 0,
executable: false,
restricted: false,
children: vec![0, 1],
redirect: None,
name: String::new(),
parser: None,
suggestions: None,
},
],
root: 4,
}
}
#[test]
fn argument_nodes_round_trip() {
let p = packet();
let mut w = Writer::new();
Commands::write(&p, &mut w);
let back = Commands::read(&mut Reader::new(&w.data)).unwrap();
assert_eq!(back, p);
}
#[test]
fn server_tree_becomes_a_dispatcher() {
let local = super::super::vanilla::dispatcher();
let d = from_packet(&packet(), local);
assert!(matches!(d.nodes[0].kind, NodeKind::Root));
let warp = d.find("warp").expect("warp");
let count = d.nodes[warp].children[0];
match &d.nodes[count].kind {
NodeKind::Argument {
ty: ArgType::Integer { min, max },
..
} => assert_eq!((*min, *max), (1, 64)),
other => panic!("count is {other:?}"),
}
assert_eq!(d.nodes[count].command, Some(SERVER_COMMAND));
// `give <targets>` is vanilla's: it keeps the client's type.
let give = d.find("give").expect("give");
let targets = d.nodes[give].children[0];
assert!(matches!(
d.nodes[targets].kind,
NodeKind::Argument {
ty: ArgType::Entity {
single: false,
players: true
},
..
}
));
}
#[test]
fn every_argument_type_decodes() {
for (id, name) in ARGUMENT_TYPES.iter().enumerate() {
let mut w = Writer::new();
match *name {
"brigadier:integer" | "brigadier:long" | "brigadier:float" | "brigadier:double" => {
w.u8(0)
}
"brigadier:string" => w.varint(2),
"minecraft:entity" | "minecraft:score_holder" => w.u8(1),
"minecraft:time" => w.i32(0),
n if n.starts_with("minecraft:resource") && n != "minecraft:resource_location" => {
w.string("minecraft:item")
}
_ => {}
}
let mut r = Reader::new(&w.data);
ArgumentProperties::read(&mut r, id as i32).unwrap();
assert_eq!(r.remaining(), 0, "{name}");
let _ = argument_type(id as i32, &w.data);
}
}
}
@@ -185,6 +185,42 @@ fn vanilla_error_messages() {
assert_eq!(e.message.plain(), "argument.block.property.invalid");
}
#[test]
fn commands_need_allow_commands() {
// IntegratedServer.getProfilePermissions: the owner of a world without
// Allow Commands is at LevelBasedPermissionSet.ALL.
let reg = Registries {
permission: 0,
..reg()
};
let d = vanilla::game_dispatcher();
let e = d.parse("gamemode creative", &reg).executable().unwrap_err();
assert_eq!(e.message.plain(), "command.unknown.command");
assert!(d.parse("me waves", &reg).executable().is_ok());
let parse = d.parse("", &reg);
// Brigadier itself lists every literal; the chat's (client tree) only
// the ones the player may use.
assert!(
d.suggestions(&parse, 0, &reg)
.1
.iter()
.any(|s| s.text == "gamemode")
);
let root: Vec<String> = d
.client_suggestions(&parse, 0, &reg)
.1
.into_iter()
.map(|s| s.text)
.collect();
// Commands.java with CommandSelection.INTEGRATED: seed and version need no level.
assert_eq!(
root,
[
"help", "list", "me", "msg", "random", "seed", "teammsg", "tell", "tm", "trigger",
"version", "w"
]
);
}
#[test]
fn suggestions_like_vanilla() {
let root = suggestions("");
assert!(root.contains(&"gamemode".to_owned()) && root.contains(&"tp".to_owned()));
@@ -534,3 +570,25 @@ fn place_template_tree() {
assert!(run("place template minecraft:x ~ ~ ~ sideways").is_err());
assert!(run("place template minecraft:x ~ ~ ~ none none 2").is_err());
}
#[test]
fn mod_commands_join_the_tree_for_every_player() {
let reg = Registries {
permission: 0,
..reg()
};
let d = vanilla::game_dispatcher().with_mod_commands(["where"]);
for line in ["where", "where north 12"] {
let parse = d.parse(line, &reg);
assert_eq!(parse.executable().unwrap().1, MOD_COMMAND, "{line}");
}
let parse = d.parse("w", &reg);
let names: Vec<String> = d
.client_suggestions(&parse, 1, &reg)
.1
.into_iter()
.map(|s| s.text)
.collect();
assert!(names.contains(&"where".to_owned()), "{names:?}");
// The game's own tree is left as it was.
assert!(vanilla::game_dispatcher().find("where").is_none());
}
+256
View File
@@ -0,0 +1,256 @@
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
use crate::{
save::{number, text},
text::Text,
};
use horizon_world::nbt::Tag;
/// `CommandBlockEntity.Mode`, in declaration order (the edit screen's mode
/// button cycles through it).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Sequence,
Auto,
Redstone,
}
impl Mode {
/// `CommandBlockEntity.getMode`: the block the entity sits in.
pub fn of_block(name: &str) -> Option<Self> {
match name {
"minecraft:command_block" => Some(Self::Redstone),
"minecraft:repeating_command_block" => Some(Self::Auto),
"minecraft:chain_command_block" => Some(Self::Sequence),
_ => None,
}
}
/// The block `ServerGamePacketListenerImpl.handleSetCommandBlock` puts
/// down for a mode.
pub fn block(self) -> &'static str {
match self {
Self::Sequence => "minecraft:chain_command_block",
Self::Auto => "minecraft:repeating_command_block",
Self::Redstone => "minecraft:command_block",
}
}
/// `CycleButton`: the next value, wrapping round.
pub fn next(self) -> Self {
match self {
Self::Sequence => Self::Auto,
Self::Auto => Self::Redstone,
Self::Redstone => Self::Sequence,
}
}
/// `CommandBlockEditScreen`'s label for the mode.
pub fn label(self) -> &'static str {
match self {
Self::Sequence => "advMode.mode.sequence",
Self::Auto => "advMode.mode.auto",
Self::Redstone => "advMode.mode.redstone",
}
}
}
/// The `automatic` flag `CommandBlock` gives its entity: chain command blocks
/// are always active when placed.
pub fn automatic_block(name: &str) -> bool {
name == "minecraft:chain_command_block"
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
impl Default for CommandBlock {
fn default() -> Self {
Self {
command: String::new(),
success_count: 0,
custom_name: None,
track_output: true,
last_output: None,
update_last_execution: true,
last_execution: -1,
powered: false,
auto: false,
condition_met: false,
}
}
}
impl CommandBlock {
/// `CommandBlockEntity.loadAdditional` with `BaseCommandBlock.load`.
pub fn load(tag: &Tag) -> Self {
let flag = |key: &str, default: bool| number(tag.get(key)).map_or(default, |v| v != 0);
let track_output = flag("TrackOutput", true);
let update_last_execution = flag("UpdateLastExecution", true);
Self {
command: text(tag.get("Command")).unwrap_or_default(),
success_count: number(tag.get("SuccessCount")).unwrap_or(0) as i32,
custom_name: tag.get("CustomName").cloned(),
track_output,
last_output: track_output
.then(|| tag.get("LastOutput"))
.flatten()
.map(|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t))),
update_last_execution,
last_execution: if update_last_execution {
number(tag.get("LastExecution")).unwrap_or(-1)
} else {
-1
},
powered: flag("powered", false),
auto: flag("auto", false),
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
("SuccessCount", Tag::Int(self.success_count)),
];
if let Some(name) = &self.custom_name {
out.push(("CustomName", name.clone()));
}
out.push(("TrackOutput", Tag::Byte(i8::from(self.track_output))));
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
}
out.push((
"UpdateLastExecution",
Tag::Byte(i8::from(self.update_last_execution)),
));
if self.update_last_execution && self.last_execution != -1 {
out.push(("LastExecution", Tag::Long(self.last_execution)));
}
out.push(("powered", Tag::Byte(i8::from(self.powered))));
out.push(("conditionMet", Tag::Byte(i8::from(self.condition_met))));
out.push(("auto", Tag::Byte(i8::from(self.auto))));
out
}
/// `BaseCommandBlock.setCommand`: a new command starts at no successes.
pub fn set_command(&mut self, command: &str) {
self.command = command.to_owned();
self.success_count = 0;
}
/// `getLastOutput().getString()`: what the edit screen shows.
pub fn last_output_string(&self) -> String {
self.last_output
.as_ref()
.map(Text::plain)
.unwrap_or_default()
}
/// `getName`: the custom name, else `@`.
pub fn name(&self) -> Text {
self.custom_name.as_ref().map_or_else(
|| Text::literal("@"),
|t| crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
)
}
}
/// What `CommandBlock.tick` does with a block of `mode` whose condition was
/// `met` when last checked: run its command (`Some(true)`), clear the success
/// count of a conditional block (`Some(false)`), or nothing. Chain blocks
/// only run from the block before them (`executeChain`).
pub fn tick_action(mode: Mode, met: bool, conditional: bool) -> Option<bool> {
match mode {
Mode::Sequence => None,
_ if met => Some(true),
_ if conditional => Some(false),
_ => None,
}
}
/// `CommandBlock.tick`: a repeating block ticks again next tick while powered
/// or always active.
pub fn repeats(mode: Mode, powered: bool, auto: bool) -> bool {
mode == Mode::Auto && (powered || auto)
}
/// `CommandBlock.setPoweredAndUpdate`: a rising signal schedules the impulse
/// or repeating block's tick unless it is always active anyway.
pub fn power_schedules(mode: Mode, was_powered: bool, powered: bool, auto: bool) -> bool {
powered && !was_powered && !auto && mode != Mode::Sequence
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_cycle_and_map_to_blocks() {
for mode in [Mode::Sequence, Mode::Auto, Mode::Redstone] {
assert_eq!(Mode::of_block(mode.block()), Some(mode));
}
assert_eq!(Mode::Redstone.next(), Mode::Sequence);
assert_eq!(Mode::Sequence.next(), Mode::Auto);
assert!(automatic_block("minecraft:chain_command_block"));
assert!(!automatic_block("minecraft:repeating_command_block"));
}
#[test]
fn tick_follows_the_condition() {
assert_eq!(tick_action(Mode::Redstone, true, false), Some(true));
assert_eq!(tick_action(Mode::Redstone, false, true), Some(false));
assert_eq!(tick_action(Mode::Auto, false, false), None);
assert_eq!(tick_action(Mode::Sequence, true, false), None);
assert!(repeats(Mode::Auto, false, true));
assert!(!repeats(Mode::Auto, false, false));
assert!(!repeats(Mode::Redstone, true, true));
}
#[test]
fn only_a_rising_edge_schedules() {
assert!(power_schedules(Mode::Redstone, false, true, false));
assert!(!power_schedules(Mode::Redstone, true, true, false));
assert!(!power_schedules(Mode::Redstone, false, true, true));
assert!(!power_schedules(Mode::Sequence, false, true, false));
}
#[test]
fn saves_like_base_command_block() {
let block = CommandBlock {
command: "say hi".into(),
success_count: 1,
last_output: Some(Text::literal("[12:00:00] hi")),
last_execution: 42,
powered: true,
..CommandBlock::default()
};
let tag = Tag::compound(block.save());
let back = CommandBlock::load(&tag);
assert_eq!(back.command, "say hi");
assert_eq!(back.success_count, 1);
assert_eq!(back.last_output_string(), "[12:00:00] hi");
assert_eq!(back.last_execution, 42);
assert!(back.powered && !back.auto && back.track_output);
// Not tracking output drops it, and no execution time is kept at -1.
let quiet = CommandBlock {
track_output: false,
last_output: Some(Text::literal("x")),
..CommandBlock::default()
};
let tag = Tag::compound(quiet.save());
assert!(tag.get("LastOutput").is_none() && tag.get("LastExecution").is_none());
assert_eq!(CommandBlock::load(&tag).last_output, None);
}
}
+29 -3
View File
@@ -70,8 +70,12 @@ pub enum BlockEntity {
Spawner(Box<crate::mobs::spawner::Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<crate::vault::Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
@@ -128,6 +132,8 @@ impl Crafter {
pub struct Banner {
pub layers: Vec<crate::banner::BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
@@ -192,12 +198,24 @@ impl BlockEntity {
if name == "minecraft:trial_spawner" {
return Some(Self::TrialSpawner(Box::default()));
}
if name == "minecraft:vault" {
return Some(Self::Vault(Box::default()));
}
if name == "minecraft:beehive" || name == "minecraft:bee_nest" {
return Some(Self::Beehive(Box::default()));
}
if name == "minecraft:lectern" {
return Some(Self::Lectern(Lectern::default()));
}
if crate::command_block::Mode::of_block(name).is_some() {
// CommandBlock.newBlockEntity: setAutomatic(this.automatic).
return Some(Self::CommandBlock(Box::new(
crate::command_block::CommandBlock {
auto: crate::command_block::automatic_block(name),
..Default::default()
},
)));
}
if let Some(sculk) = crate::sculk::SculkEntity::for_block(name) {
return Some(Self::Sculk(Box::new(sculk)));
}
@@ -272,7 +290,9 @@ impl BlockEntity {
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
Self::TrialSpawner(_) => "minecraft:trial_spawner",
Self::Vault(_) => "minecraft:vault",
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -286,7 +306,9 @@ impl BlockEntity {
| Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Beehive(_) => Vec::new(),
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -308,7 +330,9 @@ impl BlockEntity {
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Beehive(_) => &[],
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
@@ -326,7 +350,9 @@ impl BlockEntity {
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Beehive(_) => &mut [],
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &mut [],
}
}
}
+12 -8
View File
@@ -467,13 +467,16 @@ impl FrameStats {
}
let total: f64 = sorted.iter().map(|&m| f64::from(m)).sum();
let percentile = |p: f64| sorted[((n as f64 * p).ceil() as usize).clamp(1, n) - 1];
// Average frame time of the slowest 1% of frames, as frames per second.
let slowest = n.div_ceil(100);
let slow: f64 = sorted[n - slowest..]
.iter()
.map(|&m| f64::from(m))
.sum::<f64>()
/ slowest as f64;
// Average frame time of the slowest 1% (0.1%) of frames, as frames per second.
let low = |per: usize| {
let slowest = n.div_ceil(per);
let slow: f64 = sorted[n - slowest..]
.iter()
.map(|&m| f64::from(m))
.sum::<f64>()
/ slowest as f64;
1000.0 / slow
};
// Where the slowest 1% of frames by CPU time go.
let slow_phases = {
let mut cpu = self.cpu.clone();
@@ -487,7 +490,8 @@ impl FrameStats {
"frames": n,
"seconds": total / 1000.0,
"avg_fps": n as f64 * 1000.0 / total,
"low_1pct_fps": 1000.0 / slow,
"low_1pct_fps": low(100),
"low_01pct_fps": low(1000),
"p50_ms": percentile(0.5),
"p95_ms": percentile(0.95),
"p99_ms": percentile(0.99),
+27 -12
View File
@@ -14,7 +14,7 @@ use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
path::{Path, PathBuf},
sync::Arc,
};
@@ -1182,18 +1182,33 @@ impl Recipes {
let mut crafting = Vec::new();
let mut cooking = Vec::new();
let mut meta = HashMap::new();
let mut files: Vec<_> = fs::read_dir(root.join("data/minecraft/recipe"))?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
let mut files: Vec<_> = fs::read_dir(dir)?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
Ok(files)
};
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut files: Vec<(String, PathBuf)> = json_files(&root.join("data/minecraft/recipe"))?
.into_iter()
.map(|p| (String::new(), p))
.collect();
files.sort();
for path in files {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
files.extend(
json_files(&dir)?
.into_iter()
.map(|p| (format!("{namespace}:"), p)),
);
}
}
for (prefix, path) in files {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let name = format!("{prefix}{stem}");
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
let text = |k: &str| v[k].as_str().unwrap_or("").to_owned();
meta.insert(name.clone(), (text("category"), text("group")));
+2 -1
View File
@@ -164,7 +164,8 @@ fn operating_system() -> String {
)
}
fn cpu() -> String {
/// Logical processors and the CPU's name (`12x AMD Ryzen 5 5600X ...`).
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
+213 -7
View File
@@ -36,6 +36,9 @@ pub struct Creative {
/// The scrollbar is held (scrolling): dragging moves it.
pub scrolling: bool,
pub search: String,
/// When the search box took the focus (`EditBox.setFocused`): its
/// cursor blinks from then.
pub search_focused: std::time::Instant,
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
@@ -46,17 +49,24 @@ pub struct Names {
}
impl Names {
pub fn load(root: &Path) -> Self {
let map = fs::read(root.join("assets/minecraft/lang/en_us.json"))
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, String>>(&b).ok())
.unwrap_or_default();
let read = |namespace: &str| {
fs::read(root.join(format!("assets/{namespace}/lang/en_us.json")))
.ok()
.and_then(|b| serde_json::from_slice::<HashMap<String, String>>(&b).ok())
.unwrap_or_default()
};
let mut map = read("minecraft");
// Content mods' names (docs/modpacks.md); none in a vanilla root.
for namespace in crate::mod_content::namespaces(root) {
map.extend(read(&namespace));
}
Self { map }
}
pub fn item(&self, id: &str) -> String {
let path = id.trim_start_matches("minecraft:");
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
self.map
.get(&format!("item.minecraft.{path}"))
.or_else(|| self.map.get(&format!("block.minecraft.{path}")))
.get(&format!("item.{namespace}.{path}"))
.or_else(|| self.map.get(&format!("block.{namespace}.{path}")))
.cloned()
.unwrap_or_else(|| path.replace('_', " "))
}
@@ -93,6 +103,26 @@ impl Creative {
_ => None,
}
}
/// Game slot id of an item tab's first display slot (ItemPickerMenu's
/// `CustomCreativeSlot`s, 9 by 5); its hotbar row keeps ids 0..9.
pub const DISPLAY: usize = 900;
/// Visible slots of an item tab (game slot ids).
pub fn tab_slots() -> impl Iterator<Item = usize> {
(Self::DISPLAY..Self::DISPLAY + 45).chain(0..9)
}
/// ItemPickerMenu's slot positions.
pub fn tab_slot_position(slot: usize) -> Option<(f32, f32)> {
match slot {
0..9 => Some((9.0 + slot as f32 * 18.0, 112.0)),
_ if (Self::DISPLAY..Self::DISPLAY + 45).contains(&slot) => {
let i = slot - Self::DISPLAY;
Some((9.0 + (i % 9) as f32 * 18.0, 18.0 + (i / 9) as f32 * 18.0))
}
_ => None,
}
}
/// The scrollbar track (insideScrollbar), relative to the screen origin.
pub const SCROLL_X: f32 = 175.0;
pub const SCROLL_Y: f32 = 18.0;
@@ -212,10 +242,21 @@ impl Creative {
scroll: 0.0,
scrolling: false,
search: String::new(),
search_focused: std::time::Instant::now(),
search_items,
search_index,
}
}
/// Where a stack comes in the creative tabs (the search tab lists them
/// all, in tab order): the same stack's place, else its item's first.
/// None for items the tabs don't show.
pub fn order_of(&self, stack: &ItemStack) -> Option<usize> {
let items = &self.search_items;
items
.iter()
.position(|s| s.same_item_same_components(stack))
.or_else(|| items.iter().position(|s| s.item == stack.item))
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
}
@@ -226,6 +267,9 @@ impl Creative {
}
self.selected = tab;
self.scroll = 0.0;
if self.tab().search {
self.search_focused = std::time::Instant::now();
}
}
/// The search tab's stacks.
pub fn search_items(&self) -> &[ItemStack] {
@@ -380,10 +424,172 @@ fn split_id(id: &str) -> (String, String) {
None => ("minecraft".to_owned(), id.to_owned()),
}
}
/// What a click on an item tab's display slot does besides changing the
/// carried stack.
#[derive(Clone, PartialEq, Debug)]
pub enum DisplayClick {
Nothing,
/// SWAP: the hotbar slot gets a full stack of the item.
Hotbar(usize, ItemStack),
/// THROW: a copy of the item is dropped.
Drop(ItemStack),
}
/// `CreativeModeInventoryScreen.slotClicked` on a display slot
/// (`slot.container == CONTAINER`): `shown` is the slot's item (None past
/// the tab's end) and `max` gives an item's max stack size. The tab's items
/// are never changed: clicks make copies.
pub fn click_display(
carried: &mut Option<ItemStack>,
shown: Option<&ItemStack>,
max: impl Fn(&ItemStack) -> u32,
button: i32,
input: crate::menu::Input,
) -> DisplayClick {
use crate::menu::Input;
let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() };
let quick_key = input == Input::QuickMove;
match input {
Input::Swap => {
return match shown {
Some(s) => DisplayClick::Hotbar(button as usize, copy(s, max(s))),
None => DisplayClick::Nothing,
};
}
Input::Clone => {
if carried.is_none()
&& let Some(s) = shown
{
*carried = Some(copy(s, max(s)));
}
return DisplayClick::Nothing;
}
Input::Throw => {
return match shown {
Some(s) => DisplayClick::Drop(copy(s, if button == 0 { 1 } else { max(s) })),
None => DisplayClick::Nothing,
};
}
_ => {}
}
match (carried.as_mut(), shown) {
(Some(c), Some(s)) if c.item == s.item && c.extra == s.extra && c.damage == s.damage => {
// isSameItemSameComponents: grow, fill or shrink what is carried.
if button == 0 {
if quick_key {
c.count = max(c);
} else if c.count < max(c) {
c.count += 1;
}
} else {
c.count -= 1;
}
}
(None, Some(s)) => {
*carried = Some(copy(s, if quick_key { max(s) } else { s.count }));
}
(Some(c), _) => {
if button == 0 {
*carried = None;
} else {
c.count -= 1;
}
}
(None, None) => {}
}
if carried.as_ref().is_some_and(|c| c.count == 0) {
*carried = None;
}
DisplayClick::Nothing
}
#[cfg(test)]
mod tests {
use super::*;
fn stack(item: &str, count: u32) -> ItemStack {
ItemStack {
item: format!("minecraft:{item}").into(),
count,
damage: 0,
extra: None,
}
}
/// One click on a display slot showing one stone (max 64; pearls 16).
fn display(
carried: Option<ItemStack>,
button: i32,
input: crate::menu::Input,
) -> (Option<ItemStack>, DisplayClick) {
let mut carried = carried;
let max = |s: &ItemStack| if s.item.ends_with("pearl") { 16 } else { 64 };
let effect = click_display(&mut carried, Some(&stack("stone", 1)), max, button, input);
(carried, effect)
}
#[test]
fn display_slot_clicks_follow_slot_clicked() {
use crate::menu::Input::*;
// An empty hand takes the shown stack, or a full one with Shift.
assert_eq!(display(None, 0, Pickup).0, Some(stack("stone", 1)));
assert_eq!(display(None, 1, Pickup).0, Some(stack("stone", 1)));
assert_eq!(display(None, 0, QuickMove).0, Some(stack("stone", 64)));
// The same item grows by one (left), fills (Shift) or shrinks (right).
assert_eq!(
display(Some(stack("stone", 5)), 0, Pickup).0,
Some(stack("stone", 6))
);
assert_eq!(
display(Some(stack("stone", 64)), 0, Pickup).0,
Some(stack("stone", 64))
);
assert_eq!(
display(Some(stack("stone", 5)), 0, QuickMove).0,
Some(stack("stone", 64))
);
assert_eq!(
display(Some(stack("stone", 5)), 1, Pickup).0,
Some(stack("stone", 4))
);
assert_eq!(display(Some(stack("stone", 1)), 1, QuickMove).0, None);
// Another item is deleted (left) or loses one (right).
assert_eq!(display(Some(stack("ender_pearl", 9)), 0, Pickup).0, None);
assert_eq!(display(Some(stack("ender_pearl", 9)), 0, QuickMove).0, None);
assert_eq!(
display(Some(stack("ender_pearl", 9)), 1, Pickup).0,
Some(stack("ender_pearl", 8))
);
// Number keys fill a hotbar slot, Q drops copies, pick block clones.
assert_eq!(
display(None, 3, Swap).1,
DisplayClick::Hotbar(3, stack("stone", 64))
);
assert_eq!(
display(None, 0, Throw).1,
DisplayClick::Drop(stack("stone", 1))
);
assert_eq!(
display(None, 1, Throw).1,
DisplayClick::Drop(stack("stone", 64))
);
assert_eq!(display(None, 2, Clone).0, Some(stack("stone", 64)));
assert_eq!(
display(Some(stack("dirt", 2)), 2, Clone).0,
Some(stack("dirt", 2))
);
}
#[test]
fn an_empty_display_slot_deletes_or_shrinks_the_carried_stack() {
use crate::menu::Input::Pickup;
let mut carried = Some(stack("dirt", 3));
click_display(&mut carried, None, |_| 64, 1, Pickup);
assert_eq!(carried, Some(stack("dirt", 2)));
click_display(&mut carried, None, |_| 64, 0, Pickup);
assert_eq!(carried, None);
}
fn creative(items: usize) -> Creative {
let mut stacks: Vec<String> = ["anvil", "crafting_table", "wooden_hoe"]
.iter()
+62 -1
View File
@@ -34,6 +34,27 @@ impl DataPack {
}
}
}
/// The files of `data/<namespace>/<kind>/` for each kind, in the order
/// `list("data/")` gives them. A folder pack walks only those folders:
/// the built-in pack holds about 10,000 other data files.
fn list_kinds(&self, kinds: &[&str]) -> Vec<String> {
debug_assert!(kinds.is_sorted(), "kinds in walk order");
let Source::Dir(root) = &self.source else {
return self.list("data/");
};
let Ok(entries) = std::fs::read_dir(root.join("data")) else {
return Vec::new();
};
let mut namespaces: Vec<_> = entries.flatten().map(|e| e.path()).collect();
namespaces.sort();
let mut out = Vec::new();
for ns in namespaces {
for kind in kinds {
walk(root, &ns.join(kind), &mut out);
}
}
out
}
fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
@@ -308,7 +329,7 @@ impl DataStore {
pub fn load(packs: &[&DataPack]) -> Self {
let mut s = Self::default();
for pack in packs {
for f in pack.list("data/") {
for f in pack.list_kinds(&["function", "item_modifier", "predicate", "tags/function"]) {
if let Some(id) = resource_id(&f, "function", ".mcfunction")
&& let Some(bytes) = pack.read(&f)
{
@@ -505,4 +526,44 @@ mod tests {
assert!(store.predicates.contains_key("mine:p"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn folder_packs_list_the_needed_kinds_in_walk_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-kinds-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let pack = dir.join("datapacks/p");
let files = [
"data/b/function/z.mcfunction",
"data/b/function/a/y.mcfunction",
"data/b/loot_table/l.json",
"data/b/tags/block/t.json",
"data/b/tags/function/load.json",
"data/b/predicate/p.json",
"data/a/item_modifier/m.json",
"data/a/function/f.mcfunction",
"data/a/functions/old.mcfunction",
"data/a/recipe/r.json",
"data/a/tags/function/tick.json",
"data/c/worldgen/noise/n.json",
];
for f in files {
let path = pack.join(f);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "{}").unwrap();
}
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
let p = DataPack::builtin(&pack);
let kinds = ["function", "item_modifier", "predicate", "tags/function"];
let wanted: Vec<String> = p
.list("data/")
.into_iter()
.filter(|f| {
let rest = f.splitn(3, '/').nth(2).unwrap_or_default();
kinds.iter().any(|k| rest.starts_with(&format!("{k}/")))
})
.collect();
assert_eq!(wanted.len(), 7);
assert_eq!(p.list_kinds(&kinds), wanted);
let _ = std::fs::remove_dir_all(&dir);
}
}
+43
View File
@@ -72,6 +72,12 @@ pub struct DebugCharts {
pub ping: SampleLogger,
/// `bandwidthLogger`: bytes received per tick.
pub bandwidth: SampleLogger,
/// `MinecraftServer.smoothedTickTimeMillis`.
pub smoothed_tick_ms: f32,
/// Connection's smoothed packets per second, sent then received.
pub packet_rates: [f32; 2],
packet_totals: [i64; 2],
packet_tick: u8,
previous_tick: Option<Instant>,
bytes_seen: Option<i64>,
}
@@ -110,6 +116,10 @@ impl DebugCharts {
self.bandwidth.reset();
self.previous_tick = None;
self.bytes_seen = None;
self.smoothed_tick_ms = 0.0;
self.packet_rates = [0.0; 2];
self.packet_totals = [0; 2];
self.packet_tick = 0;
}
/// One integrated server tick that started at `started` and ran its
/// tick method for `method` (`MinecraftServer.logFullTickTime` and
@@ -123,6 +133,7 @@ impl DebugCharts {
.max(method);
self.previous_tick = Some(started);
self.tick_times.log_full([full, method, 0, full - method]);
self.smoothed_tick_ms = self.smoothed_tick_ms * 0.8 + method as f32 / 1e6 * 0.199_999_99;
}
/// `BandwidthDebugMonitor.tick`: the bytes received since the last
/// tick, from the connection's running total.
@@ -132,6 +143,17 @@ impl DebugCharts {
}
self.bytes_seen = Some(total);
}
/// `Connection.tickSecond`: sampled on the first tick, then every 20 ticks.
pub fn log_packets(&mut self, totals: [i64; 2]) {
if self.packet_tick == 0 {
for (i, total) in totals.into_iter().enumerate() {
let count = (total - self.packet_totals[i]).max(0) as f32;
self.packet_rates[i] = count + 0.75 * (self.packet_rates[i] - count);
}
self.packet_totals = totals;
}
self.packet_tick = (self.packet_tick + 1) % 20;
}
/// The charts along the bottom of the screen (`extractRenderState`):
/// frame times left and tick times right, or bandwidth left (remote
/// servers only) and ping right. `mspt` is the level's milliseconds
@@ -520,6 +542,27 @@ mod tests {
assert!(draws.contains(&fill(361, 174, 365, 178, 0xFF0A_141E)));
}
#[test]
fn packet_rates_follow_connection_smoothing_and_reset_between_worlds() {
let mut charts = DebugCharts::default();
charts.log_packets([80, 400]);
assert_eq!(charts.packet_rates, [20.0, 100.0]);
for _ in 1..20 {
charts.log_packets([180, 800]);
}
assert_eq!(charts.packet_rates, [20.0, 100.0]);
charts.log_packets([180, 800]);
assert_eq!(charts.packet_rates, [40.0, 175.0]);
for _ in 0..20 {
charts.log_packets([180, 800]);
}
assert_eq!(charts.packet_rates, [30.0, 131.25]);
charts.reset();
assert_eq!(charts.packet_rates, [0.0, 0.0]);
charts.log_packets([4, 8]);
assert_eq!(charts.packet_rates, [1.0, 2.0]);
}
#[test]
fn ticks_and_bandwidth_log_their_differences() {
let mut c = DebugCharts::default();
+340
View File
@@ -0,0 +1,340 @@
//! `DebugScreenOverlay`: the F3 text. The entries of the default profile
//! (`DebugScreenEntries.PROFILES`) report priority lines, regular lines and
//! groups; the overlay splits them into a left and a right column and draws
//! them last, over any screen, at the debug GUI scale.
use crate::{
font::Font,
hud_layout::{GuiDraw, PoseOp, fill},
text::Text,
};
/// `extractLines`'s background: ARGB 0x90505050.
const BACKGROUND: u32 = 0x9050_5050;
/// `extractLines`'s text colour: ARGB 0xFFE0E0E0.
const TEXT: u32 = 0xFFE0_E0E0;
/// `Font.lineHeight`.
const LINE_HEIGHT: i32 = 9;
/// What the enabled entries reported (`DebugScreenDisplayer`).
#[derive(Default, Debug)]
pub struct Displayer {
left: Vec<String>,
right: Vec<String>,
lines: Vec<String>,
groups: Vec<(&'static str, Vec<String>)>,
}
impl Displayer {
/// `addPriorityLine`: to the shorter column, the left one first.
pub fn priority(&mut self, line: impl Into<String>) {
if self.left.len() > self.right.len() {
self.right.push(line.into());
} else {
self.left.push(line.into());
}
}
/// `addLine`: split between the columns after the priority lines.
pub fn line(&mut self, line: impl Into<String>) {
self.lines.push(line.into());
}
/// `addToGroup`: lines kept together, groups in the order first added.
pub fn group(&mut self, group: &'static str, lines: impl IntoIterator<Item = String>) {
let index = match self.groups.iter().position(|(g, _)| *g == group) {
Some(i) => i,
None => {
self.groups.push((group, Vec::new()));
self.groups.len() - 1
}
};
self.groups[index].1.extend(lines);
}
/// `extractRenderState`: the left and right columns, then `footer` (the
/// chart keys, shown while the overlay is open) under the left one.
pub fn columns(self, footer: &[String]) -> (Vec<String>, Vec<String>) {
let (mut left, mut right) = (self.left, self.right);
if !left.is_empty() {
left.push(String::new());
}
if !right.is_empty() {
right.push(String::new());
}
if !self.lines.is_empty() {
let mid = self.lines.len().div_ceil(2);
left.extend_from_slice(&self.lines[..mid]);
right.extend_from_slice(&self.lines[mid..]);
left.push(String::new());
if mid < self.lines.len() {
right.push(String::new());
}
}
let mid = self.groups.len().div_ceil(2);
for (i, (_, lines)) in self.groups.into_iter().enumerate() {
if lines.is_empty() {
continue;
}
let column = if i < mid { &mut left } else { &mut right };
column.extend(lines);
column.push(String::new());
}
if !footer.is_empty() {
left.push(String::new());
left.extend_from_slice(footer);
}
(left, right)
}
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
if wrapped >= 180.0 {
wrapped - 360.0
} else if wrapped < -180.0 {
wrapped + 360.0
} else {
wrapped
}
}
/// `DebugEntryPosition` and `DebugEntrySectionPosition` for a camera at
/// `pos` looking `yaw`/`pitch` in `dimension` with `forced` force-loaded chunks.
pub fn position(
pos: [f64; 3],
yaw: f32,
pitch: f32,
dimension: &str,
forced: usize,
) -> Vec<String> {
let block = pos.map(|v| v.floor() as i32);
let (cx, sy, cz) = (block[0] >> 4, block[1] >> 4, block[2] >> 4);
// Direction.fromYRot: floor(yRot / 90 + 0.5) & 3 as a 2D data value.
let (direction, towards) = match ((f64::from(yaw) / 90.0 + 0.5).floor() as i32) & 3 {
0 => ("south", "Towards positive Z"),
1 => ("west", "Towards negative X"),
2 => ("north", "Towards negative Z"),
_ => ("east", "Towards positive X"),
};
vec![
format!("XYZ: {:.3} / {:.5} / {:.3}", pos[0], pos[1], pos[2]),
format!("Block: {} {} {}", block[0], block[1], block[2]),
format!(
"Chunk: {cx} {sy} {cz} [{} {} in r.{}.{}.mca]",
cx & 31,
cz & 31,
cx >> 5,
cz >> 5
),
format!(
"Facing: {direction} ({towards}) ({:.1} / {:.1})",
wrap_degrees(yaw),
wrap_degrees(pitch)
),
format!("{dimension} FC: {forced}"),
format!(
"Section-relative: {:02} {:02} {:02}",
block[0] & 15,
block[1] & 15,
block[2] & 15
),
]
}
/// `VideoMode.refreshRateLabel`: whole rates without decimals.
pub fn refresh_rate_label(hz: f64) -> String {
if hz == hz.round() {
format!("{}", hz as i64)
} else {
format!("{hz:.2}")
}
}
/// `DeviceInfo.vendorName` for a PCI vendor id.
pub fn vendor_name(id: u32) -> String {
match id {
0x10de => "NVIDIA Corporation".into(),
0x1002 | 0x1022 => "AMD".into(),
0x8086 => "Intel".into(),
0x106b => "Apple".into(),
0x5143 => "Qualcomm".into(),
0x13b5 => "ARM".into(),
0x10005 => "Mesa".into(),
0 => "unknown".into(),
other => format!("{other:#06x}"),
}
}
/// The overlay's scale against the GUI scale and the scaled screen size it
/// lays out in. `debugGuiScale`: -1 keeps the GUI scale, 0 (the default) is
/// half the largest automatic scale, n is `calculateScale(n)`; only a scale
/// below the GUI scale applies.
pub fn scale(
framebuffer: [i32; 2],
gui_scale: i32,
option: i32,
unicode: bool,
gui_size: [i32; 2],
) -> (f32, [i32; 2]) {
let scale = match option {
-1 => gui_scale,
0 => crate::window::calculate_scale(framebuffer, 0, unicode) / 2,
n => crate::window::calculate_scale(framebuffer, n, unicode),
};
if scale < gui_scale && scale > 0 {
(
scale as f32 / gui_scale as f32,
[framebuffer[0] / scale, framebuffer[1] / scale],
)
} else {
(1.0, gui_size)
}
}
/// `extractLines` for both columns under the overlay's pose: each line's
/// background first, then the text, right-aligned in the right column.
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
let placed: Vec<(&String, i32, i32)> = lines
.iter()
.enumerate()
.filter(|(_, l)| !l.is_empty())
.map(|(i, l)| {
let w = font.width(l) as i32;
let left = if align_left { 2 } else { width - 2 - w };
(l, left, 2 + LINE_HEIGHT * i as i32)
})
.collect();
for (line, left, top) in &placed {
let w = font.width(line) as i32;
out.push(fill(
left - 1,
top - 1,
left + w + 1,
top + LINE_HEIGHT - 1,
BACKGROUND,
));
}
for (line, left, top) in placed {
out.push(GuiDraw::Text {
text: Text::literal(line.clone()),
x: left,
y: top,
color: TEXT,
shadow: false,
});
}
}
out.push(GuiDraw::Pop);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn columns_follow_the_vanilla_split() {
// The default profile's entries, sorted by identifier as
// DebugScreenEntryList.rebuildCurrentList does: fps, game_version,
// memory, player_position, player_section_position,
// simple_performance_impactors, system_specs, tps.
let mut d = Displayer::default();
d.priority("112 fps T: 120 (fifo) @165Hz");
d.priority("Minecraft 26.3 (26.3/vanilla)");
d.group("memory", lines(&["Mem: 25% 1046/4096MiB"]));
d.group("position", lines(&["XYZ: 1 / 2 / 3", "Block: 1 2 3"]));
d.group("position", lines(&["Section-relative: 01 02 03"]));
d.line("fancy-clouds B: 2");
d.line("Filtering: RGSS");
d.line("Terrain Rendering: multidrawindirect");
d.group(
"system",
lines(&["CPU: 12x Ryzen", "Display: 854x709 (NVIDIA)"]),
);
d.line("Integrated server @ 6.6/50.0 ms, 28 tx, 492 rx");
let footer = lines(&["Debug charts: ...", "To edit: press [F3+F6]"]);
let (left, right) = d.columns(&footer);
// As the issue's vanilla screenshot (#376) shows them.
assert_eq!(
left,
lines(&[
"112 fps T: 120 (fifo) @165Hz",
"",
"fancy-clouds B: 2",
"Filtering: RGSS",
"",
"Mem: 25% 1046/4096MiB",
"",
"XYZ: 1 / 2 / 3",
"Block: 1 2 3",
"Section-relative: 01 02 03",
"",
"",
"Debug charts: ...",
"To edit: press [F3+F6]",
])
);
assert_eq!(
right,
lines(&[
"Minecraft 26.3 (26.3/vanilla)",
"",
"Terrain Rendering: multidrawindirect",
"Integrated server @ 6.6/50.0 ms, 28 tx, 492 rx",
"",
"CPU: 12x Ryzen",
"Display: 854x709 (NVIDIA)",
"",
])
);
}
#[test]
fn position_lines_match_vanilla() {
// The issue's vanilla screenshot (#376).
assert_eq!(
position([108.5, 104.0, -51.5], -1.0, 7.1, "minecraft:overworld", 0),
lines(&[
"XYZ: 108.500 / 104.00000 / -51.500",
"Block: 108 104 -52",
"Chunk: 6 6 -4 [6 28 in r.0.-1.mca]",
"Facing: south (Towards positive Z) (-1.0 / 7.1)",
"minecraft:overworld FC: 0",
"Section-relative: 12 08 12",
])
);
assert_eq!(
position([0.0, 64.0, 0.0], 450.0, 0.0, "minecraft:the_nether", 2)[3],
"Facing: west (Towards negative X) (90.0 / 0.0)"
);
assert_eq!(refresh_rate_label(165.0), "165");
assert_eq!(refresh_rate_label(59.94), "59.94");
}
#[test]
fn debug_scale_is_half_the_automatic_scale() {
// The issue's window: 854x709 at GUI scale 2 draws F3 at scale 1.
assert_eq!(
scale([854, 709], 2, 0, false, [427, 355]),
(0.5, [854, 709])
);
// 1920x1080 at GUI scale 4: auto is 4, so F3 is at 2.
assert_eq!(
scale([1920, 1080], 4, 0, false, [480, 270]),
(0.5, [960, 540])
);
// A GUI scale already at or under the debug scale is kept.
assert_eq!(
scale([1920, 1080], 2, 0, false, [960, 540]),
(1.0, [960, 540])
);
assert_eq!(
scale([1920, 1080], 3, -1, false, [640, 360]),
(1.0, [640, 360])
);
}
}
+41
View File
@@ -68,6 +68,14 @@ impl EffectInstance {
}
changed
}
/// `MobEffectInstance.tickClient`: a client counts its effects down
/// (the server removes them).
pub fn tick_client(&mut self) {
if self.infinite() || self.duration > 0 {
self.tick_down();
self.downgrade();
}
}
fn tick_down(&mut self) {
if let Some(h) = self.hidden.as_mut() {
h.tick_down();
@@ -170,6 +178,39 @@ impl PlayerEffects {
pub fn clear(&mut self) {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, _) in saved {
if let Some(def) = brewing::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
effects.add(e);
}
}
effects
}
/// `Player.active_effects` for the save.
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
self.list
.iter()
.map(|e| {
(
format!("minecraft:{}", e.id),
e.duration,
e.amplifier,
e.ambient,
e.visible,
)
})
.collect()
}
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
/// particles visible and the icon shown (always, for these effects).
pub fn flags(e: &EffectInstance) -> i8 {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick). Returns the actions to apply, in order, and
+2
View File
@@ -185,6 +185,8 @@ impl Enchantments {
.into_iter()
.filter(|(k, _)| {
k.starts_with("enchantment.")
|| k.starts_with("attribute.")
|| k.starts_with("item.modifiers.")
|| k.starts_with("effect.")
|| k.starts_with("item.minecraft.potion")
|| k.starts_with("item.minecraft.splash_potion")
+45
View File
@@ -301,6 +301,20 @@ pub struct Experience {
pub total: i32,
}
impl Experience {
/// `Player.getBaseExperienceReward`: keep-inventory and spectators drop none.
pub fn death_reward(&self, keep: bool) -> u32 {
if keep {
0
} else {
self.level.wrapping_mul(7).clamp(0, 100) as u32
}
}
/// `ServerPlayer.restoreFrom`: only keep-inventory and spectators retain XP.
pub fn respawn(&mut self, keep: bool) {
if !keep {
*self = Self::default();
}
}
pub fn needed(level: i32) -> i32 {
if level >= 30 {
112 + (level - 30) * 9
@@ -540,6 +554,37 @@ impl ArmorStand {
}
}
#[cfg(test)]
mod experience_tests {
use super::*;
#[test]
fn death_reward_uses_levels_and_caps_the_recoverable_points() {
for (level, reward) in [(0, 0), (1, 7), (14, 98), (15, 100), (30, 100)] {
let xp = Experience {
level,
progress: 0.75,
total: 10_000,
};
assert_eq!(xp.death_reward(false), reward);
assert_eq!(xp.death_reward(true), 0);
}
}
#[test]
fn respawn_clears_all_xp_unless_it_is_kept() {
let mut xp = Experience::default();
xp.give(155);
let before = (xp.level, xp.progress, xp.total);
xp.respawn(true);
assert_eq!((xp.level, xp.progress, xp.total), before);
xp.respawn(false);
assert_eq!((xp.level, xp.progress, xp.total), (0, 0.0, 0));
xp.give(7);
assert_eq!((xp.level, xp.progress, xp.total), (1, 0.0, 7));
}
}
#[cfg(test)]
mod fire_environment_tests {
use super::*;
+15 -2
View File
@@ -58,8 +58,8 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position, 1.0);
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
@@ -90,6 +90,19 @@ fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay == 4u { out.spherical = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position, uv, overlay, color, light, normal);
}
struct CloudInfo {
offset: vec4<f32>,
colors: array<vec4<f32>, 4>,
}
@group(1) @binding(0) var<uniform> cloud: CloudInfo;
@vertex fn vs_cloud(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position + cloud.offset.xyz, uv, overlay, cloud.colors[light.x], light, normal);
}
// rendertype_end_portal.fsh: the layer colours.
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
+266
View File
@@ -0,0 +1,266 @@
//! Conservative, budgeted occlusion against full opaque block cross-sections.
//!
//! A covered projection rectangle proves that an entire bounding box is hidden.
//! Testing a few rays would miss openings between those rays.
use std::cell::Cell;
const EPSILON: f64 = 1.0e-5;
pub struct Culler {
pub camera: [f64; 3],
remaining: Cell<usize>,
}
impl Culler {
pub fn new(camera: [f64; 3], budget: usize) -> Self {
Self {
camera,
remaining: Cell::new(budget),
}
}
/// Remove only this entity's completed camera-relative mesh when hidden.
pub fn cull_vertices(
&self,
vertices: &mut Vec<crate::mesher::Vertex>,
start: usize,
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if start >= vertices.len() || self.remaining.get() == 0 {
return false;
}
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for vertex in &vertices[start..] {
for a in 0..3 {
let value = f64::from(vertex.pos[a]) + self.camera[a];
if !value.is_finite() {
return false;
}
min[a] = min[a].min(value);
max[a] = max[a].max(value);
}
}
// Cover floating-point and rasterization edges as well as the vertices.
if self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque) {
vertices.truncate(start);
return true;
}
false
}
/// Returns true only when a complete opaque block plane covers the bounds.
/// Unknown blocks, invalid bounds and an exhausted lookup budget stay visible.
pub fn occluded(
&self,
min: [f64; 3],
max: [f64; 3],
mut opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if (0..3).any(|a| {
!self.camera[a].is_finite()
|| !min[a].is_finite()
|| !max[a].is_finite()
|| min[a] > max[a]
}) {
return false;
}
let mut best = (0, 0.0, 0.0);
for a in 0..3 {
let (gap, sign) = if self.camera[a] < min[a] {
(min[a] - self.camera[a], 1.0)
} else {
(self.camera[a] - max[a], -1.0)
};
if gap > best.1 {
best = (a, gap, sign);
}
}
let (axis, gap, sign) = best;
if !gap.is_finite() || gap <= EPSILON {
return false;
}
let across = [(axis + 1) % 3, (axis + 2) % 3];
let Some(mut cell) = floor_cell(self.camera[axis]) else {
return false;
};
let step = sign as i32;
if (f64::from(cell) + 0.5 - self.camera[axis]) * sign <= EPSILON {
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
loop {
let depth = (f64::from(cell) + 0.5 - self.camera[axis]) * sign;
if depth >= gap - EPSILON || self.remaining.get() == 0 {
return false;
}
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
for corner in 0..8 {
let point: [f64; 3] = std::array::from_fn(|a| {
if corner & (1 << a) == 0 {
min[a]
} else {
max[a]
}
});
let t = depth / ((point[axis] - self.camera[axis]) * sign);
for (i, &a) in across.iter().enumerate() {
let p = self.camera[a] + t * (point[a] - self.camera[a]);
if !p.is_finite() {
return false;
}
lo[i] = lo[i].min(p);
hi[i] = hi[i].max(p);
}
}
let bounds = [
floor_cell(lo[0] - EPSILON),
floor_cell(hi[0] + EPSILON),
floor_cell(lo[1] - EPSILON),
floor_cell(hi[1] + EPSILON),
];
let [Some(x0), Some(x1), Some(y0), Some(y1)] = bounds else {
return false;
};
let mut pos = [0; 3];
pos[axis] = cell;
pos[across[0]] = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
pos[across[1]] = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
self.remaining.set(self.remaining.get() - 1);
if opaque(pos) {
let area = (i64::from(x1) - i64::from(x0) + 1)
.checked_mul(i64::from(y1) - i64::from(y0) + 1)
.and_then(|n| usize::try_from(n).ok());
let Some(area) = area.filter(|&n| n <= self.remaining.get()) else {
return false;
};
// Reserve the worst-case lookups before walking the rectangle.
self.remaining.set(self.remaining.get() - area);
let covered = (x0..=x1).all(|x| {
(y0..=y1).all(|y| {
pos[across[0]] = x;
pos[across[1]] = y;
opaque(pos)
})
});
if covered {
return true;
}
}
let Some(next) = cell.checked_add(step) else {
return false;
};
cell = next;
}
}
}
fn floor_cell(value: f64) -> Option<i32> {
let n = value.floor();
(n.is_finite() && n >= f64::from(i32::MIN) && n <= f64::from(i32::MAX)).then_some(n as i32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completed_mesh_culling_preserves_prior_geometry_and_protruding_parts() {
let vertex = |pos| crate::mesher::Vertex {
pos,
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
};
let prior = vertex([0.0; 3]);
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
let wall = |p: [i32; 3]| p[0] == 4 && (-1..=0).contains(&p[1]);
let mut hidden = vec![prior];
hidden.extend(body);
assert!(Culler::new([0.0; 3], 1_000).cull_vertices(&mut hidden, 1, wall));
assert_eq!(hidden.len(), 1);
assert_eq!(hidden[0].pos, prior.pos);
// A held item or animated part beyond the wall keeps the whole model.
let mut exposed = vec![prior];
exposed.extend(body);
exposed.push(vertex([8.0, 5.0, 0.0]));
let before = exposed.clone();
assert!(!Culler::new([0.0; 3], 1_000).cull_vertices(&mut exposed, 1, wall));
assert_eq!(
bytemuck::cast_slice::<_, u8>(&exposed),
bytemuck::cast_slice::<_, u8>(&before)
);
assert!(!Culler::new([0.0; 3], 0).cull_vertices(&mut exposed, 1, |_| panic!()));
}
#[test]
fn opaque_walls_hide_bounds_in_all_directions() {
for axis in 0..3 {
for sign in [-1.0, 1.0] {
let mut min = [-2.0; 3];
let mut max = [2.0; 3];
min[axis] = if sign > 0.0 { 8.0 } else { -10.0 };
max[axis] = if sign > 0.0 { 10.0 } else { -8.0 };
let wall = if sign > 0.0 { 4 } else { -5 };
assert!(Culler::new([0.0; 3], 1_000).occluded(min, max, |p| p[axis] == wall));
}
}
}
#[test]
fn windows_slits_and_partial_walls_stay_visible() {
let min = [8.0, -4.0, -4.0];
let max = [10.0, 4.0, 4.0];
for opening in 0..3 {
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |p| {
p[0] == 4
&& match opening {
0 => !(p[1] == 1 && p[2] == 1),
1 => p[1] != 1,
_ => p[1] < 0,
}
}));
}
assert!(!Culler::new([0.0; 3], 1_000).occluded(min, max, |_| false));
}
#[test]
fn budgets_are_shared_and_exhaustion_fails_open() {
let culler = Culler::new([0.0; 3], 3);
let calls = Cell::new(0);
for _ in 0..2 {
assert!(!culler.occluded([8.0; 3], [10.0; 3], |_| {
calls.set(calls.get() + 1);
false
}));
}
assert_eq!(calls.get(), 3);
assert!(!Culler::new([0.0; 3], 0).occluded([8.0; 3], [10.0; 3], |_| panic!()));
assert!(!Culler::new([0.0; 3], 1).occluded([8.0; 3], [10.0; 3], |_| true));
}
#[test]
fn camera_inside_invalid_and_target_crossing_wall_stay_visible() {
let culler = Culler::new([0.0; 3], 1_000);
assert!(!culler.occluded([-1.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([f64::NAN; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([2.0; 3], [1.0; 3], |_| panic!()));
assert!(!culler.occluded([4.0, -1.0, -1.0], [8.0, 1.0, 1.0], |p| p[0] == 4));
assert!(!Culler::new([f64::INFINITY; 3], 1_000).occluded(
[8.0; 3],
[10.0; 3],
|_| panic!()
));
assert!(!Culler::new([f64::MAX; 3], 1_000).occluded(
[-f64::MAX; 3],
[-1.0; 3],
|_| panic!()
));
}
}
+44 -8
View File
@@ -435,8 +435,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
});
}
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
for (key, value) in [
("IsBaby", Tag::Byte(0)),
("CanBreakDoors", Tag::Byte(0)),
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
@@ -845,6 +845,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.health = health;
m.extra.flag2 = flag(tag, "wasOnGround");
}
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
// zombies of trial spawners).
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
crate::mobs::finalize::set_baby(&mut m, true);
}
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -1041,12 +1046,15 @@ pub fn arrow_from_nbt(
}
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
#[allow(clippy::too_many_arguments)]
pub fn falling_block_to_nbt(
state: Tag,
position: Vec3,
motion: Vec3,
time: i32,
drop_item: bool,
damage: crate::falling_block::Damage,
fall_distance: f64,
uuid: [i32; 4],
) -> Tag {
let base = Base {
@@ -1056,29 +1064,27 @@ pub fn falling_block_to_nbt(
motion,
rotation: [0.0, 0.0],
on_ground: false,
fall_distance: 0.0,
fall_distance,
};
let mut tag = base.tag(None);
for (key, value) in [
("BlockState", state),
("Time", Tag::Int(time)),
("DropItem", Tag::Byte(i8::from(drop_item))),
("HurtEntities", Tag::Byte(0)),
("CancelDrop", Tag::Byte(0)),
("FallHurtAmount", Tag::Float(0)),
("FallHurtMax", Tag::Int(40)),
] {
put(&mut tag, key, value);
}
damage.save(&mut tag);
tag
}
/// (block state tag, position, motion, time)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
/// (block state tag, position, motion, time, fall distance)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32, f64)> {
Some((
tag.get("BlockState")?.clone(),
position(tag)?,
motion(tag),
number(tag.get("Time")).unwrap_or(0) as i32,
float(tag.get("fall_distance")).unwrap_or(0.0),
))
}
@@ -1488,6 +1494,36 @@ mod tests {
use super::*;
use crate::items::ItemStack;
#[test]
fn falling_block_round_trip_keeps_impact_distance_and_settings() {
let state = Tag::compound([("Name", string("minecraft:damaged_anvil"))]);
let position = Vec3::new(3.5, 100.0, -7.5);
let velocity = Vec3::new(0.0, -1.25, 0.0);
let damage = crate::falling_block::Damage {
cancel_drop: true,
..crate::falling_block::Damage::anvil()
};
let tag = falling_block_to_nbt(
state.clone(),
position,
velocity,
30,
false,
damage,
19.75,
[1, 2, 3, 4],
);
let (loaded_state, pos, motion, age, distance) = falling_block_from_nbt(&tag).unwrap();
assert_eq!(loaded_state, state);
assert_eq!(
(pos, motion, age, distance),
(position, velocity, 30, 19.75)
);
assert_eq!(falling_block_flags(&tag), (false, false));
assert_eq!(crate::falling_block::Damage::from_nbt(&tag, true), damage);
assert_eq!(uuid(&tag), Some([1, 2, 3, 4]));
}
#[test]
fn item_entities_round_trip() {
let e = ItemEntity {
+95 -4
View File
@@ -7,6 +7,8 @@ use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the zoom key waits for a key.
pub const ZOOM_KEY: &str = "horizon.zoom";
/// The same for the sort key.
pub const SORT_KEY: &str = "horizon.sort";
/// Zoom levels the default cycles through, and the wheel's limits.
pub const ZOOM_LEVELS: [u8; 6] = [2, 3, 4, 6, 8, 10];
pub const ZOOM_MIN: f32 = 1.25;
@@ -66,6 +68,7 @@ impl Corner {
#[derive(Clone, Debug, PartialEq)]
pub struct Extras {
pub fullbright: bool,
pub zoom: bool,
/// A vanilla key name (`key.keyboard.z`).
pub zoom_key: String,
@@ -94,10 +97,19 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
pub mouse_tweaks_wheel: bool,
/// Sort buttons on the inventory and chest-like containers.
pub sort: bool,
/// A vanilla key name: sorts the part under the cursor.
pub sort_key: String,
}
impl Default for Extras {
fn default() -> Self {
Self {
fullbright: false,
zoom: false,
zoom_key: "key.keyboard.z".into(),
zoom_level: 4,
@@ -116,6 +128,10 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
sort_key: "key.keyboard.unknown".into(),
}
}
}
@@ -141,6 +157,7 @@ impl Extras {
_ => d,
};
match key.trim() {
"fullbright" => e.fullbright = flag(e.fullbright),
"zoom" => e.zoom = flag(e.zoom),
"zoomKey" if value.starts_with("key.") => e.zoom_key = value.to_owned(),
"zoomLevel" => {
@@ -173,6 +190,10 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
_ => {}
}
}
@@ -180,7 +201,8 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"zoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\n",
"fullbright:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
bool_text(self.fullbright),
bool_text(self.zoom),
self.zoom_key,
self.zoom_level,
@@ -203,8 +225,22 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
self.sort_key,
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
ZOOM_KEY => self.zoom_key = value,
SORT_KEY => self.sort_key = value,
_ => return false,
}
true
}
/// Written only once something differs from the defaults, so a vanilla
/// setup gains no file.
pub fn save(&self, dir: &Path) {
@@ -386,6 +422,7 @@ impl Cps {
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Item {
Fullbright,
Zoom,
ZoomKey,
ZoomLevel,
@@ -404,11 +441,17 @@ pub enum Item {
MinimapZoom,
MinimapRotate,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
Sort,
SortKey,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Ok(Fullbright),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
@@ -430,6 +473,11 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(Keystrokes),
Ok(KeystrokesCorner),
Ok(KeystrokesCps),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Multiplayer"),
Ok(AutoReconnect),
]
@@ -452,6 +500,7 @@ fn next<T: Copy + PartialEq>(all: &[T], current: T, back: bool) -> T {
impl Item {
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
Item::Zoom => "Zoom",
Item::ZoomKey => "Zoom key",
Item::ZoomLevel => "Starting zoom",
@@ -465,6 +514,10 @@ impl Item {
Item::MinimapZoom => "Zoom",
Item::MinimapRotate => "Turn with the player",
Item::AutoReconnect => "Auto-reconnect",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
Item::SortKey => "Sort key",
Item::FpsLows => "Minimum and 1% low",
Item::Keystrokes => "Keystrokes",
Item::KeystrokesCps => "Clicks per second",
@@ -473,6 +526,7 @@ impl Item {
/// What the row says under its caption.
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
Item::Zoom => "Hold the zoom key to zoom; the wheel changes it while held.",
Item::ZoomKey => "Click, then press a key. Escape unbinds it.",
Item::ZoomLevel => "The zoom the key starts at.",
@@ -491,6 +545,18 @@ impl Item {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
Item::FpsLows => "The slowest frame and the 1% low of the last 5 seconds.",
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
Item::MouseTweaksWheel => {
"Down sends one item of the slot to the other side, up brings one back."
}
Item::Sort => {
"Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks."
}
Item::SortKey => {
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
}
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
}
@@ -499,15 +565,16 @@ impl Item {
pub fn cost(self) -> Option<crate::graphics_style::Cost> {
use crate::graphics_style::Cost;
match self {
Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Fullbright | Item::Zoom | Item::Fps | Item::Keystrokes => Some(Cost::None),
Item::Minimap => Some(Cost::Low),
Item::AutoReconnect => Some(Cost::None),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
}
}
/// On/off items and their value.
pub fn switch(self, e: &Extras) -> Option<bool> {
Some(match self {
Item::Fullbright => e.fullbright,
Item::Zoom => e.zoom,
Item::ZoomSmooth => e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity,
@@ -518,6 +585,9 @@ impl Item {
Item::Minimap => e.minimap,
Item::MinimapRotate => e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
_ => return None,
})
}
@@ -525,6 +595,7 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::ZoomKey => crate::keybinds::display_name(&e.zoom_key).plain(),
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::ZoomLevel => format!("{}x", e.zoom_level),
Item::FpsCorner => e.fps_corner.label().into(),
Item::KeystrokesCorner => e.keystrokes_corner.label().into(),
@@ -545,6 +616,7 @@ impl Item {
/// A click (`back` for the right button). The zoom key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::Zoom => e.zoom = !e.zoom,
Item::ZoomSmooth => e.zoom_smooth = !e.zoom_smooth,
Item::ZoomSensitivity => e.zoom_sensitivity = !e.zoom_sensitivity,
@@ -560,6 +632,9 @@ impl Item {
Item::Minimap => e.minimap = !e.minimap,
Item::MinimapRotate => e.minimap_rotate = !e.minimap_rotate,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
Item::MinimapCorner => e.minimap_corner = next(&Corner::ALL, e.minimap_corner, back),
Item::MinimapSize => {
let sizes: Vec<u8> = MINIMAP_SIZES.iter().map(|s| s.0).collect();
@@ -567,7 +642,15 @@ impl Item {
}
Item::MinimapShape => e.minimap_round = !e.minimap_round,
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::ZoomKey => {}
Item::ZoomKey | Item::SortKey => {}
}
}
/// The key name the options screen waits on for a key item.
pub fn key_name(self) -> Option<&'static str> {
match self {
Item::ZoomKey => Some(ZOOM_KEY),
Item::SortKey => Some(SORT_KEY),
_ => None,
}
}
}
@@ -579,10 +662,14 @@ mod tests {
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
assert!(!d.fullbright);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
Item::Fullbright.cycle(&mut e, false);
assert_eq!(Item::Fullbright.switch(&e), Some(true));
e.zoom = true;
e.zoom_key = "key.keyboard.c".into();
e.zoom_level = 6;
@@ -596,6 +683,10 @@ mod tests {
e.minimap_rotate = true;
e.minimap_corner = Corner::BottomLeft;
e.auto_reconnect = true;
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
e.sort_key = "key.keyboard.r".into();
assert_eq!(Extras::parse(&e.serialize()), e);
}
+26
View File
@@ -129,6 +129,32 @@ pub fn draw_fps(
);
}
/// A box of text lines (a mod's HUD panel): `(text, 0xRRGGBB)` each.
pub fn draw_lines(
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
corner: Corner,
lines: &[(&str, u32)],
) {
if lines.is_empty() {
return;
}
let w = lines
.iter()
.map(|(text, _)| HudBatch::text_width(atlas, text))
.fold(0.0, f32::max)
+ 4.0;
let h = 10.0 * lines.len() as f32 + 2.0;
let (x, y) = corners.place(corner, w, h);
batch.fill(atlas, x, y, w, h, BACKGROUND);
for (i, (text, color)) in lines.iter().enumerate() {
let [_, r, g, b] = color.to_be_bytes();
let y = y + 2.0 + 10.0 * i as f32;
batch.text(atlas, text, x + 2.0, y, rgba(r, g, b, 255), true);
}
}
/// What the keystroke overlay shows.
pub struct Keystrokes {
/// Forward, left, back, right: held, and the bound key's name.
+192
View File
@@ -0,0 +1,192 @@
//! FallingBlockEntity's impact damage, anvil wear and saved impact settings.
use crate::{
mobs::{Damage as MobDamage, DamageKind, Mob},
save::{float, number, put},
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::{chunk::BlockState, nbt::Tag};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Damage {
pub hurt_entities: bool,
pub cancel_drop: bool,
pub per_distance: f32,
pub max: i32,
}
impl Default for Damage {
fn default() -> Self {
Self {
hurt_entities: false,
cancel_drop: false,
per_distance: 0.0,
max: 40,
}
}
}
impl Damage {
pub fn anvil() -> Self {
Self {
hurt_entities: true,
per_distance: 2.0,
..Self::default()
}
}
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
Self {
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
cancel_drop: number(tag.get("CancelDrop")).is_some_and(|v| v != 0),
per_distance: float(tag.get("FallHurtAmount")).unwrap_or(0.0) as f32,
max: number(tag.get("FallHurtMax")).unwrap_or(40) as i32,
}
}
pub fn save(self, tag: &mut Tag) {
for (key, value) in [
("HurtEntities", Tag::Byte(i8::from(self.hurt_entities))),
("CancelDrop", Tag::Byte(i8::from(self.cancel_drop))),
("FallHurtAmount", Tag::Float(self.per_distance.to_bits())),
("FallHurtMax", Tag::Int(self.max)),
] {
put(tag, key, value);
}
}
pub fn impact(self, fall_distance: f64) -> Option<Impact> {
if !self.hurt_entities {
return None;
}
let steps = (fall_distance - 1.0).ceil() as i32;
if steps < 0 {
return None;
}
let amount = ((steps as f32 * self.per_distance).floor() as i32).min(self.max) as f32;
Some(Impact {
amount,
wear_chance: if amount > 0.0 {
0.05 + steps as f32 * 0.05
} else {
0.0
},
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct Impact {
pub amount: f32,
pub wear_chance: f32,
}
/// AnvilBlock.damage: advance wear without changing facing; false means broken.
pub fn wear_anvil(state: &mut BlockState) -> bool {
state.name = match state.name.as_str() {
"minecraft:anvil" => "minecraft:chipped_anvil",
"minecraft:chipped_anvil" => "minecraft:damaged_anvil",
_ => return false,
}
.into();
true
}
pub fn bounds(position: Vec3) -> Aabb {
let size = f64::from(0.98_f32);
Aabb::new(
Vec3::new(position.x - size / 2.0, position.y, position.z - size / 2.0),
Vec3::new(
position.x + size / 2.0,
position.y + size,
position.z + size / 2.0,
),
)
}
pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
if amount <= 0.0 {
return;
}
let bounds = bounds(position);
let source = MobDamage {
kind: DamageKind::FallingBlock,
attacker: None,
from: Some([position.x, position.y, position.z]),
};
for mob in mobs
.iter_mut()
.filter(|m| m.alive() && bounds.intersects(m.bounds()))
{
mob.damage(amount, source);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
let damage = Damage::anvil();
assert!(Damage::default().impact(50.0).is_none());
assert!(damage.impact(0.0).is_none());
let short = damage.impact(1.0).unwrap();
assert_eq!((short.amount, short.wear_chance), (0.0, 0.0));
assert_eq!(damage.impact(1.01).unwrap().amount, 2.0);
assert_eq!(damage.impact(5.0).unwrap().amount, 8.0);
assert_eq!(damage.impact(100.0).unwrap().amount, 40.0);
assert!(damage.impact(21.0).unwrap().wear_chance > 1.0);
}
#[test]
fn damage_settings_round_trip_and_keep_vanilla_load_defaults() {
let empty = Tag::Compound(Default::default());
assert_eq!(Damage::from_nbt(&empty, false), Damage::default());
let loaded_anvil = Damage::from_nbt(&empty, true);
assert!(loaded_anvil.hurt_entities);
assert_eq!(loaded_anvil.per_distance, 0.0);
let damage = Damage {
hurt_entities: true,
cancel_drop: true,
per_distance: 3.5,
max: 17,
};
let mut tag = empty;
damage.save(&mut tag);
assert_eq!(Damage::from_nbt(&tag, false), damage);
}
#[test]
fn anvil_wears_twice_then_breaks_preserving_facing() {
for facing in ["north", "south", "east", "west"] {
let mut state = BlockState {
name: "minecraft:anvil".into(),
properties: [("facing".into(), facing.into())].into(),
};
for name in ["minecraft:chipped_anvil", "minecraft:damaged_anvil"] {
assert!(wear_anvil(&mut state));
assert_eq!(state.name, name);
assert_eq!(state.properties["facing"], facing);
}
assert!(!wear_anvil(&mut state));
}
}
#[test]
#[ignore = "requires prepared entity models/data in HORIZON_TEST_RESOURCES"]
fn impact_hurts_only_living_intersecting_noninvulnerable_mobs() {
use crate::mobs::{Mob, MobTypes};
use std::{path::Path, sync::Arc};
let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES");
let types = MobTypes::load(Path::new(&root)).unwrap();
let cow = Arc::new(types.types["minecraft:cow"].clone());
let mut mobs = vec![
Mob::new(1, cow.clone(), Vec3::ZERO, 0.0),
Mob::new(2, cow.clone(), Vec3::new(3.0, 0.0, 0.0), 0.0),
Mob::new(3, cow.clone(), Vec3::ZERO, 0.0),
Mob::new(4, cow, Vec3::ZERO, 0.0),
];
mobs[2].invulnerable_flag = true;
mobs[3].extra.head = Some("minecraft:carved_pumpkin".into());
let initial = mobs.iter().map(|m| m.health).collect::<Vec<_>>();
hurt_mobs(
&mut mobs,
Vec3::ZERO,
Damage::anvil().impact(5.0).unwrap().amount,
);
assert_eq!(mobs[0].health, initial[0] - 8.0);
assert_eq!(mobs[1].health, initial[1]);
assert_eq!(mobs[2].health, initial[2]);
assert_eq!(mobs[3].health, initial[3] - 6.0);
}
}
+52 -1
View File
@@ -31,7 +31,9 @@ pub enum ItemPose {
},
}
/// `submitArmWithItem`: the main-hand item pose, before the model transform.
/// `submitArmWithItem`: the pose of an item in the hand on the `left` or
/// right arm, before the model transform. Only the main hand's charged
/// crossbow is turned aside.
pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -> glam::Mat4 {
use glam::{Mat4, Vec3};
use horizon_core::geometry::trig::Mth;
@@ -127,6 +129,25 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
m
}
/// `FirstPersonHandsAndItems.evaluateWhichHandsToRender`: whether the main
/// and the off hand show, from what each holds (item and whether it is a
/// charged crossbow) and the item in use with its hand. A bow or crossbow
/// being drawn hides the other hand, and a charged crossbow in the main hand
/// hides the off hand unless the off hand is the one in use.
pub fn hands_to_render(held: [Option<(&str, bool)>; 2], using: Option<(Hand, &str)>) -> [bool; 2] {
let bow_like = |id: &str| matches!(id, "minecraft:bow" | "minecraft:crossbow");
let charged_crossbow =
|h: Option<(&str, bool)>| h.is_some_and(|(id, c)| c && id == "minecraft:crossbow");
if !held.iter().flatten().any(|&(id, _)| bow_like(id)) {
return [true, true];
}
match using {
None => [true, !charged_crossbow(held[0])],
Some((hand, id)) if bow_like(id) => [hand == Hand::Main, hand == Hand::Off],
Some((hand, _)) => [true, !(hand == Hand::Main && charged_crossbow(held[1]))],
}
}
/// `InteractionHand`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hand {
@@ -719,6 +740,36 @@ impl Clicks {
mod tests {
use super::*;
#[test]
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
let (bow, apple) = (
Some(("minecraft:bow", false)),
Some(("minecraft:apple", false)),
);
let charged = Some(("minecraft:crossbow", true));
assert_eq!(hands_to_render([apple, apple], None), [true, true]);
assert_eq!(hands_to_render([bow, apple], None), [true, true]);
assert_eq!(hands_to_render([charged, apple], None), [true, false]);
assert_eq!(hands_to_render([apple, charged], None), [true, true]);
let drawing = |hand| Some((hand, "minecraft:bow"));
assert_eq!(
hands_to_render([bow, apple], drawing(Hand::Main)),
[true, false]
);
assert_eq!(
hands_to_render([apple, bow], drawing(Hand::Off)),
[false, true]
);
let eating = |hand| Some((hand, "minecraft:apple"));
assert_eq!(
hands_to_render([apple, charged], eating(Hand::Main)),
[true, false]
);
assert_eq!(
hands_to_render([charged, apple], eating(Hand::Off)),
[true, true]
);
}
#[test]
fn a_swing_lasts_its_duration_and_restarts_after_half() {
let mut s = SwingState::default();
assert!(s.start_if_able(Hand::Main, SwingAnimation::DEFAULT, 6));
-16
View File
@@ -119,22 +119,6 @@ impl FogState {
}
}
/// The fog tables and evolving state kept by the game.
pub struct FogRenderer {
pub tables: FogTables,
pub state: FogState,
}
impl FogRenderer {
pub fn load(root: &Path, biomes: &Biomes) -> Self {
Self {
tables: FogTables::load(root, biomes),
state: FogState::default(),
}
}
pub fn setup(&self, input: &FogInputs) -> FogData {
setup_fog(&self.tables, &self.state, input)
}
}
/// `FluidState.getHeightForCamera`.
fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option<(Fluid, f64)> {
let s = facts.get(world.block(b[0], b[1], b[2]));
+119 -6
View File
@@ -318,6 +318,7 @@ impl Title {
x: f32,
y: f32,
) -> Action {
self.remember_direct_address();
if self.skip_opening() {
return Action::None;
}
@@ -441,7 +442,9 @@ impl Title {
Screen::Direct { .. } => 53,
Screen::Friend { .. } => 57,
Screen::Disconnected { .. } => 54,
Screen::ConfirmDelete { .. } => 59,
}),
CONFIRM_DELETE => Some(58),
CANCEL_RETRY => Some(55),
DONE => Some(match screen {
Screen::Direct { .. } => 52,
@@ -470,7 +473,7 @@ impl Title {
_ => None,
};
if let Some(id) = id {
match screen.click(Some(id), 0.0, 0.0, (0.0, 0.0), &saves) {
match self.multiplayer_click(Some(id), 0.0, 0.0, (0.0, 0.0)) {
Outcome::Back => self.mode = Mode::Main,
Outcome::Connect(address) => return Action::Connect { address },
Outcome::None => {}
@@ -840,8 +843,14 @@ fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
Some(Ping::Ok {
motd, players, ms, ..
}) => (
motd.lines().next().unwrap_or("").trim().to_owned(),
format!("{players} · {ms} ms"),
motd.resolve_legacy()
.plain()
.lines()
.next()
.unwrap_or("")
.trim()
.to_owned(),
format!("{} · {ms} ms", Ping::player_count(*players).plain()),
color::ACCENT,
),
Some(Ping::Failed(_)) => (
@@ -1035,6 +1044,40 @@ fn multiplayer_page(ui: &mut Ui, x: f32, w: f32, screen: &Screen, caret: bool) {
ui.text(Face::Regular, SMALL, x, ry + 12.0, color::MUTED, &note);
}
}
Screen::ConfirmDelete { name, .. } => {
let mut y = header(
ui,
x,
"Remove this server?",
"It leaves your list. You can add it again later.",
);
ui.divider(x, y, w);
y += 16.0;
y += notice(
ui,
x,
y,
w,
Some(name),
"Its address and icon will be forgotten.",
) + 16.0;
let dw = ui.button_width("Delete server", false);
ui.button(
CONFIRM_DELETE,
[x, y, dw, CONTROL],
"Delete server",
Kind::Danger,
true,
);
let cw = ui.button_width("Cancel", false);
ui.button(
BACK,
[x + dw + 8.0, y, cw, CONTROL],
"Cancel",
Kind::Ghost,
true,
);
}
Screen::Disconnected { reason, retry } => {
let mut y = header(
ui,
@@ -1130,6 +1173,7 @@ pub mod pause {
quit_label: &str,
opened: Instant,
friends: &FriendsView,
remote: bool,
) {
let (w, h) = (ui.w, ui.h);
let p = t::ease_out_quint(opened.elapsed().as_secs_f32() / t::move_());
@@ -1164,11 +1208,11 @@ pub mod pause {
Kind::Primary,
true,
);
let lw = ui.button_width("Open to LAN", true);
let lw = ui.button_width("World Options", true);
ui.button(
act(9),
[x + bw + 8.0, y, lw, CONTROL_LARGE],
"Open to LAN",
"World Options",
Kind::Secondary,
true,
);
@@ -1192,7 +1236,11 @@ pub mod pause {
x,
y,
"Leave",
"Saves the world, then returns to the title.",
if remote {
"Disconnects from the server, then returns to the title."
} else {
"Saves the world, then returns to the title."
},
cw - t::ROW_LABEL - 220.0,
);
let qw = ui.button_width(quit_label, false);
@@ -1320,6 +1368,7 @@ pub mod pause {
world: &str,
quit_label: &str,
friends: &FriendsView,
remote: bool,
) -> Option<u8> {
let mut ui = Ui::dry(atlas, batch, (x, y));
draw(
@@ -1328,6 +1377,7 @@ pub mod pause {
quit_label,
Instant::now() - std::time::Duration::from_secs(5),
friends,
remote,
);
let hit = ui.hit((x, y))?;
let nav = id("pause.nav", 0);
@@ -1348,6 +1398,47 @@ mod tests {
use super::*;
use horizon_world::nbt::Tag;
#[test]
fn pause_world_options_and_disconnect_are_clickable_on_servers() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let friends = FriendsView::default();
for remote in [false, true] {
let quit = if remote {
"Disconnect"
} else {
"Save and quit"
};
for action in [9u8, 2] {
let mut batch = HudBatch::new([1280, 720]);
let (rect, density) = {
let mut ui = Ui::dry(&atlas, &mut batch, (0.0, 0.0));
pause::draw(
&mut ui,
"World",
quit,
Instant::now() - std::time::Duration::from_secs(5),
&friends,
remote,
);
let hit = ui
.hits
.iter()
.find(|h| h.id == id("pause", u64::from(action)))
.expect("pause action available");
(hit.rect, ui.density)
};
let k = density / batch.scale;
let x = (rect[0] + rect[2]) / 2.0 * k;
let y = (rect[1] + rect[3]) / 2.0 * k;
assert_eq!(
pause::click(&atlas, &mut batch, x, y, "World", quit, &friends, remote),
Some(action)
);
}
}
}
fn world(saves: &std::path::Path, folder: &str, last_played: i64) {
crate::save::write_dat(
&saves.join(folder).join("level.dat"),
@@ -1390,6 +1481,28 @@ mod tests {
}
}
#[test]
fn horizon_direct_connection_remembers_the_address_after_leaving() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let saves =
std::env::temp_dir().join(format!("horizon-front-direct-{}", std::process::id()));
let mut title = Title::new(saves.clone(), &saves);
title.last_server = "localhost:25566".into();
title.mode = Mode::Multiplayer(Screen::list(&saves));
press(&mut title, &atlas, DIRECT);
assert_eq!(title.focused_field().unwrap().1, "localhost:25566");
*title.focused_field().unwrap().1 = "example.org:25567".into();
press(&mut title, &atlas, BACK);
press(&mut title, &atlas, DIRECT);
assert_eq!(title.focused_field().unwrap().1, "example.org:25567");
*title.focused_field().unwrap().1 = "localhost:25568".into();
press(&mut title, &atlas, NAV);
press(&mut title, &atlas, NAV + 2);
press(&mut title, &atlas, DIRECT);
assert_eq!(title.focused_field().unwrap().1, "localhost:25568");
}
#[test]
fn horizon_world_delete_confirms_and_keeps_the_selection_in_place() {
let atlas =
File diff suppressed because it is too large. Load diff
+173 -100
View File
@@ -7,10 +7,11 @@ use horizon_client::{
hud::HudBatch,
items::ItemStack,
text::{self, Style, Text},
toasts::Slide,
};
use serde_json::Value;
use std::{
collections::{BTreeMap, VecDeque},
collections::{BTreeMap, BTreeSet, VecDeque},
sync::Arc,
time::Instant,
};
@@ -68,61 +69,25 @@ impl Toast {
(fully_for as f64 / (5000.0 * multiplier / n).max(1.0) % n) as usize
}
}
/// `ToastManager.ToastInstance`: slides a toast in and out over 600 ms,
/// eased by the square, and counts how long it has been fully shown.
#[derive(Default)]
struct Slide {
/// When it first updated; the times below are milliseconds since.
origin: Option<Instant>,
begun: bool,
/// `animationStartTime`.
start: i64,
/// `becameFullyVisibleAt`.
fully_at: i64,
show: bool,
/// `visiblePortion`: 0 off screen, 1 fully in.
portion: f32,
/// `fullyVisibleFor`.
fully_for: i64,
}
impl Slide {
const MS: i64 = 600;
/// `ToastInstance.update` at `now`; `wants_show` is the toast's own
/// update. The visibility it switched to (the sound to play), and whether
/// it has finished sliding out.
fn update(&mut self, now: i64, wants_show: impl FnOnce(i64) -> bool) -> (Option<bool>, bool) {
let was = self.show;
if !self.begun {
self.begun = true;
self.start = now;
self.show = true;
}
if self.show && now - self.start <= Self::MS {
self.fully_at = now;
}
self.fully_for = now - self.fully_at;
let progress = ((now - self.start) as f32 / Self::MS as f32).clamp(0.0, 1.0);
let progress = progress * progress;
self.portion = if self.show { progress } else { 1.0 - progress };
let wanted = wants_show(self.fully_for);
if wanted != self.show {
self.start = now - ((1.0 - self.portion) * Self::MS as f32) as i64;
self.show = wanted;
}
let finished = !self.show && now - self.start > Self::MS;
((self.show != was).then_some(self.show), finished)
}
}
#[derive(Default)]
pub(super) struct AdvState {
pub(super) tree: Arc<Tree>,
/// `ClientAdvancements`: a server's tree carries each advancement's
/// place on its tab (`DisplayInfo` x, y), in tree order.
pub(super) positions: Option<Vec<(f32, f32)>>,
pub(super) progress: Progress,
pub(super) loaded: bool,
pub(super) toasts: VecDeque<Toast>,
/// Awards skip the toast (`/advancement grant ... everything`).
pub(super) quiet: bool,
/// The inventory last seen by `inventory_changed`.
last_inventory: u64,
pub(super) last_inventory: u64,
/// While the game acts for a network player (`as_guest`): the
/// advancements whose progress changed (`progressChanged`), the recipes
/// unlocked and the announcements, for their client and everyone's.
pub(super) changed: BTreeSet<usize>,
pub(super) unlocked: Vec<String>,
pub(super) announced: Vec<Text>,
}
/// The game's world for predicates.
struct View<'a, 'g>(&'a Game<'g>);
@@ -153,7 +118,7 @@ impl adv::World for View<'_, '_> {
let name = self.0.res.facts.get(state).name.to_string();
let props = self
.0
.res
.pack
.assets
.registry
.state(state)
@@ -215,29 +180,10 @@ impl adv::World for View<'_, '_> {
}
}
fn now_text() -> String {
// "yyyy-MM-dd HH:mm:ss Z" in UTC.
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64;
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
// Civil from days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} +0000",
rem / 3600,
rem % 3600 / 60,
rem % 60
)
adv::date_text(secs)
}
impl Game<'_> {
/// `ItemPredicate.test` (the `item_filter` of a filtered item modifier).
@@ -261,30 +207,30 @@ pub(super) fn item_view(s: &ItemStack) -> ItemView {
}
impl Game<'_> {
fn adv_path(&self) -> Option<std::path::PathBuf> {
let uuid = self.level.player_uuid?;
let hex: String = uuid.iter().map(|v| format!("{:08x}", *v as u32)).collect();
let text = format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
);
Some(
self.world_root
.join("players/advancements")
.join(format!("{text}.json")),
)
Some(super::stats::player_json(
&self.world_root,
"advancements",
self.level.player_uuid?,
))
}
pub(super) fn load_advancements(&mut self) {
if self.adv.loaded || self.remote.is_some() {
return;
}
self.adv.loaded = true;
/// A server with no player of its own and nobody it acts for: there is
/// no player for advancements and statistics.
pub(super) fn no_player_here(&self) -> bool {
self.options.dedicated.is_some() && self.lan_acting.is_none()
}
/// The advancement tree, loaded once.
pub(super) fn advancement_tree(&mut self) -> Arc<Tree> {
if self.adv.tree.list.is_empty() {
self.adv.tree = Arc::new(Tree::load(&self.options.resources.join("data")));
}
self.adv.tree.clone()
}
pub(super) fn load_advancements(&mut self) {
if self.adv.loaded || self.remote.is_some() || self.no_player_here() {
return;
}
self.adv.loaded = true;
self.advancement_tree();
let progress = self
.adv_path()
.and_then(|p| std::fs::read_to_string(p).ok())
@@ -298,7 +244,7 @@ impl Game<'_> {
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
}
pub(super) fn save_advancements(&self) {
if !self.adv.loaded || self.remote.is_some() {
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
return;
}
let Some(path) = self.adv_path() else {
@@ -420,7 +366,7 @@ impl Game<'_> {
/// `SimpleCriterionTrigger.trigger`: test each open criterion of the
/// trigger and award what it completes.
pub(super) fn trigger(&mut self, name: &str, test: impl Fn(&Value, &dyn adv::World) -> bool) {
if self.remote.is_some() {
if self.remote.is_some() || self.no_player_here() {
return;
}
self.load_advancements();
@@ -447,15 +393,27 @@ impl Game<'_> {
let a = &tree.list[ai];
let was = self.adv.progress.done(a);
self.adv.progress.grant(&a.id, &a.criteria[ci].name, &date);
self.progress_changed(ai);
if !was && self.adv.progress.done(a) {
self.award(ai);
}
}
}
/// `PlayerAdvancements.progressChanged`, kept for a network player's client.
fn progress_changed(&mut self, index: usize) {
if self.lan_acting.is_some() {
self.adv.changed.insert(index);
}
}
/// After `ServerRecipeBook.addRecipes`: the toast and the
/// `recipe_unlocked` trigger of each recipe now known.
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
self.recipe_toast(unlocked);
if self.lan_acting.is_some() {
// A network player's client gets them in ClientboundRecipeBookAddPacket.
self.adv.unlocked.extend_from_slice(unlocked);
} else {
self.recipe_toast(unlocked);
}
for r in unlocked {
let r = r.clone();
self.trigger("minecraft:recipe_unlocked", move |c, _| {
@@ -497,7 +455,8 @@ impl Game<'_> {
}
}
if let Some(d) = &a.display {
if d.show_toast && !self.adv.quiet {
// A network player's client shows its own toast.
if d.show_toast && !self.adv.quiet && self.lan_acting.is_none() {
self.adv.toasts.push_back(Toast {
icon: d.icon.clone(),
frame: d.frame,
@@ -515,10 +474,18 @@ impl Game<'_> {
Frame::Challenge => "chat.type.advancement.challenge",
};
let name = adv_name(a);
self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
));
// PlayerList.broadcastSystemMessage: everyone hears of a
// network player's advancement.
match self.lan_acting_name.clone() {
Some(player) => self
.adv
.announced
.push(Text::translate(key, &[Text::literal(&player), name])),
None => self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
)),
}
}
}
self.recipe_unlocked(&unlocked);
@@ -657,7 +624,7 @@ impl Game<'_> {
}
/// `EnterBlockTrigger`: the block, its state properties and the player.
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
let Some(definition) = self.res.assets.registry.state(state).cloned() else {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
@@ -790,11 +757,11 @@ impl Game<'_> {
if let Some((station, item)) = recipe {
// RecipeToast: black description under a purple title, the
// station at 0.6 scale in the corner over the cycling result.
let atlas = &self.res.atlas;
let atlas = &self.atlas;
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
let description = self.translate("recipe.toast.description", "Check your recipe book");
let atlas = &self.res.atlas;
let atlas = &self.atlas;
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
let start = batch.vertices.len();
@@ -803,7 +770,7 @@ impl Game<'_> {
self.draw_item_model(batch, &item, x + 8.0, 8.0);
return;
}
let atlas = &self.res.atlas;
let atlas = &self.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
@@ -864,6 +831,7 @@ impl Game<'_> {
let was = self.adv.progress.done(a);
if self.adv.progress.grant(&a.id, c, &date) {
changed = true;
self.progress_changed(index);
if !was && self.adv.progress.done(a) {
self.award(index);
}
@@ -875,6 +843,7 @@ impl Game<'_> {
pub(super) fn revoke_advancement(&mut self, index: usize, criterion: Option<&str>) -> bool {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
self.progress_changed(index);
match criterion {
Some(c) => self.adv.progress.revoke(&a.id, c),
None => {
@@ -887,6 +856,110 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
/// `ClientAdvancements.update` (ClientboundUpdateAdvancementsPacket):
/// the server's advancements replace or join the tree, each changed one
/// takes the progress sent, and one just done shows its toast. `icons`
/// are the added advancements' icon items by name.
pub(super) fn remote_advancements(
&mut self,
p: horizon_protocol::packets::play::UpdateAdvancements,
icons: Vec<String>,
) {
use horizon_client::scoreboard::tag_to_json;
let mut list: Vec<adv::Advancement> = Vec::new();
let mut positions: Vec<(f32, f32)> = Vec::new();
if !p.reset {
let old = self.adv.positions.take().unwrap_or_default();
for (i, a) in self.adv.tree.list.iter().enumerate() {
if !p.removed.contains(&a.id) {
list.push(a.clone());
positions.push(old.get(i).copied().unwrap_or((0.0, 0.0)));
}
}
} else {
self.adv.progress = Progress::default();
}
for id in &p.removed {
self.adv.progress.criteria.remove(id);
}
for (a, icon) in p.added.into_iter().zip(icons) {
list.retain(|o| o.id != a.id);
let display = a.display.map(|d| adv::Display {
icon,
title: tag_to_json(&d.title),
description: tag_to_json(&d.description),
frame: match d.frame {
1 => Frame::Challenge,
2 => Frame::Goal,
_ => Frame::Task,
},
background: d.background,
show_toast: d.show_toast,
announce: false,
hidden: d.hidden,
});
let mut criteria: Vec<adv::Criterion> = Vec::new();
for name in a.requirements.iter().flatten() {
if !criteria.iter().any(|c| &c.name == name) {
criteria.push(adv::Criterion {
name: name.clone(),
trigger: String::new(),
conditions: Value::Null,
});
}
}
list.push(adv::Advancement {
id: a.id,
parent: a.parent,
display,
criteria,
requirements: a.requirements,
rewards: Default::default(),
});
positions.push((a.x, a.y));
}
let mut tree = Tree {
list,
..Tree::default()
};
for (i, a) in tree.list.iter().enumerate() {
tree.by_id.insert(a.id.clone(), i);
}
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
self.adv.tree = Arc::new(tree);
self.adv.positions = Some(positions);
let tree = self.adv.tree.clone();
for (id, criteria) in p.progress {
let Some(&i) = tree.by_id.get(&id) else {
continue;
};
// The client only shows whether a criterion is obtained, not when.
let obtained: BTreeMap<String, String> = criteria
.into_iter()
.filter(|(_, at)| at.is_some())
.map(|(name, _)| (name, String::new()))
.collect();
self.adv.progress.criteria.insert(id, obtained);
let a = &tree.list[i];
if !p.reset
&& p.show
&& self.adv.progress.done(a)
&& let Some(d) = a.display.as_ref().filter(|d| d.show_toast)
{
self.adv.toasts.push_back(Toast {
icon: d.icon.clone(),
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
recipes: Vec::new(),
changed: false,
last_changed: 0,
});
}
}
}
}
/// A text component from advancement JSON (`translate` or `text`).
pub(super) fn text_of(v: &Value) -> Text {
if let Some(key) = v["translate"].as_str() {
@@ -163,11 +163,18 @@ impl Game<'_> {
}
let tree = self.adv.tree.clone();
let kids = tree.children();
let pos = tree
.layout()
.into_iter()
.map(|p| p.map(|(x, y)| (x * 28, (y * 27.0).floor() as i32)))
.collect();
// A server's advancements come placed (`DisplayInfo` x, y).
let pos = match &self.adv.positions {
Some(server) => server
.iter()
.map(|&(x, y)| Some(((x * 28.0).floor() as i32, (y * 27.0).floor() as i32)))
.collect(),
None => tree
.layout()
.into_iter()
.map(|p| p.map(|(x, y)| (x * 28, (y * 27.0).floor() as i32)))
.collect(),
};
self.adv_ui.cache = Some(Cache { tree, kids, pos });
}
fn adv_done(&self, i: usize) -> bool {
@@ -184,7 +191,12 @@ impl Game<'_> {
};
};
let tree = &c.tree;
let visible = tree.visible(&c.kids, |i| self.adv.progress.done(&tree.list[i]));
// A server sends only what the player may see.
let visible = if self.adv.positions.is_some() {
vec![true; tree.list.len()]
} else {
tree.visible(&c.kids, |i| self.adv.progress.done(&tree.list[i]))
};
// AdvancementTab.create: a displayed root with a background.
let mut tabs: Vec<usize> = (0..tree.list.len())
.filter(|&i| {
@@ -257,7 +269,7 @@ impl Game<'_> {
}
/// `AdvancementWidget`'s title lines, description lines and width.
fn adv_widget_text(&self, i: usize) -> (Vec<Text>, Vec<Text>, f32) {
let font = &self.res.atlas.font;
let font = &self.atlas.font;
let a = &self.adv.tree.list[i];
let Some(d) = &a.display else {
return (Vec::new(), Vec::new(), 0.0);
@@ -320,7 +332,7 @@ impl Game<'_> {
let (sx, sy) = self.adv_ui.scroll.unwrap_or_default();
let (sx, sy) = (sx.floor() as i32, sy.floor() as i32);
self.adv_tick_hover(&view, sx, sy, left, top);
let atlas = &self.res.atlas;
let atlas = &self.atlas;
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let title = Text::translate("gui.advancements", &[]);
let tw = atlas.font.width_text(&title);
@@ -432,7 +444,7 @@ impl Game<'_> {
}
let (x, y) = pos(i);
let (x, y) = ((ox + x) as f32, (oy + y) as f32);
let atlas = &self.res.atlas;
let atlas = &self.atlas;
batch.sprite_scaled(
atlas,
frame_sprite(d.frame, done),
@@ -449,7 +461,7 @@ impl Game<'_> {
}
}
// The window, tabs and title.
let atlas = &self.res.atlas;
let atlas = &self.atlas;
batch.sprite_region(
atlas,
"gui/advancements/window",
@@ -463,7 +475,7 @@ impl Game<'_> {
let (x, y) = tab_pos(ty, i);
let sprite = tab_sprite(ty, i, ty.3, Some(t) == tab);
batch.sprite_scaled(
&self.res.atlas,
&self.atlas,
&sprite,
left + x,
top + y,
@@ -482,7 +494,7 @@ impl Game<'_> {
}
}
}
let atlas = &self.res.atlas;
let atlas = &self.atlas;
let title = tab.and_then(|t| tree.list[t].display.as_ref()).map_or_else(
|| Text::translate("gui.advancements", &[]),
|d| text_of(&d.title),
@@ -497,7 +509,7 @@ impl Game<'_> {
}
}
// Done.
let atlas = &self.res.atlas;
let atlas = &self.atlas;
let (bx, by) = ((w / 2.0 - 100.0).floor(), h - FOOTER + 6.0);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
@@ -520,7 +532,7 @@ impl Game<'_> {
let (cx, cy) = self.cursor;
if cx > x && cx < x + ty.1 && cy > y && cy < y + ty.2 {
if let Some(d) = &tree.list[t].display {
ui::tooltip(batch, &self.res.atlas, &[text_of(&d.title)], self.cursor);
ui::tooltip(batch, &self.atlas, &[text_of(&d.title)], self.cursor);
}
break;
}
@@ -604,7 +616,7 @@ impl Game<'_> {
],
)
});
let atlas = &self.res.atlas;
let atlas = &self.atlas;
let progress_w = progress.as_ref().map_or(0.0, |t| atlas.font.width_text(t)) as i32;
let top_side = title_bottom + desc_h >= INSIDE_H;
let amount = if a.criteria.is_empty() || total == 0 {
Loaded 100 of 586 files, more files were not shown because too many files have changed in this diff. Show more