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
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
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
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
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
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
Ludovic Marie cf4f95f3d8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-17 00:48:02 +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
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 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
Cosmic 53955abf99 Hide ominous banner pattern descriptions in tooltips 2026-10-16 16:56:14 -04:00
Cosmic 86ccd5d299 Render banner breaking cracks and oak-plank particles 2026-10-16 16:40:29 -04: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 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
Cosmic 4218a496b2 Preserve ominous banner components through placement and drops 2026-10-16 14:40:28 -04:00
Cosmic 1ee64ded55 Show equipment attributes in hover tooltips 2026-10-16 12:10:45 -04:00
235 changed files with 24727 additions and 2103 deletions

No files matched your search

+1 -1
View File
@@ -83,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
+1
View File
@@ -28,5 +28,6 @@
/horizon-player.txt
/servers.dat
/screenshots/
/.horizon-cache/
/crash-reports/
/debug/
Generated
+4
View File
@@ -1573,6 +1573,7 @@ dependencies = [
"horizon-protocol",
"horizon-world",
"log",
"ureq",
]
[[package]]
@@ -1608,7 +1609,10 @@ dependencies = [
"horizon-core",
"libm",
"lz4_flex",
"postcard",
"serde",
"serde_json",
"sha2",
"xxhash-rust",
]
@@ -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."
}
@@ -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;
@@ -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(())
}
@@ -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,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(())
}
@@ -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);
}
}
+1 -1
View File
@@ -245,7 +245,7 @@ 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,
+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![
@@ -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)]| {
+236 -13
View File
@@ -2,7 +2,10 @@
//! (`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,
@@ -107,12 +110,178 @@ 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 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)?;
@@ -125,9 +294,6 @@ impl BlockFacts {
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())
@@ -221,13 +387,13 @@ impl BlockFacts {
outline,
outline_edges,
name: intern(&state.name),
emission: entry.emission as u8,
dampening: entry.dampening as u8,
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 f32,
hardness: entry.hardness.as_f64().unwrap_or(0.0) as f32,
requires_tool: entry.requires_tool,
replaceable: entry.replaceable,
random_ticks: entry.random_ticks,
@@ -264,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]
@@ -281,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(""),
_ => "",
}
}
}
+6 -3
View File
@@ -155,17 +155,20 @@ object!(Sound<'a> {
});
object!(Entry<'a> {
id: Option<u64> = None => value(unsigned),
emission: f64 = 0.0 => value(number),
dampening: f64 = 0.0 => value(number),
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: f64 = 0.0 => value(number),
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),
@@ -89,9 +89,9 @@ fn typed_fields_keep_defaults_duplicates_and_wrong_type_behavior() -> crate::Res
assert_eq!(entries[1].id, None);
let e = &entries[2];
assert_eq!(e.id, Some(2));
assert_eq!(e.emission, 4.5);
assert_eq!(e.dampening, 0.0);
assert_eq!(e.hardness.to_bits(), (-0.0f64).to_bits());
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);
+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);
+39 -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).
@@ -400,6 +417,10 @@ impl FixedStep {
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()
}
@@ -484,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"
);
}
}
+1 -1
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;
+1
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;
@@ -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);
}
}
}
+2
View File
@@ -132,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)]
+39
View File
@@ -74,6 +74,10 @@ pub struct DebugCharts {
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>,
}
@@ -113,6 +117,9 @@ impl DebugCharts {
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
@@ -136,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
@@ -524,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();
+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::*;
+16 -2
View File
@@ -68,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,
@@ -108,6 +109,7 @@ pub struct Extras {
impl Default for Extras {
fn default() -> Self {
Self {
fullbright: false,
zoom: false,
zoom_key: "key.keyboard.z".into(),
zoom_level: 4,
@@ -155,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" => {
@@ -198,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:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\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,
@@ -418,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,
@@ -445,6 +450,8 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Ok(Fullbright),
Err("Zoom"),
Ok(Zoom),
Ok(ZoomKey),
@@ -493,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",
@@ -518,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.",
@@ -556,7 +565,7 @@ 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 | Item::MouseTweaks | Item::Sort => Some(Cost::None),
_ => None,
@@ -565,6 +574,7 @@ impl Item {
/// 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,
@@ -606,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,
@@ -651,11 +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;
+51 -3
View File
@@ -1173,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_());
@@ -1207,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,
);
@@ -1235,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);
@@ -1363,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(
@@ -1371,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);
@@ -1391,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"),
+353 -105
View File
@@ -68,8 +68,10 @@ mod lan_mobs;
mod lan_pvp;
mod lan_screen;
mod lan_trading;
mod lan_waypoints;
mod leads;
mod level_particles;
mod levels;
mod lightning;
mod llamas;
mod loom;
@@ -112,6 +114,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_travel;
mod shader_packs;
mod shield;
mod sign_edit;
@@ -245,8 +248,9 @@ struct Options {
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
dev_mods: Vec<PathBuf>,
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on
/// an integrated server thread (game/integrated.rs).
/// Single player runs on an integrated server thread (game/integrated.rs),
/// as vanilla's does: the default. `--no-server-thread` or
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
@@ -301,7 +305,10 @@ impl Options {
dedicated: None,
benchmark: None,
dev_mods: Vec::new(),
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1"),
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
integrated: false,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
@@ -310,7 +317,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
"Horizon — Minecraft Java Edition 26.3 in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause."
);
return Ok(None);
}
@@ -324,6 +331,7 @@ impl Options {
"--offscreen" => o.offscreen = true,
"--control" => o.control = Some(horizon_client::control::default_path()),
"--server-thread" => o.server_thread = true,
"--no-server-thread" => o.server_thread = false,
_ => {
let value = args
.next()
@@ -966,12 +974,21 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// Sleeping in a bed: (bed head position, ticks asleep).
sleeping: Option<([i32; 3], u32)>,
/// The head of the bed while sleeping; the visual timer also survives waking.
sleeping: Option<[i32; 3]>,
sleep_timer: u32,
dim: Dim,
world_root: PathBuf,
/// A player carried through a portal, re-placed once the destination loads.
travelling: Option<(PlayerMotion, Vec3)>,
/// A server's players on their way to another level, each waiting for
/// the destination to load around them.
server_trips: Vec<server_travel::Trip>,
/// A server's levels other than the one in place (game/levels.rs).
levels: levels::Levels,
/// Which pass of a server tick runs (`tick_server`): the first one reads
/// the network, the last one ends the players' tick.
level_pass: levels::Pass,
/// Nether portals: the player's, items' and mobs' (game/nether_portal.rs).
portals: nether_portal::Portals,
/// The pending travel goes through an End portal (EndPortalBlock destinations).
@@ -1087,6 +1104,8 @@ struct Game<'a> {
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
integrated: Option<integrated::Integrated>,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
friends: Option<friends::Sharing>,
world_options: Option<lan_screen::WorldOptions>,
@@ -1159,7 +1178,7 @@ struct Game<'a> {
/// The local player as a model (third person, the inventory).
player_model: Option<Mob>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Dim {
Overworld,
Nether,
@@ -1447,9 +1466,13 @@ impl Game<'_> {
.iter()
.flat_map(|(_, m)| m.faces.iter())
.find(|f| f.quad.facing == horizon_assets::model::Direction::Up)
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next())?;
let name = self.pack.layer_names.get(&face.layer)?.clone();
let tint = if face.quad.tint_index >= 0 {
.or_else(|| models.iter().flat_map(|(_, m)| m.faces.iter()).next());
// Entity-rendered blocks may have a particle texture without any faces.
let layer = face
.map(|face| face.layer)
.or_else(|| models.iter().find_map(|(_, model)| model.particle))?;
let name = self.pack.layer_names.get(&layer)?.clone();
let tint = if face.is_some_and(|face| face.quad.tint_index >= 0) {
let n = &self.res.facts.get(state).name;
if n.contains("leaves") || n.contains("vine") {
0x48B518
@@ -1777,6 +1800,12 @@ impl Game<'_> {
});
self.process_level_events()
}
fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.with_level(|b, level, ticks| {
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]));
});
self.process_level_events()
}
/// Track which chunks are ticking (Ticks needs chunk load/unload).
fn sync_tick_chunks(&mut self) {
if !self.tick_chunks.synchronize(&self.world.chunks) {
@@ -2885,6 +2914,7 @@ impl Game<'_> {
return Ok(());
}
if self.mining.as_ref().is_none_or(|m| m.pos != hit.pos) {
self.attack_block(hit.pos)?;
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
@@ -3091,6 +3121,7 @@ impl Game<'_> {
self.eye = 1.62;
self.previous_eye = 1.62;
self.clock.reset();
self.capture_loaded_world();
Ok(())
}
fn tick(&mut self) -> Result<()> {
@@ -3165,7 +3196,7 @@ impl Game<'_> {
if !self.streaming.is_lit(cx, cz) {
return Ok(());
}
let playing = self.screen == Screen::Playing;
let playing = self.screen == Screen::Playing && self.sleeping.is_none();
let held = |k| playing && self.keys.contains(&k);
let keys = Keys {
forward: held(KeyCode::KeyW),
@@ -3259,7 +3290,12 @@ impl Game<'_> {
let feet = [player.position.x, player.position.y, player.position.z];
let player_velocity = [player.velocity.x, player.velocity.y, player.velocity.z];
self.previous_eye = self.eye;
self.eye += (camera_eye.unwrap_or(player.dimensions.eye_height) - self.eye) * 0.5;
let eye_height = if self.sleeping.is_some() {
0.2
} else {
player.dimensions.eye_height
};
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
self.tick_avatar(before, riding);
self.record_combat(&events);
@@ -3406,11 +3442,8 @@ impl Game<'_> {
}
if player.health.dead_or_dying() && self.screen != Screen::Dead {
// Inventory.dropAll: every stack thrown in a random direction.
let at = [
player.position.x,
player.position.y + 1.32,
player.position.z,
];
let feet = [player.position.x, player.position.y, player.position.z];
let at = [feet[0], feet[1] + 1.32, feet[2]];
let killer = self.combat_killer_kind();
self.death_message();
// ServerPlayer.die: deaths, and the time-since counters restart.
@@ -3426,6 +3459,10 @@ impl Game<'_> {
}
self.stats.reset("custom", "minecraft:time_since_death");
self.stats.reset("custom", "minecraft:time_since_rest");
let reward = self
.experience
.death_reward(self.ui.rules.bool("keep_inventory") || self.spectator());
self.spawn_orbs(feet, reward);
if !self.creative && !self.ui.rules.bool("keep_inventory") {
// Inventory.dropAll: slots 0..36, armor feet to head (36..39), off hand (40).
let mut stacks: Vec<ItemStack> =
@@ -3521,7 +3558,11 @@ impl Game<'_> {
});
horizon_client::spans::time("tick_particles", || self.tick_particles());
}
if self.right && self.use_delay == 0 && self.screen == Screen::Playing {
if self.right
&& self.use_delay == 0
&& self.screen == Screen::Playing
&& self.sleeping.is_none()
{
self.use_with_hand()?;
self.use_delay = 4;
}
@@ -3634,8 +3675,8 @@ impl Game<'_> {
}
/// `Minecraft.pause`: a single-player game with a pausing screen open.
fn pauses_game(&self) -> bool {
(self.remote.is_none() || self.integrated.is_some())
&& self.lan.is_none()
self.has_singleplayer_server()
&& !self.is_published()
&& self.title.is_none()
&& (matches!(
self.screen,
@@ -3648,6 +3689,15 @@ impl Game<'_> {
) || self.book.is_some())
}
fn capture(&mut self, capture: bool) {
// A guest's action (`as_guest`) opens and closes screens on the
// guest's own client: the window's pointer stays the host's (#687).
if self.lan_acting.is_some() {
return;
}
// MouseHandler.grabMouse never captures an inactive window.
if capture && !self.focused {
return;
}
if !capture {
self.stop_mining();
}
@@ -3666,6 +3716,13 @@ impl Game<'_> {
}
window.set_cursor_visible(!self.captured);
}
/// LevelLoadingScreen.onClose -> Gui.setScreen(null) -> grabMouse.
fn capture_loaded_world(&mut self) {
// Loading may finish after focus was lost or another screen opened.
if self.title.is_none() && self.screen == Screen::Playing {
self.capture(true);
}
}
fn draw_item(&mut self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
// special renderers included), then the decorations.
@@ -3843,7 +3900,9 @@ impl Game<'_> {
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
// Horizon's pause menu sits over the blurred world alone.
if !self.ui.hide_gui && !self.horizon_pause() {
let horizon_pause = self.horizon_pause();
let show_hud = !self.ui.hide_gui && !horizon_pause;
if show_hud {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
@@ -3888,15 +3947,7 @@ impl Game<'_> {
self.draw_boss_bars(&mut batch);
self.draw_extras(&mut batch);
}
// Hud.extractSleepOverlay, under the sidebar, titles and chat.
if let Some((_, ticks)) = self.sleeping {
let draws = hud_overlays::sleep_overlay(wi, hi, ticks as i32);
hud_layout::paint(&mut batch, &self.atlas, &draws);
}
if !self.ui.hide_gui {
self.draw_titles(&mut batch);
self.draw_scoreboard(&mut batch);
self.draw_subtitles(&mut batch);
if show_hud {
// The mount's hearts (and the jump bar that replaces the experience bar).
let (vehicle_hearts, jump_bar) = if spectator {
(0, false)
@@ -3968,16 +4019,26 @@ impl Game<'_> {
self.draw_selected_name(&mut batch, !self.creative);
}
}
// Hud.extractSleepOverlay: over all health/food bars, under titles and chat.
let draws = hud_overlays::sleep_overlay(wi, hi, self.sleep_timer as i32);
hud_layout::paint(&mut batch, &self.atlas, &draws);
if show_hud {
self.draw_titles(&mut batch);
self.draw_scoreboard(&mut batch);
self.draw_subtitles(&mut batch);
}
let atlas = &self.atlas;
if !self.ui.hide_gui
if show_hud
&& let Some((text, at)) = &self.message
&& at.elapsed() < Duration::from_secs(3)
{
let tw = HudBatch::text_width(atlas, text);
batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true);
}
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
if !horizon_pause {
self.draw_chat(&mut batch);
self.draw_tab_list(&mut batch);
}
match self.screen {
Screen::Paused if self.pause_menu => self.draw_pause_menu(&mut batch),
// PauseScreen(false): only its title, at the top.
@@ -4336,14 +4397,7 @@ impl Game<'_> {
} else {
("Crafting", 97.0)
};
batch.text(
atlas,
title,
ox + tx,
oy + if self.craft_size == 3 { 6.0 } else { 8.0 },
grey,
false,
);
batch.text(atlas, title, ox + tx, oy + 6.0, grey, false);
if self.craft_size == 3 {
batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false);
}
@@ -5579,6 +5633,20 @@ impl Game<'_> {
.block_entity_render
.render(&scene, visible, &mut out);
}
if let Some(mining) = &self.mining
&& mining.progress > 0.0
&& self.atlas.sprites.contains_key(&format!(
"block/destroy_stage_{}",
((mining.progress * 10.0) as u32).min(9)
))
{
let state = self
.world
.block(mining.pos[0], mining.pos[1], mining.pos[2]);
self.res
.block_entity_render
.render_breaking(&scene, mining.pos, state.0, &mut out);
}
}
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
if let Some(r) = self
@@ -5816,6 +5884,38 @@ impl Game<'_> {
let delay = 20.0 / (4.0 + speed).max(0.1);
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
}
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
pub(super) fn sweep_attack_position(&self, scale: f32, crit: bool) -> Option<[f64; 3]> {
let p = self.player.as_ref()?;
let sword = self.held().is_some_and(|s| {
self.res
.items
.item_tags
.get("minecraft:swords")
.is_some_and(|tag| tag.contains(&*s.item))
});
if !p.is_sweep_attack(scale, crit, sword) {
return None;
}
let yaw = f64::from(self.yaw.to_radians());
Some([
p.position.x - yaw.sin(),
p.position.y + f64::from(p.dimensions.height) * 0.5,
p.position.z + yaw.cos(),
])
}
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
self.particles.add(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
),
at,
[0.0; 3],
);
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
}
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
/// and can't be hit critically): knockback when sprinting at full
/// strength, then strong or weak. Before `swing_arm` resets the charge.
@@ -5832,7 +5932,11 @@ impl Game<'_> {
} else {
"minecraft:entity.player.attack.weak"
};
self.audio.play(sound, from, 1.0, 1.0);
if let Some(at) = self.sweep_attack_position(self.attack_strength_scale(), false) {
self.sweep_attack(at, from);
} else {
self.audio.play(sound, from, 1.0, 1.0);
}
}
/// Player.attack with the attack-strength charge, crits and knockback.
fn attack(&mut self) -> bool {
@@ -5884,6 +5988,7 @@ impl Game<'_> {
let target_kind = self.mobs[index].kind.id.clone();
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
let (amount, scale, crit) = self.melee_damage(&target_kind);
let sweep = self.sweep_attack_position(scale, crit);
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
@@ -5943,7 +6048,11 @@ impl Game<'_> {
"minecraft:entity.player.attack.weak"
};
// Player.playServerSideSound: at the player.
self.audio.play(sound, from, 1.0, 1.0);
if let Some(at) = sweep {
self.sweep_attack(at, from);
} else {
self.audio.play(sound, from, 1.0, 1.0);
}
// The hurt or death sound the mob raised (getHurtSound / getDeathSound,
// its voice pitch from its own random).
let queued = std::mem::take(&mut self.mobs[index].outbox);
@@ -6284,6 +6393,8 @@ impl Game<'_> {
self.clear_entities();
self.travelling = None;
self.player = None;
self.sleeping = None;
self.sleep_timer = 0;
self.spawn = None;
self.respawn_travel = None;
self.respawn_config =
@@ -6410,6 +6521,17 @@ impl Game<'_> {
let Some(player) = self.player.take() else {
return Ok(());
};
let from = self.dim;
let destination = self.switch_level(target, player.position)?;
self.trigger_dimension(from, target);
self.travelling = Some((player, destination));
self.end_travel = (target == Dim::End || from == Dim::End) && self.end_portal_travel;
Ok(())
}
/// The level swap under a dimension change: save the departing level,
/// open `target`'s, and return where a player leaving from `from` goes
/// (scaled coordinates, or the End portal's fixed destination).
pub(super) fn switch_level(&mut self, target: Dim, from: Vec3) -> Result<Vec3> {
// Save the departing dimension's changed chunks.
self.streaming.save(&mut self.world, true);
let keys: Vec<_> = self.world.modified.drain().collect();
@@ -6425,11 +6547,10 @@ impl Game<'_> {
_ => 1.0,
};
let destination = self.end_destination(target).unwrap_or(Vec3::new(
player.position.x * scale,
player.position.y,
player.position.z * scale,
from.x * scale,
from.y,
from.z * scale,
));
let from_end = self.dim == Dim::End;
let (streaming, save) =
self.make_streaming(&self.world_root.clone(), target, self.level.seed)?;
let mut world = World::new(self.res.facts.air, self.level.seed);
@@ -6437,12 +6558,14 @@ impl Game<'_> {
world.game_time = self.world.game_time;
world.day_time = self.world.day_time;
world.weather = self.world.weather;
// An open hub keeps hearing the new level's block changes.
world.block_log = self.world.block_log.as_ref().map(|_| Vec::new());
// The sculk tables' watched blocks are built once per game.
world.watched = std::mem::take(&mut self.world.watched);
self.streaming = streaming;
self.world = world;
self.save = save;
let from = self.dim;
self.dim = target;
self.trigger_dimension(from, target);
self.level.dimension = target.id().to_owned();
self.block_ticks = Ticks::new(self.level.seed as i32);
self.tick_chunks.clear();
@@ -6455,13 +6578,11 @@ impl Game<'_> {
self.pickups.clear();
self.orbs.clear();
self.mining = None;
self.travelling = Some((player, destination));
self.end_travel = (target == Dim::End || from_end) && self.end_portal_travel;
if let Some(r) = self.renderer.as_mut() {
r.clear_sections();
}
self.apply_settings();
Ok(())
Ok(destination)
}
/// PortalShape: an obsidian frame around 2..=21 by 3..=21 empty blocks.
fn portal_shape(&self, start: [i32; 3], x_axis: bool) -> Option<([i32; 3], i32, i32)> {
@@ -6631,6 +6752,8 @@ impl Game<'_> {
self.streaming.paused = true;
self.world = World::new(self.res.facts.air, 0);
self.player = None;
self.sleeping = None;
self.sleep_timer = 0;
self.clear_entities();
self.screen = Screen::Playing;
if let Some(r) = self.renderer.as_mut() {
@@ -7460,6 +7583,7 @@ impl Game<'_> {
// GameRenderer.renderItemInHand: F1 hides the hand with the HUD.
if self.spectator()
|| self.ui.hide_gui
|| self.sleeping.is_some()
|| !self.camera_type.first_person()
|| self
.player
@@ -7648,7 +7772,15 @@ impl Game<'_> {
/// The renderer for the window, with the current block sprites, shared
/// atlas and world border texture.
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
let assets = &self.pack.assets;
self.renderer_for(window, &self.pack, &self.atlas)
}
fn renderer_for(
&self,
window: Arc<Window>,
pack: &PackResources,
atlas: &HudAtlas,
) -> Result<Renderer> {
let assets = &pack.assets;
let mut renderer = pollster::block_on(Renderer::new(
window,
self.options.offscreen,
@@ -7658,12 +7790,12 @@ impl Game<'_> {
assets.texture_layers,
&assets.sprite_mips,
&assets.animations,
(&self.atlas.pixels, self.atlas.width, self.atlas.height),
(&self.pack.forcefield.2, self.pack.forcefield.0),
&self.pack.panorama,
(&atlas.pixels, atlas.width, atlas.height),
(&pack.forcefield.2, pack.forcefield.0),
&pack.panorama,
self.renderer.as_ref(),
))?;
renderer.hud.fire_animations = self
.atlas
renderer.hud.fire_animations = atlas
.fire_animations
.iter()
.map(|(rect, animation)| (*rect, animation.clone(), usize::MAX))
@@ -7927,13 +8059,23 @@ impl Game<'_> {
return;
}
self.message = Some(("Respawn point set".into(), Instant::now()));
self.sleeping = Some((head, 0));
self.stop_mining();
self.left = false;
self.right = false;
self.using = None;
self.sleeping = Some(head);
self.sleep_timer = 0;
self.stats.custom("sleep_in_bed", 1);
self.trigger_player("minecraft:slept_in_bed");
self.stats.reset("custom", "minecraft:time_since_rest");
}
fn wake_up(&mut self) {
self.sleeping = None;
self.sleep_timer = 100;
}
fn tick_sleep(&mut self) {
let Some((head, ticks)) = self.sleeping else {
self.sleep_timer = tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
let Some(head) = self.sleeping else {
return;
};
if self.keys.contains(&KeyCode::ShiftLeft)
@@ -7942,7 +8084,7 @@ impl Game<'_> {
.as_ref()
.is_none_or(|p| p.health.dead_or_dying())
{
self.sleeping = None;
self.wake_up();
return;
}
if let Some(p) = self.player.as_mut() {
@@ -7953,18 +8095,15 @@ impl Game<'_> {
);
p.velocity = Vec3::ZERO;
}
let ticks = ticks + 1;
// ServerLevel: once everyone has slept 100 ticks, skip to morning and clear weather.
if ticks >= 100 {
if self.sleep_timer >= 100 {
let t = self.world.day_time;
self.world.day_time = t + 24000 - t.rem_euclid(24000);
self.world.weather.raining = false;
self.world.weather.thundering = false;
self.world.weather.rain_time = 0;
self.world.weather.thunder_time = 0;
self.sleeping = None;
} else {
self.sleeping = Some((head, ticks));
self.wake_up();
}
}
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
@@ -8064,7 +8203,7 @@ impl Game<'_> {
self.creative_screen.search_focused.elapsed().as_millis(),
);
}
let mut hovered: Option<String> = None;
let mut hovered: Option<ItemStack> = None;
if tab.inventory {
self.draw_player_preview(
batch,
@@ -8114,7 +8253,7 @@ impl Game<'_> {
&& self.cursor.1 < y + 16.0
{
batch.fill(&self.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
hovered = Some(stack.item.to_string());
hovered = Some(stack.clone());
}
}
}
@@ -8129,7 +8268,7 @@ impl Game<'_> {
&& self.cursor.1 >= y
&& self.cursor.1 < y + 16.0
{
hovered = Some(stack.item.to_string());
hovered = Some(stack);
}
}
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
@@ -8165,8 +8304,8 @@ impl Game<'_> {
} else if self.carried.is_none()
&& let Some(item) = hovered
{
let name = self.pack.names.item(&item);
self.tooltip(batch, &name, self.cursor.0, self.cursor.1);
let lines = self.stack_tooltip(&item);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
}
@@ -8310,21 +8449,7 @@ impl Game<'_> {
self.ender_items = items.clone();
}
level.ender_items = self.ender_items.clone();
level.active_effects = self
.potions
.effects
.list
.iter()
.map(|e| {
(
format!("minecraft:{}", e.id),
e.duration,
e.amplifier,
e.ambient,
e.visible,
)
})
.collect();
level.active_effects = self.potions.effects.saved();
}
fn save_game(&mut self) -> Result<()> {
self.save_game_with(true)
@@ -8336,18 +8461,10 @@ impl Game<'_> {
if self.remote.is_some() {
return Ok(());
}
self.streaming.save(&mut self.world, wait);
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
self.save
.save_later(&chunks, self.pack.assets.registry, &self.res.biomes)?;
if wait {
self.save.flush();
}
self.save_entities(wait)?;
self.save_level(wait)?;
self.save_parked_levels(wait)?;
self.save_maps();
self.save_random_sequences();
self.save_raids();
self.save_fight();
self.save_wandering_trader();
self.save_portal_arrivals();
@@ -8364,6 +8481,20 @@ impl Game<'_> {
self.message = Some(("Saved the game".into(), Instant::now()));
Ok(())
}
/// Save the level in place: its changed chunks, its entities and its raids.
fn save_level(&mut self, wait: bool) -> Result<()> {
self.streaming.save(&mut self.world, wait);
let keys: Vec<_> = self.world.modified.drain().collect();
let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k)));
self.save
.save_later(&chunks, self.pack.assets.registry, &self.res.biomes)?;
if wait {
self.save.flush();
}
self.save_entities(wait)?;
self.save_raids();
Ok(())
}
fn render(&mut self) -> Result<bool> {
self.poll_shader_pack();
let Some(size) = self
@@ -8442,12 +8573,23 @@ impl Game<'_> {
let debug_lines = horizon_client::gizmos::camera_relative(&lines, at, view);
let debug_lines_on_top = horizon_client::gizmos::camera_relative(&on_top, at, view);
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
// LevelRenderer.renderHitOutline: the outline shape's edges.
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
let outline = if self.screen == Screen::Playing && !self.spectator() {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
let f = self.res.facts.get(state);
outline_after_terrain = self
.pack
.assets
.model_catalog
.collect(state, BlockPos::new(h.pos[0], h.pos[1], h.pos[2]))
.is_ok_and(|models| {
models
.iter()
.any(|(_, model)| model.faces.iter().any(|f| f.translucent))
});
let o = if f.outline_offset {
self.pack
.context
@@ -8597,6 +8739,7 @@ impl Game<'_> {
}),
dark_disc: self.dark_disc(camera),
outline,
outline_after_terrain,
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -8774,6 +8917,18 @@ impl Game<'_> {
}
/// A key press while a world is open (keyboard or control socket).
fn key_pressed(&mut self, key: KeyCode) -> Result<()> {
if self.sleeping.is_some()
&& self.screen == Screen::Playing
&& !matches!(
key,
KeyCode::F1 | KeyCode::F2 | KeyCode::F11 | KeyCode::KeyT | KeyCode::Slash
)
{
if matches!(key, KeyCode::Escape | KeyCode::ShiftLeft) {
self.wake_up();
}
return Ok(());
}
if self.sort_key_press(key) {
return Ok(());
}
@@ -8860,7 +9015,13 @@ impl Game<'_> {
}
_ => {}
},
// key.swapOffhand: ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND.
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
// (the server swaps the hands and sends both slots back; spectators can't).
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
if !self.spectator() {
self.remote_swap_hands();
}
}
KeyCode::KeyF if self.screen == Screen::Playing => {
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
self.using = None;
@@ -8898,6 +9059,9 @@ impl Game<'_> {
}
/// A mouse button while playing (window or control socket).
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
if self.sleeping.is_some() {
return Ok(());
}
if self.remote.is_some() {
return self.remote_mouse(button, pressed);
}
@@ -9227,6 +9391,7 @@ impl ApplicationHandler for Game<'_> {
title.scroll_worlds(d, self.cursor, (batch.width, batch.height));
}
if self.screen == Screen::Playing
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.extras_scroll(d)
&& let Some((wheel, _)) =
@@ -9363,6 +9528,21 @@ impl ApplicationHandler for Game<'_> {
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if matches!(self.screen, Screen::Inventory | Screen::Creative)
&& let Some(key) = self
.settings
.keys
.container_mouse_key(button, self.creative)
{
if pressed && self.carried.is_none() {
self.inventory_hotkey(key);
}
// Neither edge of a bound button is an ordinary slot click.
return Ok(());
}
if pressed && self.sort_mouse_press(button) {
return Ok(());
}
match self.screen {
Screen::Playing => {
if pressed && !self.captured {
@@ -9485,9 +9665,6 @@ impl ApplicationHandler for Game<'_> {
self.station_text(event.text.as_deref(), backspace);
}
}
WindowEvent::KeyboardInput { event, .. } if self.remote_chat_open() => {
self.remote_chat_key(&event)
}
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::WorldOptions
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
@@ -9594,7 +9771,6 @@ impl ApplicationHandler for Game<'_> {
&& self.title.is_none()
&& self.sign_edit.is_none()
&& self.book.is_none()
&& !self.remote_chat_open()
{
let turn = |k| f32::from(u8::from(self.keys.contains(&k)));
let secs = elapsed.as_secs_f32().min(0.1);
@@ -10003,9 +10179,13 @@ impl<'a> Game<'a> {
avatar: Default::default(),
flash: 0,
sleeping: None,
sleep_timer: 0,
dim: Dim::Overworld,
world_root: PathBuf::new(),
travelling: None,
server_trips: Vec::new(),
levels: Default::default(),
level_pass: Default::default(),
portals: Default::default(),
end_travel: false,
respawn_config: None,
@@ -10086,6 +10266,7 @@ impl<'a> Game<'a> {
remote: None,
lan: None,
integrated: None,
serving: None,
world_options: None,
options_to_world_options: false,
command_block_screen: None,
@@ -10147,7 +10328,16 @@ pub fn run() -> Result<()> {
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs);
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
let mut pack_error = None;
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs)
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
vanilla_root.clone()
});
options.resources = mods::content_root(
&options.resources,
&early_settings.mods.enabled,
@@ -10159,6 +10349,7 @@ pub fn run() -> Result<()> {
// with the game's own resources.
Err(e) if options.resources != vanilla_root => {
log::warn!("Horizon: resource packs failed to load ({e}); using the default resources");
pack_error = Some(e.to_string());
options.resources = vanilla_root.clone();
Assets::load(&vanilla_root)?
}
@@ -10172,7 +10363,11 @@ pub fn run() -> Result<()> {
let root = options.resources.clone();
let assets = Arc::new(assets);
log::info!("load assets: {:.2}s", started.elapsed().as_secs_f32());
let facts = BlockFacts::load(&root, assets.registry)?;
let behaviour_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
std::fs::File::open(root.join("block-behaviours.json"))?,
))?;
let (facts, gameplay_projection) =
BlockFacts::load_with_projection(&root, assets.registry, &behaviour_json)?;
let mob_data = horizon_client::mobs::MobData::load(&root, &facts);
horizon_client::mobs::trial_spawner::load_registry(&root);
log::info!("load facts: {:.2}s", started.elapsed().as_secs_f32());
@@ -10239,7 +10434,12 @@ pub fn run() -> Result<()> {
ambient: horizon_client::ambience::AmbientTables::load(&root),
nether_environment: Environment::load_dimension(&root, "the_nether", false)?,
end_environment: Environment::load_dimension(&root, "the_end", false)?,
behaviours: Behaviours::load(registry_owner.registry, &root)?,
behaviours: Behaviours::load_with_gameplay(
registry_owner.registry,
&root,
&behaviour_json,
&gameplay_projection,
)?,
mob_types: MobTypes::load(&root)?
.types
.into_iter()
@@ -10251,6 +10451,10 @@ pub fn run() -> Result<()> {
player_arm: horizon_client::mobs::load_model(&root, "minecraft:player#main")
.and_then(|m| m.children.into_iter().find(|c| c.name == "right_arm")),
});
// Both inputs are needed only while constructing the behaviour tables.
// Release their owned JSON/Arc buffers before entering the long-lived game loop.
drop(gameplay_projection);
drop(behaviour_json);
let mut settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
horizon_client::ui_theme::load_preferences(&std::env::current_dir()?);
// Scripted and frame-limited runs (the parity harnesses) see vanilla's
@@ -10265,6 +10469,10 @@ pub fn run() -> Result<()> {
}
let mut game = Game::new(&resources, pack, atlas, options, settings, audio)?;
game.ui.vanilla_root = vanilla_root;
if let Some(error) = pack_error {
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
game.ui.options.resource_error = Some(error);
}
if game.options.benchmark.is_some() {
// The same conditions on every machine: no VSync, no frame cap, no
// pause or idle limit without focus. In memory only, never saved.
@@ -10372,6 +10580,17 @@ pub fn run() -> Result<()> {
Ok(())
}
// Player.tick: cap the sleeping fade at 100, then fade out over 10 waking ticks.
fn tick_sleep_timer(timer: u32, sleeping: bool) -> u32 {
if sleeping {
(timer + 1).min(100)
} else if (1..109).contains(&timer) {
timer + 1
} else {
0
}
}
fn reset_world_progress(adv: &mut advancements::AdvState, stats: &mut stats::Stats) {
*adv = Default::default();
*stats = Default::default();
@@ -10406,6 +10625,35 @@ mod tests {
use horizon_client::container_screen::{Button, Clicks, Release, Target};
use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys};
#[test]
fn sleep_fade_lasts_ten_ticks_after_waking() {
let alpha = |timer| match hud_overlays::sleep_overlay(320, 240, timer).first() {
Some(hud_layout::GuiDraw::Fill { color, .. }) => color >> 24,
None => 0,
_ => panic!("sleep overlay must be a full-screen fill"),
};
assert_eq!(tick_sleep_timer(0, false), 0);
let mut timer = 0;
for _ in 0..50 {
timer = tick_sleep_timer(timer, true);
}
assert_eq!(alpha(timer as i32), 110);
for _ in 0..100 {
timer = tick_sleep_timer(timer, true);
}
assert_eq!(timer, 100);
assert_eq!(alpha(timer as i32), 220);
for _ in 0..5 {
timer = tick_sleep_timer(timer, false);
}
assert_eq!(alpha(timer as i32), 110);
for _ in 0..5 {
timer = tick_sleep_timer(timer, false);
}
assert_eq!(timer, 0);
assert!(hud_overlays::sleep_overlay(320, 240, timer as i32).is_empty());
}
/// An integrated server thread shares the loaded resources with the client.
#[test]
fn shared_resources_can_cross_threads() {
+167 -50
View File
@@ -11,7 +11,7 @@ use horizon_client::{
};
use serde_json::Value;
use std::{
collections::{BTreeMap, VecDeque},
collections::{BTreeMap, BTreeSet, VecDeque},
sync::Arc,
time::Instant,
};
@@ -72,13 +72,22 @@ impl Toast {
#[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>);
@@ -171,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).
@@ -217,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())
@@ -254,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 {
@@ -376,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();
@@ -403,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, _| {
@@ -453,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,
@@ -471,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);
@@ -820,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);
}
@@ -831,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 => {
@@ -843,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| {
+37 -2
View File
@@ -460,12 +460,18 @@ impl Game<'_> {
json!({
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"world": (self.title.is_none()).then(|| self.level.name.clone()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
}),
"dimension": self.dim.id(),
"gamemode": format!("{:?}", self.mode).to_lowercase(),
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
"weather": { "raining": self.world.weather.raining, "thundering": self.world.weather.thundering },
"paused": self.pause,
// Mouse movement turns the camera (`apply_mouse_look`): the real
// pointer is grabbed, or an agent's `mouse-move` stands in for it.
"pointer_grabbed": self.captured || self.agent_pointer,
"player": player,
"selected": self.selected + 1,
"held": self.held().map(|s| self.stack_value(s)),
@@ -610,6 +616,9 @@ impl Game<'_> {
"screen": self.screen_label(),
"buttons": buttons,
});
if let Some(chat) = &self.ui.chat_screen {
v["chat_input"] = json!(chat.input.value);
}
if self.title.is_some() {
v["menu"] = self.title_value();
}
@@ -628,6 +637,15 @@ impl Game<'_> {
v["craft_size"] = json!(self.craft_size);
v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind)));
v["container"] = json!(self.container);
// The recipe book's buttons (GUI pixels): the book button, then
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
if !book.is_empty() {
v["recipe_book"] = json!({
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
"known": self.recipe_book_known(),
});
}
v["slot_ids"] = json!(
"0-8 hotbar, 9-35 inventory, 100+ crafting grid (row-major), 200 crafting result, 300+ container, 500-503 armor, 504 offhand, 600+ station"
);
@@ -1476,6 +1494,13 @@ impl Game<'_> {
return Err("command TEXT".into());
}
let command = format!("/{text}");
// Single player's console is its server thread's.
if let Some(server) = &self.integrated {
let chat = server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
return ok(json!({ "ok": true, "chat": chat }));
}
if self.remote.is_some() {
self.send_chat(&command);
return ok(json!({ "ok": true }));
@@ -1761,11 +1786,21 @@ impl Game<'_> {
if self.ticks < *report_tick && !timed_out {
return None;
}
let after = pos.map(|p| self.block_json(p));
let changed = match (*pos, *before) {
(Some(p), Some(b)) => self.world.block(p[0], p[1], p[2]) != b,
_ => false,
};
// A client of a server (single player's server thread, a LAN
// guest) sees the block once the server's answer comes.
if *place
&& !changed
&& self.remote.is_some()
&& self.ticks < *report_tick + 20
&& !timed_out
{
return None;
}
let after = pos.map(|p| self.block_json(p));
let used = delta(inventory, &self.inventory_counts());
let mut v = json!({
"ok": !*place || changed,
+4
View File
@@ -220,7 +220,11 @@ impl Game<'_> {
levels
}
/// `applyEffects`: the powers for players within `levels * 10 + 10` (any height above).
/// `BeaconBlockEntity.applyEffects`: every player in range.
fn beacon_effects(&mut self, p: [i32; 3], b: &Beacon) {
self.for_each_player(|g| g.beacon_effects_on_player(p, b));
}
fn beacon_effects_on_player(&mut self, p: [i32; 3], b: &Beacon) {
let Some(primary) = b.primary.as_deref().and_then(effect_id) else {
return;
};
+33 -6
View File
@@ -10,6 +10,18 @@ use horizon_client::{
text::{ClickEvent, Style, Text},
};
use std::collections::BTreeMap;
/// The tree the chat parses and completes with: a server's, once it has
/// sent one (ClientPacketListener.commands), else the game's own.
fn chat_tree<'a>(
remote: &'a Option<super::remote::Remote>,
mods: &'a super::mods::ModsState,
) -> &'a horizon_client::command::Dispatcher {
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
Some(tree) => tree,
None => mods.dispatcher(),
}
}
use winit::{
event::KeyEvent,
keyboard::{KeyCode, PhysicalKey},
@@ -378,6 +390,10 @@ impl Game<'_> {
/// The block the player is looking at, for coordinate suggestions.
fn refresh_dynamic_registries(&mut self) {
self.ui.registries.target_block = self.pick().map(|h| h.pos);
// ClientPacketListener.getOnlinePlayerNames on a server.
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
}
}
pub(super) fn open_chat(&mut self, initial: &str) {
if self.player.is_none() {
@@ -391,7 +407,7 @@ impl Game<'_> {
let b = HudBatch::new(size);
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: self.mods.dispatcher(),
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
let mut screen = ChatScreen::new(
@@ -425,6 +441,12 @@ impl Game<'_> {
capture.push(text);
return;
}
// Connected to a server (single player's own included), the chat on
// screen is the connection's: the game's own messages join it.
if self.remote.is_some() {
self.chat_line(text.plain());
return;
}
let focused = self.screen == Screen::Chat;
let heard = self.mods.mods.is_running().then(|| text.plain());
self.ui.chat.add(
@@ -443,7 +465,7 @@ impl Game<'_> {
if let Some(shortcut) = self.edit_shortcut(event) {
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: self.mods.dispatcher(),
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -489,7 +511,7 @@ impl Game<'_> {
};
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: self.mods.dispatcher(),
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
@@ -515,7 +537,7 @@ impl Game<'_> {
pub(super) fn chat_type(&mut self, text: &str) {
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: self.mods.dispatcher(),
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -525,6 +547,11 @@ impl Game<'_> {
/// ChatScreen.handleChatInput: history, then a command or a chat line.
pub(super) fn handle_chat_input(&mut self, message: &str) {
self.ui.chat.add_recent(message);
// On a server (single player's own included) the server runs it.
if self.remote.is_some() {
self.send_chat(message);
return;
}
if let Some(command) = message.strip_prefix('/') {
self.run_command(command);
} else {
@@ -566,7 +593,7 @@ impl Game<'_> {
});
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: self.mods.dispatcher(),
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -611,7 +638,7 @@ impl Game<'_> {
};
let m = Measure(&self.atlas.font);
let c = Commands {
dispatcher: self.mods.dispatcher(),
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
@@ -758,18 +758,25 @@ impl Game<'_> {
.and_then(|l| l.local_addr())
.map_or(25564, |a| i32::from(a.port())),
};
if let Some(lan) = &self.lan {
return Err(err(
"commands.publish.alreadyPublished.lan",
&[&lan.settings.port],
));
let published = if self.serving() {
self.published_port()
} else {
self.lan.as_ref().map(|l| l.settings.port)
};
if let Some(port) = published {
return Err(err("commands.publish.alreadyPublished.lan", &[&port]));
}
let settings = super::super::lan::LanSettings {
port: port as u16,
guest_commands: allow,
force_game_mode: false,
};
if self.lan_start(settings).is_err() {
let started = if self.serving() {
self.publish(settings).is_ok()
} else {
self.lan_start(settings).is_ok()
};
if !started {
return Err(err("commands.publish.failed", &[]));
}
let port_text = port.to_string();
@@ -785,6 +792,13 @@ impl Game<'_> {
Ok(port)
}
pub(super) fn cmd_unpublish(&mut self) -> R<i32> {
if self.serving() {
if !self.unpublish() {
return Err(err("commands.unpublish.notPublished", &[]));
}
self.feedback(Text::translate("commands.unpublish.success", &[]));
return Ok(1);
}
if self.lan.is_none() {
return Err(err("commands.unpublish.notPublished", &[]));
}
+64 -28
View File
@@ -29,7 +29,7 @@ const HELP: &[&str] = &[
"chat TEXT send a chat line or /command as if typed",
"type TEXT type into the open chat box (key enter tap sends)",
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs graphics graphics-advanced)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
"stats [reset] frame-time summary since the last reset",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
@@ -54,12 +54,23 @@ pub struct Flight {
/// Reply with every frame time too (`fly ... PITCH raw`).
raw: bool,
reply: Sender<String>,
/// The world's smoothed tick time each frame (the F3 server line):
/// the server thread's when single player runs on one.
tick_ms: Vec<f32>,
}
impl Game<'_> {
/// Move the camera along the running flight; reply when it ends.
pub(super) fn advance_flight(&mut self) {
let Some(flight) = &self.flight else { return };
let tick_ms = self
.integrated
.as_ref()
.map_or(self.debug_charts.smoothed_tick_ms, |s| s.smoothed_tick_ms());
let Some(flight) = self.flight.as_mut() else {
return;
};
flight.tick_ms.push(tick_ms);
let flight = &*flight;
let t = (flight.started.elapsed().as_secs_f64() / flight.seconds).min(1.0);
let (a, b) = (flight.from, flight.to);
let d = [b.x - a.x, b.y - a.y, b.z - a.z];
@@ -79,6 +90,9 @@ impl Game<'_> {
&& let Some(flight) = self.flight.take()
{
let mut summary = self.frame_stats.summary();
let n = flight.tick_ms.len().max(1) as f32;
summary["tick_ms"] = (flight.tick_ms.iter().sum::<f32>() / n).into();
summary["tick_ms_max"] = flight.tick_ms.iter().copied().fold(0.0, f32::max).into();
if flight.raw {
summary["frame_ms"] = self.frame_stats.frame_millis().into();
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
@@ -339,6 +353,14 @@ impl Game<'_> {
.get(&item)
.ok_or_else(|| format!("unknown item {item}"))?;
let count: u32 = words.get(2).map_or(Ok(1), |c| c.parse())?;
// Single player's inventory is its server thread's player's.
if let Some(server) = &self.integrated {
let command = format!("give @s {item} {count}");
server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
return Ok(Some(json!({ "ok": true, "given": count })));
}
let max = info.max_stack;
let mut given = 0;
// Stop once the inventory is full.
@@ -369,7 +391,14 @@ impl Game<'_> {
ok()
}
"time" => {
self.world.day_time = arg(1)?.parse()?;
let time: i64 = arg(1)?.parse()?;
self.world.day_time = time;
// Single player's clock is its server thread's.
if let Some(server) = &self.integrated {
server
.call(move |g| g.world.day_time = time)
.ok_or("the integrated server stopped")?;
}
ok()
}
"summon" => {
@@ -462,8 +491,10 @@ impl Game<'_> {
"language" => Page::Language,
"font" => Page::Font,
"packs" => Page::Packs,
"mods" => Page::Mods,
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"extras" => Page::Extras,
other => return Err(format!("unknown options page {other}").into()),
};
if self.screen == Screen::Playing && self.title.is_none() {
@@ -475,7 +506,10 @@ impl Game<'_> {
}
"chat-log" => {
let n = words.get(1).map_or(Ok(10), |n| n.parse())?;
Ok(Some(json!({ "chat": self.ui.chat.recent_lines(n) })))
let lines = self
.remote_chat(n)
.unwrap_or_else(|| self.ui.chat.recent_lines(n));
Ok(Some(json!({ "chat": lines })))
}
"stats" => {
let summary = self.frame_stats.summary();
@@ -512,6 +546,7 @@ impl Game<'_> {
seconds: n(7)?.max(0.1),
raw: words.get(9) == Some(&"raw"),
reply: reply.clone(),
tick_ms: Vec::new(),
});
Ok(None)
}
@@ -549,18 +584,20 @@ impl Game<'_> {
self.leave_server(None)?;
ok()
}
"save" => {
"save" | "save-all" => {
self.require_world()?;
self.save_game()?;
ok()
}
"save-all" => {
// Like `save-all flush`: also rewrite chunks that were not modified
// (the save round-trip harness compares them with vanilla's files).
self.require_world()?;
let keys: Vec<_> = self.world.chunks.keys().copied().collect();
self.world.modified.extend(keys);
self.save_game()?;
// `save-all` is like `save-all flush`: it also rewrites chunks
// that were not modified (the save round-trip harness compares
// them with vanilla's files).
let all = words[0] == "save-all";
// Single player's world is its server thread's.
if let Some(server) = &self.integrated {
server
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
.ok_or("the integrated server stopped")??;
} else {
self.control_save(all)?;
}
ok()
}
"dragon-hurt" => {
@@ -581,6 +618,15 @@ impl Game<'_> {
}
}
/// `horizon-ctl save` and `save-all`.
fn control_save(&mut self, all: bool) -> Result<()> {
if all {
let keys: Vec<_> = self.world.chunks.keys().copied().collect();
self.world.modified.extend(keys);
}
self.save_game()
}
fn require_world(&self) -> Result<()> {
if self.title.is_some() {
return Err("no world open; use: open WORLD [SEED] [creative]".into());
@@ -590,18 +636,6 @@ impl Game<'_> {
fn control_key_down(&mut self, key: KeyCode) -> Result<()> {
self.physical_keys.insert(key);
// A server's chat input takes the keys first, as remote_chat_key does.
if self.remote_chat_open() {
use winit::keyboard::NamedKey;
let named = match key {
KeyCode::Escape => Some(NamedKey::Escape),
KeyCode::Enter | KeyCode::NumpadEnter => Some(NamedKey::Enter),
KeyCode::Backspace => Some(NamedKey::Backspace),
_ => None,
};
self.remote_chat_input(named, None);
return Ok(());
}
if self.screen == Screen::Chat {
self.chat_key_code(key, None);
return Ok(());
@@ -715,7 +749,9 @@ impl Game<'_> {
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
"fight": self.dragon_status(),
"carts": self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>(),
// A client of a server (single player's server thread) knows the
// carts it was sent.
"carts": self.remote_carts().unwrap_or_else(|| self.carts.iter().map(|c| serde_json::json!({"kind": c.kind.id(), "position": [c.position.x, c.position.y, c.position.z], "velocity": [c.velocity.x, c.velocity.y, c.velocity.z]})).collect::<Vec<_>>()),
"riding": self.riding,
"using": self.using,
"render_distance": self.streaming.radius,
@@ -362,3 +362,11 @@ pub(super) struct CreakingState {
/// Which block states are creaking hearts, by state id.
hearts: Vec<bool>,
}
impl CreakingState {
/// Exchange the chunks scanned for creaking hearts with another level's
/// (game/levels.rs).
pub(super) fn swap_level(&mut self, other: &mut Self) {
std::mem::swap(&mut self.scanned, &mut other.scanned);
}
}
+34 -7
View File
@@ -1358,25 +1358,52 @@ impl Game<'_> {
/// mining fatigue III for five minutes unless a stronger or longer one
/// is on, with the curse sound and the elder's apparition.
fn elder_curse(&mut self, at: [f64; 3]) {
let mut cursed = Vec::new();
self.for_each_player(|g| {
if g.elder_curse_player(at) {
cursed.push(g.lan_acting);
}
});
// A guest's client shows the apparition and plays the curse itself
// (ClientboundGameEventPacket.GUARDIAN_ELDER_EFFECT).
if let Some(host) = self.lan.as_ref() {
for conn in cursed.into_iter().flatten() {
host.hub.send_to(
conn,
&horizon_protocol::packets::play::Clientbound::GameEvent(
horizon_protocol::packets::play::GameEvent {
event: horizon_client::lan::hub::game_event::GUARDIAN_ELDER_EFFECT,
param: 1.0,
},
),
);
}
}
}
/// The curse on the player in place; whether it took.
fn elder_curse_player(&mut self, at: [f64; 3]) -> bool {
if self.creative || self.spectator() {
return;
return false;
}
let Some(p) = self.player.as_ref() else {
return;
return false;
};
let d = p.position.subtract(Vec3::new(at[0], at[1], at[2]));
if d.x * d.x + d.y * d.y + d.z * d.z >= 2500.0 || p.health.dead_or_dying() {
return;
return false;
}
let active = self.potions.effects.get("minecraft:mining_fatigue");
if active.is_some_and(|e| e.amplifier >= 2 && e.duration >= 1200) {
return;
return false;
}
let at = [p.position.x, p.position.y, p.position.z];
self.give_player_effect("minecraft:mining_fatigue", 6000, 2);
self.audio
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
if self.lan_acting.is_none() {
self.audio
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
}
true
}
/// Damage of a given type to the player from a point.
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
+32 -18
View File
@@ -38,7 +38,7 @@ impl DebugHost for Host<'_, '_> {
// the menu as it always has (key_pressed).
let g = &mut *self.game;
if without_menu && g.screen == Screen::Playing {
g.pause_menu = !(g.remote.is_none() && g.lan.is_none());
g.pause_menu = !(g.has_singleplayer_server() && !g.is_published());
g.screen = Screen::Paused;
g.capture(false);
g.mining = None;
@@ -88,8 +88,7 @@ impl Game<'_> {
|| self.book.is_some()
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|| self.recipe_search_focused()
|| self.station_typing()
|| self.remote_chat_open();
|| self.station_typing();
// Container screens use Escape, the inventory key and the hotbar and
// drop keys first; any other screen closes on Escape.
let screen_key = match self.screen {
@@ -194,15 +193,17 @@ impl Game<'_> {
}
return;
};
let received = r
.session
.counters
.lock()
.unwrap()
.as_ref()
.map(|c| c.bytes_in.load(std::sync::atomic::Ordering::Relaxed));
if let Some(total) = received {
self.debug_charts.log_bandwidth(total);
let received = r.session.counters.lock().unwrap().as_ref().map(|c| {
let order = std::sync::atomic::Ordering::Relaxed;
[
c.bytes_in.load(order),
c.sent.load(order),
c.received.load(order),
]
});
if let Some([bytes, sent, received]) = received {
self.debug_charts.log_bandwidth(bytes);
self.debug_charts.log_packets([sent, received]);
}
if shown {
use horizon_protocol::packets::{play::Serverbound, status::Ping};
@@ -403,8 +404,9 @@ impl Game<'_> {
],
);
}
// tps: DebugEntryTps for the integrated server (it counts no packets).
if self.player.is_some() && self.remote.is_none() {
// tps: DebugEntryTps for local and remote connections.
if self.player.is_some() {
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
let (target, status) = if self.clock.sprinting() {
("-".to_owned(), " (sprinting)")
} else if self.clock.stepping() {
@@ -417,10 +419,22 @@ impl Game<'_> {
} else {
(format!("{:.1}", 1000.0 / self.clock.rate()), "")
};
d.line(format!(
"Integrated server @ {:.1}/{target} ms{status}, 0 tx, 0 rx",
self.debug_charts.smoothed_tick_ms
));
if self.remote.is_none() || self.integrated.is_some() {
let tick_ms = self
.integrated
.as_ref()
.map_or(self.debug_charts.smoothed_tick_ms, |server| {
server.smoothed_tick_ms()
});
d.line(format!(
"Integrated server @ {tick_ms:.1}/{target} ms{status}, {tx} tx, {rx} rx",
));
} else if let Some(remote) = &self.remote {
d.line(format!(
"\"{}\" server{status}, {tx} tx, {rx} rx",
remote.brand
));
}
}
let (left, right) = d.columns(&self.debug_chart_lines());
let atlas = &self.atlas;
+122 -58
View File
@@ -9,11 +9,13 @@
use super::{Game, Options, lan::LanSettings};
use horizon_client::{
Result,
lan::{LoginGate, NetConfig, hub::HubConfig},
lan::{LoginGate, NetConfig, NetLog, OnlineMode, hub::HubConfig},
server::{
eula,
java_properties::now_millis,
profiles,
properties::{GAME_MODES, ServerProperties, SystemEntropy},
user_cache::UserCache,
user_lists::{
BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList,
format_date,
@@ -57,9 +59,26 @@ struct Setup {
started: Instant,
}
static SETUP: OnceLock<Mutex<Option<Setup>>> = OnceLock::new();
/// usercache.json (Services.nameToIdCache): used by the server thread only.
static USER_CACHE: Mutex<Option<UserCache>> = Mutex::new(None);
/// PlayerList.placeNewPlayer: a player who joined is cached (and the file saved).
pub(super) fn player_joined(id: u128, name: &str) {
if let Some(cache) = USER_CACHE.lock().unwrap().as_mut() {
cache.add(NameAndId {
id,
name: name.to_owned(),
});
}
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
}
/// A line from another of vanilla's threads (the login's User Authenticator).
fn log_from(thread: &str, level: &str, message: &str) {
let file_level = match level {
"WARN" => ::log::Level::Warn,
"ERROR" => ::log::Level::Error,
@@ -73,7 +92,7 @@ pub(super) fn log(level: &str, message: &str) {
});
let secs = now_millis().div_euclid(1000).rem_euclid(86_400);
println!(
"[{:02}:{:02}:{:02}] [Server thread/{level}]: {message}",
"[{:02}:{:02}:{:02}] [{thread}/{level}]: {message}",
secs / 3600,
secs / 60 % 60,
secs % 60
@@ -343,6 +362,14 @@ impl Game<'_> {
line.clear();
}
})?;
// Main: Services.create loads the universe's usercache.json.
let (mut cache, warnings) = UserCache::load(universe.join("usercache.json"));
for warning in warnings {
log_from("main", "WARN", &warning);
}
// DedicatedServer.initServer: names nobody has resolve offline only without authentication.
cache.resolve_offline_users = !props.online_mode;
*USER_CACHE.lock().unwrap() = Some(cache);
log("INFO", "Starting minecraft server version 26.3");
log("INFO", "Loading properties");
let game_mode = props.game_mode;
@@ -354,6 +381,19 @@ impl Game<'_> {
),
);
let port = port.unwrap_or(props.server_port as u16);
// MinecraftServer.initializeKeyPair (only online mode uses it).
log("INFO", "Generating keypair");
let online = if props.online_mode {
let key = horizon_protocol::rsa::RsaPrivateKey::generate(1024)
.map_err(|e| format!("Failed to generate key pair: {e}"))?;
Some(Arc::new(OnlineMode {
public_der: key.public_der(),
key,
prevent_proxy: props.prevent_proxy_connections,
}))
} else {
None
};
log(
"INFO",
&format!(
@@ -365,27 +405,21 @@ impl Game<'_> {
}
),
);
if props.online_mode {
// Authentication needs Mojang's session servers (an HTTPS client
// and RSA), which Horizon does not have yet: offline mode.
if online.is_none() {
log("WARN", "**** SERVER IS RUNNING IN OFFLINE/INSECURE MODE!");
log(
"WARN",
"online-mode=true is not supported by Horizon yet: running in offline mode",
"The server will make no attempt to authenticate usernames. Beware.",
);
log(
"WARN",
"While this makes the game possible to play without internet access, it also opens up the ability for hackers to connect with any username they choose.",
);
log(
"WARN",
"To change this, set \"online-mode\" to \"true\" in the server.properties file.",
);
}
log("WARN", "**** SERVER IS RUNNING IN OFFLINE/INSECURE MODE!");
log(
"WARN",
"The server will make no attempt to authenticate usernames. Beware.",
);
log(
"WARN",
"While this makes the game possible to play without internet access, it also opens up the ability for hackers to connect with any username they choose.",
);
log(
"WARN",
"To change this, set \"online-mode\" to \"true\" in the server.properties file.",
);
let mut lists = Lists::load();
lists.use_whitelist = props.white_list;
lists.enforce_whitelist = props.enforce_whitelist;
@@ -451,6 +485,12 @@ impl Game<'_> {
.any(|e| e.user.id == uuid && e.bypasses_player_limit)
})),
gate: Some(gate),
online,
log: Some(NetLog {
write: Arc::new(log_from),
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
log_ips: props.log_ips,
}),
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -463,6 +503,7 @@ impl Game<'_> {
seed: self.level.seed,
max_players: props.max_players.max(0) as usize,
dedicated: true,
integrated: false,
};
let settings = LanSettings {
port,
@@ -524,18 +565,12 @@ impl Game<'_> {
);
self.save_game()?;
}
self.update_dedicated_streaming();
self.update_world();
if paused {
// MinecraftServer.tickServer: only the connections run.
self.tick_lan();
} else {
if !state.autosave {
// `save-off`: the periodic save inside the tick is skipped.
self.ticks = self.ticks.max(1);
}
self.tick()?;
if !paused && !state.autosave {
// `save-off`: the periodic save inside the tick is skipped.
self.ticks = self.ticks.max(1);
}
// MinecraftServer.tickServer: while paused only the connections run.
self.tick_server(paused)?;
if idle_timeout > 0 {
self.kick_idle(idle_timeout);
}
@@ -576,7 +611,13 @@ impl Game<'_> {
let center = self
.lan
.as_ref()
.and_then(|h| h.hub.guests.iter().find(|g| g.placed).map(|g| g.chunk()))
.and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.map(|g| g.chunk())
})
.or_else(|| {
self.spawn
.map(|s| ((s.x.floor() as i32) >> 4, (s.z.floor() as i32) >> 4))
@@ -622,23 +663,36 @@ impl Game<'_> {
host.hub.resend_commands();
}
/// A known player by name: online first, else the offline profile.
fn resolve(&self, name: &str) -> NameAndId {
self.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|g| g.name.eq_ignore_ascii_case(name))
/// A player on the server, by name.
fn online_player(&self, name: &str) -> Option<NameAndId> {
let hub = &self.lan.as_ref()?.hub;
let g = hub
.guests
.iter()
.find(|g| g.name.eq_ignore_ascii_case(name))?;
Some(NameAndId {
id: g.uuid,
name: g.name.clone(),
})
}
/// GameProfileArgument for a name: the user cache, else the profile
/// service, else (offline) the offline player. None, told to the console:
/// no such player.
fn resolve(&self, name: &str) -> Option<NameAndId> {
let found = USER_CACHE.lock().unwrap().as_mut().and_then(|cache| {
cache.get_by_name(name, |lower| {
let found = profiles::find_by_name(&profiles::discovery_url(), lower);
if found.is_none() {
log("WARN", &format!("Couldn't find profile with name: {lower}"));
}
found
})
.map_or_else(
|| NameAndId::offline(name),
|g| NameAndId {
id: g.uuid,
name: g.name.clone(),
},
)
});
if found.is_none() {
self.console_reply(Text::translate("argument.player.unknown", &[]));
}
found
}
/// MinecraftServer.sendSystemMessage: what the console is told.
@@ -721,7 +775,9 @@ impl Game<'_> {
));
}
"op" | "deop" if !rest.is_empty() => {
let user = self.resolve(rest[0]);
let Some(user) = self.resolve(rest[0]) else {
return;
};
let level = 4;
let changed = {
let mut l = state.lists.lock().unwrap();
@@ -751,7 +807,9 @@ impl Game<'_> {
}
"whitelist" => self.console_whitelist(state, &rest),
"ban" if !rest.is_empty() => {
let user = self.resolve(rest[0]);
let Some(user) = self.resolve(rest[0]) else {
return;
};
let reason = (rest.len() > 1).then(|| rest[1..].join(" "));
let added = state.lists.lock().unwrap().bans.add(BanEntry::new(
user.clone(),
@@ -841,7 +899,9 @@ impl Game<'_> {
}
}
"pardon" if !rest.is_empty() => {
let user = self.resolve(rest[0]);
let Some(user) = self.resolve(rest[0]) else {
return;
};
let removed = state.lists.lock().unwrap().bans.remove(&user);
let key = if removed {
"commands.pardon.success"
@@ -907,15 +967,10 @@ impl Game<'_> {
}
}
"kick" if !rest.is_empty() => {
let user = self.resolve(rest[0]);
let online = self
.lan
.as_ref()
.is_some_and(|h| h.hub.guests.iter().any(|g| g.uuid == user.id));
if !online {
let Some(user) = self.online_player(rest[0]) else {
self.console_reply(Text::translate("argument.entity.notfound.player", &[]));
return;
}
};
let reason = if rest.len() > 1 {
Tag::String(JavaString::from(rest[1..].join(" ").as_str()))
} else {
@@ -995,7 +1050,9 @@ impl Game<'_> {
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
}
["add", name] | ["remove", name] => {
let user = self.resolve(name);
let Some(user) = self.resolve(name) else {
return;
};
let mut l = state.lists.lock().unwrap();
let (ok, key) = if rest[0] == "add" {
if l.white.contains(&user) {
@@ -1072,6 +1129,13 @@ impl Game<'_> {
for g in &h.hub.guests {
horizon_client::lan::playerdata::save(g, &self.world_root);
}
}
if let Some(h) = self.lan.as_ref() {
for g in &h.hub.guests {
self.save_guest_records(h, g);
}
}
if let Some(h) = self.lan.as_mut() {
let conns: Vec<u32> = h.hub.guests.iter().map(|g| g.conn).collect();
for conn in conns {
h.hub.kick(
+74 -51
View File
@@ -222,7 +222,7 @@ impl Game<'_> {
}
}
fn block_name(&self, p: [i32; 3]) -> std::sync::Arc<str> {
pub(super) fn block_name(&self, p: [i32; 3]) -> std::sync::Arc<str> {
self.res
.facts
.get(self.world.block(p[0], p[1], p[2]))
@@ -366,31 +366,11 @@ impl Game<'_> {
if self.travelling.is_some() || p.health.dead_or_dying() {
return Ok(());
}
let w = f64::from(p.dimensions.width) / 2.0;
let lo = [p.position.x - w, p.position.y, p.position.z - w];
let hi = [
p.position.x + w,
p.position.y + f64::from(p.dimensions.height),
p.position.z + w,
];
let mut portal = false;
let mut gateway = None;
for x in lo[0].floor() as i32..=hi[0].floor() as i32 {
for y in lo[1].floor() as i32..=hi[1].floor() as i32 {
for z in lo[2].floor() as i32..=hi[2].floor() as i32 {
let name = self.block_name([x, y, z]);
// EndPortalBlock entity-inside shape: y 6/16..12/16.
if &*name == "minecraft:end_portal"
&& lo[1] < f64::from(y) + 0.75
&& hi[1] > f64::from(y) + 0.375
{
portal = true;
} else if &*name == "minecraft:end_gateway" && self.dim == Dim::End {
gateway = Some([x, y, z]);
}
}
}
}
let (portal, gateway) = self.inside_end_portals(
p.position,
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
if portal {
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
@@ -417,6 +397,38 @@ impl Game<'_> {
Ok(())
}
/// Whether a `width` by `height` box at `position` is inside an End
/// portal (its entity-inside shape), and the End gateway it touches.
pub(super) fn inside_end_portals(
&self,
position: Vec3,
width: f64,
height: f64,
) -> (bool, Option<[i32; 3]>) {
let w = width / 2.0;
let lo = [position.x - w, position.y, position.z - w];
let hi = [position.x + w, position.y + height, position.z + w];
let mut portal = false;
let mut gateway = None;
for x in lo[0].floor() as i32..=hi[0].floor() as i32 {
for y in lo[1].floor() as i32..=hi[1].floor() as i32 {
for z in lo[2].floor() as i32..=hi[2].floor() as i32 {
let name = self.block_name([x, y, z]);
// EndPortalBlock entity-inside shape: y 6/16..12/16.
if &*name == "minecraft:end_portal"
&& lo[1] < f64::from(y) + 0.75
&& hi[1] > f64::from(y) + 0.375
{
portal = true;
} else if &*name == "minecraft:end_gateway" && self.dim == Dim::End {
gateway = Some([x, y, z]);
}
}
}
}
(portal, gateway)
}
/// EndPortalBlock.getPortalDestination for the pending enter_dimension.
pub(super) fn end_destination(&self, target: Dim) -> Option<Vec3> {
if !self.end_portal_travel {
@@ -451,10 +463,36 @@ impl Game<'_> {
self.previous_eye = 1.62;
self.clock.reset();
}
let mut position = target;
if let Some(job) = self.end.job.take() {
position = self.resolve_gateway(job, target)?;
} else if self.dim == Dim::End {
let position = if let Some(job) = self.end.job.take() {
self.resolve_gateway(job, target)?
} else {
let (position, yaw) = self.end_arrival(target)?;
if let Some(yaw) = yaw {
self.yaw = yaw;
player.yaw = yaw;
player.head_yaw = yaw;
}
position
};
player.position = position;
player.velocity = Vec3::ZERO;
player.fall_distance = 0.0;
self.previous_position = player.position;
self.player = Some(player);
self.pad.player_replaced();
self.sway = Default::default();
self.apply_equipment();
let pitch = self.world.random.next_float() * 0.4 + 0.8;
self.audio
.play("minecraft:block.portal.travel", None, 0.25, pitch);
Ok(())
}
/// Where an End portal's traveller lands once `target` has loaded, and
/// the yaw it faces there: the platform below the End's spawn point,
/// facing west, or the top of the Overworld's spawn column.
pub(super) fn end_arrival(&mut self, target: Vec3) -> Result<(Vec3, Option<f32>)> {
if self.dim == Dim::End {
// EndPlatformFeature.createEndPlatform below the spawn point.
let origin = [END_SPAWN[0], END_SPAWN[1] - 1, END_SPAWN[2]];
for dz in -2..=2 {
@@ -473,28 +511,13 @@ impl Game<'_> {
}
}
// Direction.WEST.toYRot().
self.yaw = 90.0;
player.yaw = 90.0;
player.head_yaw = 90.0;
} else {
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
let y = self
.land_column(target.x.floor() as i32, target.z.floor() as i32)
.map_or(target.y, f64::from);
position = Vec3::new(target.x, y, target.z);
return Ok((target, Some(90.0)));
}
player.position = position;
player.velocity = Vec3::ZERO;
player.fall_distance = 0.0;
self.previous_position = player.position;
self.player = Some(player);
self.pad.player_replaced();
self.sway = Default::default();
self.apply_equipment();
let pitch = self.world.random.next_float() * 0.4 + 0.8;
self.audio
.play("minecraft:block.portal.travel", None, 0.25, pitch);
Ok(())
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
let y = self
.land_column(target.x.floor() as i32, target.z.floor() as i32)
.map_or(target.y, f64::from);
Ok((Vec3::new(target.x, y, target.z), None))
}
/// Dying away from the Overworld respawns at the world spawn there.
+16 -3
View File
@@ -167,7 +167,10 @@ impl Game<'_> {
Snapshot {
screen: self.screen_label(),
dimension: self.dim.id().to_string(),
chat: self.ui.chat.received,
chat: self
.remote
.as_ref()
.map_or(self.ui.chat.received, |r| r.chat_received),
counts: if world.is_some() {
self.inventory_counts()
} else {
@@ -189,8 +192,18 @@ impl Game<'_> {
if let Some(before) = &self.events.last
&& *before != now
{
let new_chat = (now.chat - before.chat).min(100) as usize;
let lines = self.ui.chat.recent_lines(new_chat);
let new_chat = now.chat.saturating_sub(before.chat).min(100) as usize;
// The remote client keeps its own lines, oldest first.
let lines = match &self.remote {
Some(r) => r
.chat
.iter()
.rev()
.take(new_chat)
.map(|(t, _)| t.clone())
.collect(),
None => self.ui.chat.recent_lines(new_chat),
};
for event in diff(before, &now, &lines) {
self.events.push(self.ticks, event);
}
+5 -1
View File
@@ -58,8 +58,12 @@ impl Game<'_> {
pub(super) fn extras_scroll(&mut self, steps: f32) -> bool {
self.settings.extras.zoom && self.extras.zoom.scroll(steps)
}
/// Mouse buttons while playing, for the keystroke overlay.
/// Mouse buttons while playing, for zoom and the keystroke overlay.
pub(super) fn extras_mouse(&mut self, button: MouseButton, pressed: bool) {
let e = &self.settings.extras;
if e.zoom && e.zoom_key == keybinds::mouse_name(button) {
self.extras.zoom.key(pressed, false, true, e.zoom_level);
}
let i = match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
@@ -23,7 +23,7 @@ impl Game<'_> {
.guests
.iter()
.filter(|g| {
g.placed
host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.creative()
+7 -1
View File
@@ -55,7 +55,13 @@ impl Game<'_> {
.fov_player()
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
let target = self.zoomed_fov_target(target);
let eye = self.player.as_ref().map(|p| p.dimensions.eye_height);
let eye = self.player.as_ref().map(|p| {
if self.sleeping.is_some() {
0.2
} else {
p.dimensions.eye_height
}
});
self.view.tick(eye, target);
}
/// The player's `deathTime` while dead or dying.
+5 -2
View File
@@ -67,7 +67,7 @@ impl Game<'_> {
/// attack clicks since the last tick and the held attack button.
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
let attack_clicks = std::mem::take(&mut self.attack_clicks);
if self.screen == Screen::Playing && self.title.is_none() {
if self.screen == Screen::Playing && self.title.is_none() && self.sleeping.is_none() {
let mut clicks = std::mem::take(&mut self.clicks);
// keyAttack.isDown(); playing implies the grabbed mouse here
// (scripts under --control hold buttons without grabbing it).
@@ -409,7 +409,10 @@ impl HandWorld for GameHands<'_, '_> {
fn within_border(&mut self, _: [i32; 3]) -> bool {
true
}
fn attack_block(&mut self, _: [i32; 3]) {}
fn attack_block(&mut self, pos: [i32; 3]) {
let result = self.game.attack_block(pos);
self.keep(result);
}
fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
self.game.destroy_progress(pos)
}
+368 -40
View File
@@ -2,32 +2,34 @@
//! its own "Server thread", as vanilla's does, and the window joins it as a
//! player through memory, so a world tick never holds up a frame.
//!
//! Opt-in for now (`--server-thread` or `HORIZON_SERVER_THREAD=1`): the
//! player joins as a network player, and the server side of a network player
//! does not do everything single player does yet. Worlds whose player is
//! outside the Overworld open the usual way, since network players stay in
//! the Overworld so far.
//! The window asks its server for what only the server knows (Open to LAN,
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
//! the world in the frame instead (the escape hatch while the network
//! player's side catches up with the in-frame one).
use super::{
Dim, Game, Options, PackResources, Resources,
Game, Options, PackResources, Resources,
lan::{LanSettings, Owner},
};
use horizon_client::{
Result,
audio::Audio,
hud::HudAtlas,
lan::{MAX_PLAYERS, NetConfig, hub::HubConfig},
save::Level,
lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig},
settings::Settings,
title::WorldOptions,
};
use horizon_protocol::connection::Pipe;
use horizon_protocol::{crypto::offline_uuid, text::translatable};
use std::{
net::TcpListener,
path::Path,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{Sender, channel},
atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
mpsc::{Receiver, Sender, channel, sync_channel},
},
thread::JoinHandle,
time::{Duration, Instant},
@@ -39,18 +41,49 @@ struct Flags {
/// `Minecraft.pause`: a single-player pause screen is open.
paused: AtomicBool,
stop: AtomicBool,
/// `IntegratedServer.getPort`: the port it is open to the LAN on, 0
/// while it is not.
published: AtomicU16,
smoothed_tick_ms: AtomicU32,
}
/// Work the window hands its server thread (`MinecraftServer.execute`).
type Task = Box<dyn FnOnce(&mut Game<'_>) + Send>;
/// The window's handle on its server thread.
pub(super) struct Integrated {
flags: Arc<Flags>,
thread: Option<JoinHandle<()>>,
tasks: Sender<Task>,
/// The world's name (`level.dat`'s `LevelName`), for the pause menu.
pub world_name: String,
}
impl Integrated {
pub fn smoothed_tick_ms(&self) -> f32 {
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
}
/// `IntegratedServer.getPort` while `isPublished`.
pub fn published(&self) -> Option<u16> {
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
}
/// `executeBlocking`: run `f` on the server thread between two ticks and
/// wait for its answer; `None` once the server has stopped.
pub fn call<T: Send + 'static>(
&self,
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
) -> Option<T> {
let (tx, rx) = sync_channel(1);
self.tasks
.send(Box::new(move |g| {
let _ = tx.send(f(g));
}))
.ok()?;
rx.recv_timeout(Duration::from_secs(10)).ok()
}
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
pub fn stop(mut self) {
self.halt();
@@ -68,6 +101,84 @@ impl Drop for Integrated {
}
}
impl Game<'_> {
/// On the server thread, the window's agent runs a command as single
/// player's console does (`as_console`): as the world's owner, at the
/// owner permission level. The feedback goes to the owner's chat and is
/// returned.
pub(super) fn owner_console_command(&mut self, command: &str) -> Vec<String> {
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
return Vec::new();
};
let Some(mut host) = self.lan.take() else {
return Vec::new();
};
let mut out = Vec::new();
if let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.uuid == owner)
.map(|g| g.conn)
{
// The owner's level in place: between two ticks it is the one
// ticked last (Commands.performCommand runs in the player's level).
let dim = host.hub.guest(conn).and_then(|g| {
[super::Dim::Overworld, super::Dim::Nether, super::Dim::End]
.into_iter()
.find(|d| g.dimension == d.id())
});
if let Some(dim) = dim
&& let Err(e) = self.enter_level(Some(&mut host), dim)
{
log::error!("could not enter {}: {e}", dim.id());
}
let command = command.trim_start_matches('/').to_owned();
let lines = self.as_guest(&mut host, conn, |g| {
g.as_console(|g| {
g.chat_capture = Some(Vec::new());
g.run_command(&command);
g.chat_capture.take().unwrap_or_default()
})
});
self.sync_hub_settings(&mut host);
for line in lines.unwrap_or_default() {
out.push(line.plain());
host.hub.send_to(
conn,
&horizon_protocol::packets::play::Clientbound::SystemChat(
horizon_protocol::packets::play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
},
),
);
}
}
self.lan = Some(host);
out
}
}
/// The server thread's side of the window's handle (`Game::serving`).
pub(super) struct Serving {
flags: Arc<Flags>,
tasks: Receiver<Task>,
/// The window's profile.
owner: u128,
/// Open to LAN or closed from inside a player's command, while the hub
/// is out of its place: applied after the tick.
pending: Option<Publish>,
}
enum Publish {
Open(TcpListener, LanSettings),
Close,
}
/// What the server thread hands the window once the world is open: its end
/// of the connection and the world's name, or why it failed.
type Ready = std::result::Result<(Pipe, String), String>;
/// The world to open and the player who owns it.
struct Launch {
folder: String,
@@ -75,19 +186,32 @@ struct Launch {
seed: Option<i64>,
creative: bool,
create: Option<WorldOptions>,
/// The window's profile: the only one let in, and the world's player.
/// The window's profile: the only one let in until the world is open to
/// the LAN, and the world's player.
owner: u128,
/// Its name, for the LAN's MOTD.
player: String,
}
/// Whether this world opens on a server thread: asked for, and its player
/// (if any yet) in the Overworld.
pub(super) fn wanted(options: &Options, world: &Path) -> bool {
options.server_thread
&& options.dedicated.is_none()
&& Level::read(world).is_none_or(|l| Dim::from_id(&l.dimension) == Dim::Overworld)
/// Whether this world opens on a server thread: asked for.
pub(super) fn wanted(options: &Options, _world: &Path) -> bool {
options.server_thread && options.dedicated.is_none()
}
impl Game<'_> {
/// `Minecraft.hasSingleplayerServer`: a world of this window's own, run
/// in the frame or on its server thread.
pub(super) fn has_singleplayer_server(&self) -> bool {
self.remote.is_none() || self.integrated.is_some()
}
/// `IntegratedServer.isPublished`: the world is open to the network.
pub(super) fn is_published(&self) -> bool {
self.lan.is_some()
|| self
.integrated
.as_ref()
.is_some_and(|s| s.published().is_some())
}
/// `Minecraft.doWorldLoad`: start the server thread on the world, wait
/// until it can be joined, then join it through memory.
pub(super) fn open_integrated(
@@ -110,6 +234,7 @@ impl Game<'_> {
create: create.cloned(),
// An offline-mode server names its players by their offline profile.
owner: offline_uuid(&player),
player: player.clone(),
};
let mut options = self.options.clone();
// A server without a console: no local player, no window, no control socket.
@@ -127,6 +252,7 @@ impl Game<'_> {
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
let flags = Arc::new(Flags::default());
let (ready_tx, ready) = channel();
let (tasks, server_tasks) = channel();
let server_flags = flags.clone();
let thread = std::thread::Builder::new()
.name("Server thread".into())
@@ -137,7 +263,8 @@ impl Game<'_> {
options,
settings,
launch,
&server_flags,
server_flags,
server_tasks,
&ready_tx,
);
if let Err(e) = result {
@@ -145,12 +272,17 @@ impl Game<'_> {
let _ = ready_tx.send(Err(e.to_string()));
}
})?;
let handle = Integrated {
let mut handle = Integrated {
flags,
thread: Some(thread),
tasks,
world_name: String::new(),
};
let pipe = match ready.recv() {
Ok(Ok(pipe)) => pipe,
Ok(Ok((pipe, world_name))) => {
handle.world_name = world_name;
pipe
}
Ok(Err(e)) => return Err(e.into()),
Err(_) => return Err("the integrated server stopped".into()),
};
@@ -164,8 +296,9 @@ impl Game<'_> {
fn run_integrated(
&mut self,
launch: Launch,
flags: &Flags,
ready: &Sender<std::result::Result<Pipe, String>>,
flags: Arc<Flags>,
tasks: Receiver<Task>,
ready: &Sender<Ready>,
) -> Result<()> {
self.open_world(
&launch.folder,
@@ -178,7 +311,8 @@ impl Game<'_> {
let view_distance = self.settings.render_distance.clamp(2, 32);
let config = HubConfig {
default_game_mode: self.level.mode(),
guest_commands: true,
// Only the owner until it is opened to the LAN.
guest_commands: false,
force_game_mode: false,
hardcore: self.level.hardcore,
view_distance,
@@ -188,23 +322,28 @@ impl Game<'_> {
max_players: MAX_PLAYERS,
// No player of its own: the hub runs without a host.
dedicated: true,
integrated: true,
};
let owner = launch.owner;
let published = flags.clone();
let net = NetConfig {
// No network until it is opened to the LAN.
// No network until it is opened to the LAN; the memory connection
// is not compressed (`Connection.isMemoryConnection`).
listen: false,
// A memory connection is not compressed (`Connection.isMemoryConnection`).
compression_threshold: -1,
enable_status: false,
announce: false,
gate: Some(Arc::new(move |_, uuid, _| {
(uuid != owner).then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
})),
..NetConfig::lan(0, self.level.name.clone(), MAX_PLAYERS)
// IntegratedServer.initServer: the MOTD names the owner and the world.
..NetConfig::lan(
0,
format!("{} - {}", launch.player, self.level.name),
MAX_PLAYERS,
)
};
let settings = LanSettings {
port: 0,
guest_commands: true,
guest_commands: false,
force_game_mode: false,
};
self.lan_host(settings, net, config, None)?;
@@ -223,18 +362,30 @@ impl Game<'_> {
});
host.hub.lan.connect_local()?
};
let _ = ready.send(Ok(pipe));
self.serving = Some(Serving {
flags: flags.clone(),
tasks,
owner,
pending: None,
});
let _ = ready.send(Ok((pipe, self.level.name.clone())));
self.settle_spawn()?;
let mut next = Instant::now();
while !flags.stop.load(Ordering::Relaxed) {
self.update_dedicated_streaming();
self.update_world();
self.run_tasks();
if flags.paused.load(Ordering::Relaxed) {
// Only the connections run.
self.tick_lan();
self.tick_server(true)?;
} else {
self.tick()?;
let started = Instant::now();
self.tick_server(false)?;
self.debug_charts.log_tick(started, started.elapsed());
flags.smoothed_tick_ms.store(
self.debug_charts.smoothed_tick_ms.to_bits(),
Ordering::Relaxed,
);
}
self.apply_publish();
let rate = self.clock.rate().max(1.0);
next += Duration::from_secs_f64(1.0 / f64::from(rate));
let now = Instant::now();
@@ -250,6 +401,10 @@ impl Game<'_> {
/// `MinecraftServer.stopServer`: the players are saved and sent away,
/// then the world is saved.
fn stop_integrated(&mut self) -> Result<()> {
// IntegratedServer.stopServer: teardownPublishedState.
if let Some(s) = self.serving.take() {
s.flags.published.store(0, Ordering::Relaxed);
}
if let Some(mut host) = self.lan.take() {
for g in &host.hub.guests {
self.save_guest(&host, g);
@@ -272,18 +427,191 @@ impl Game<'_> {
}
}
/// What the world options screen shows of an integrated server.
#[derive(Clone, Copy, Debug)]
pub(super) struct ServerOptions {
pub default_mode: i32,
/// The owner's own game mode.
pub personal_mode: i32,
pub cheats: bool,
pub difficulty: u8,
pub hardcore: bool,
pub difficulty_locked: bool,
pub port: Option<u16>,
pub guest_commands: bool,
pub force: bool,
}
/// The server thread's side.
impl Game<'_> {
/// Whether this game is an integrated server's.
pub(super) fn serving(&self) -> bool {
self.serving.is_some()
}
/// The window's requests, between two ticks.
fn run_tasks(&mut self) {
let tasks: Vec<Task> = match &self.serving {
Some(s) => s.tasks.try_iter().collect(),
None => return,
};
for task in tasks {
task(self);
self.apply_publish();
}
}
/// `IntegratedServer.getPort` while published.
pub(super) fn published_port(&self) -> Option<u16> {
let s = self.serving.as_ref()?;
Some(s.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
}
/// `IntegratedServer.publishServer`: listen on `settings.port` (0 picks
/// one) and announce the world. The socket is bound at once so a busy
/// port fails here; the hub starts listening once it is at hand.
pub(super) fn publish(&mut self, settings: LanSettings) -> std::result::Result<u16, String> {
let Some(s) = self.serving.as_mut() else {
return Err("not an integrated server".into());
};
if s.flags.published.load(Ordering::Relaxed) != 0 {
return Err("already published".into());
}
let listener = Lan::bind(None, settings.port).map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
log::info!("Published LAN server on port {port}");
s.flags.published.store(port, Ordering::Relaxed);
s.pending = Some(Publish::Open(listener, LanSettings { port, ..settings }));
self.apply_publish();
Ok(port)
}
/// `IntegratedServer.unpublishServer`: stop listening and send every
/// player but the owner away. False when it was not published.
pub(super) fn unpublish(&mut self) -> bool {
let Some(s) = self.serving.as_mut() else {
return false;
};
if s.flags.published.swap(0, Ordering::Relaxed) == 0 {
return false;
}
s.pending = Some(Publish::Close);
self.apply_publish();
true
}
/// Publish or unpublish for real, once the hub is in its place (a
/// player's `/publish` runs while it is out).
fn apply_publish(&mut self) {
if self.lan.is_none() {
return;
}
let Some(s) = self.serving.as_mut() else {
return;
};
let (Some(pending), owner) = (s.pending.take(), s.owner) else {
return;
};
let allow_commands = self.allow_commands();
let Some(host) = self.lan.as_mut() else {
return;
};
match pending {
Publish::Open(listener, settings) => {
if let Err(e) = host.hub.lan.listen_on(listener, true) {
log::error!("Horizon: Open to LAN: {e}");
}
host.settings = settings;
host.hub.config.force_game_mode = settings.force_game_mode;
// IntegratedServer.setGuestCommandAccess, then updatePermissions.
host.hub.config.guest_commands = settings.guest_commands && allow_commands;
host.hub.resend_commands();
}
Publish::Close => {
host.hub.lan.stop_listening();
log::info!(
"Unpublishing integrated server (was on port {})",
host.settings.port
);
let others: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.uuid != owner)
.map(|g| g.conn)
.collect();
for conn in others {
host.hub.kick(
conn,
translatable("multiplayer.disconnect.server_shutdown", &[]),
);
}
}
}
}
/// The world options screen's state, read on the server.
pub(super) fn server_options(&self) -> ServerOptions {
let owner = self.serving.as_ref().map(|s| s.owner);
let host = self.lan.as_ref();
let personal_mode = host
.and_then(|h| h.hub.guests.iter().find(|g| Some(g.uuid) == owner))
.map_or(self.level.mode(), |g| g.game_mode);
let published = self.published_port();
ServerOptions {
default_mode: self.level.mode(),
personal_mode,
cheats: self.allow_commands(),
difficulty: self.ui.difficulty,
hardcore: self.level.hardcore,
difficulty_locked: self.level.difficulty_locked,
port: published,
guest_commands: published.is_some() && host.is_some_and(|h| h.settings.guest_commands),
force: published.is_none() || host.is_some_and(|h| h.settings.force_game_mode),
}
}
/// `IntegratedServer.setPersonalGameType`: the owner's game mode.
pub(super) fn set_owner_mode(&mut self, mode: i32) {
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
return;
};
if let Some(host) = self.lan.as_mut()
&& let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.uuid == owner)
.map(|g| g.conn)
{
host.hub.set_game_mode(conn, mode);
}
}
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
/// while the world allows commands; everyone's level is sent again.
pub(super) fn owner_allow_commands(&mut self, allow: bool) {
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
return;
};
let published = self.published_port().is_some();
if let Some(host) = self.lan.as_mut() {
host.hub.ops.insert(owner, if allow { 4 } else { 0 });
if published {
host.hub.config.guest_commands = allow && host.settings.guest_commands;
}
host.hub.resend_commands();
}
}
}
/// The server thread: a game of its own over the shared resources.
#[allow(clippy::too_many_arguments)]
fn serve(
res: &Arc<Resources>,
pack: PackResources,
options: Options,
settings: Settings,
launch: Launch,
flags: &Flags,
ready: &Sender<std::result::Result<Pipe, String>>,
flags: Arc<Flags>,
tasks: Receiver<Task>,
ready: &Sender<Ready>,
) -> Result<()> {
let atlas = HudAtlas::build(&options.resources)?;
// It draws nothing: only the font, which books and chat measure with.
let atlas = HudAtlas::fonts(&options.resources)?;
let audio = Audio::silent(&options.resources);
let mut server = Game::new(res, pack, atlas, options, settings, audio)?;
server.run_integrated(launch, flags, ready)
server.run_integrated(launch, flags, tasks, ready)
}
+235 -40
View File
@@ -31,7 +31,7 @@ use std::{
static SYNC: OnceLock<std::result::Result<Arc<SyncData>, String>> = OnceLock::new();
/// The settings of the open world (WorldOptionsScreen's multiplayer section).
#[derive(Clone, Debug)]
#[derive(Clone, Copy, Debug)]
pub struct LanSettings {
pub port: u16,
pub guest_commands: bool,
@@ -52,11 +52,15 @@ pub(super) struct LanHost {
/// Blocks broken this tick (their sound is a level event for the guests).
pub broken: Vec<[f64; 3]>,
/// Hurt and death state of the mobs guests see.
mob_state: HashMap<u32, (i32, bool)>,
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool)>>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
/// An integrated server's owner (game/integrated.rs).
pub owner: Option<Owner>,
/// The players' locator bars (game/lan_waypoints.rs), and the guests who
/// are dead, whose respawn replaces their waypoint.
pub waypoints: lan::waypoints::Waypoints,
pub waypoint_dead: std::collections::HashSet<u128>,
}
/// The player who owns an integrated server's world: a guest whose state is
@@ -316,6 +320,15 @@ impl Game<'_> {
if words[0] != "lan" {
return Ok(None);
}
if let Some(server) = &self.integrated
&& words.get(1) == Some(&"status")
{
// The hub is the server thread's.
let status = server
.call(|g| g.lan_status())
.ok_or("the integrated server stopped")?;
return Ok(Some(status));
}
match words.get(1).copied() {
Some("open") => {
if self.title.is_some() {
@@ -337,43 +350,58 @@ impl Game<'_> {
self.lan_close(true);
Ok(Some(json!({ "ok": true })))
}
Some("status") => Ok(Some(match &self.lan {
Some(h) => json!({
"open": true,
"host": h.host_name,
"port": h.settings.port,
"guests": h.hub.guests.iter().map(|g| json!({
"name": g.name,
"position": g.position,
"placed": g.placed,
"loaded": g.loaded,
"game_mode": g.game_mode,
"sneaking": g.sneaking,
"health": g.health,
// FoodData: food level, saturation and exhaustion.
"food": [
f64::from(g.food),
f64::from(g.saturation),
g.motion.as_ref().map_or(0.0, |m| f64::from(m.health.food.exhaustion)),
],
"chunks": g.sent_chunks.len(),
"tracked": g.tracker.len(),
// The armour the host draws on this guest, head to feet.
"model_armor": h.models.get(&g.conn).map(|m| {
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
.map(|s| s.as_deref())
}),
})).collect::<Vec<_>>(),
}),
None => json!({ "open": false }),
})),
Some("status") => Ok(Some(self.lan_status())),
_ => Err("lan open|close|status".into()),
}
}
/// `horizon-ctl lan status`.
fn lan_status(&self) -> serde_json::Value {
use serde_json::json;
match &self.lan {
Some(h) if !self.serving() || self.published_port().is_some() => json!({
"open": true,
"host": h.host_name,
"port": h.settings.port,
"guests": h.hub.guests.iter().map(|g| json!({
"name": g.name,
"position": g.position,
"placed": g.placed,
"loaded": g.loaded,
"game_mode": g.game_mode,
"sneaking": g.sneaking,
"health": g.health,
// FoodData: food level, saturation and exhaustion.
"food": [
f64::from(g.food),
f64::from(g.saturation),
g.motion.as_ref().map_or(0.0, |m| f64::from(m.health.food.exhaustion)),
],
"chunks": g.sent_chunks.len(),
"tracked": g.tracker.len(),
// The armour the host draws on this guest, head to feet.
"model_armor": h.models.get(&g.conn).map(|m| {
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
.map(|s| s.as_deref())
}),
})).collect::<Vec<_>>(),
}),
_ => json!({ "open": false }),
}
}
pub(super) fn lan_open(&mut self, settings: LanSettings) -> Result<()> {
self.lan_start(settings)?;
let port = self.lan.as_ref().map_or(0, |l| l.settings.port);
let port = if let Some(server) = &self.integrated {
// ShareToLanScreen: IntegratedServer.publishServer.
server
.call(move |g| g.publish(settings))
.ok_or("the integrated server stopped")??
} else if self.serving() {
return self.publish(settings).map(drop).map_err(Into::into);
} else {
self.lan_start(settings)?;
self.lan.as_ref().map_or(0, |l| l.settings.port)
};
self.system_message(Text::translate(
"menu.multiplayerOptions.publish.started.lan",
&[Text::literal(port.to_string())],
@@ -408,6 +436,7 @@ impl Game<'_> {
seed: self.level.seed,
max_players: MAX_PLAYERS,
dedicated: false,
integrated: false,
};
let net = NetConfig::lan(settings.port, motd, MAX_PLAYERS);
self.lan_host(settings, net, config, Some(host_uuid))
@@ -477,6 +506,8 @@ impl Game<'_> {
mob_state: HashMap::new(),
mob_players: HashMap::new(),
owner: None,
waypoints: Default::default(),
waypoint_dead: Default::default(),
});
self.audio.capture = Some(Vec::new());
Ok(())
@@ -484,6 +515,22 @@ impl Game<'_> {
/// IntegratedServer.unpublishServer: the guests are sent away.
pub(super) fn lan_close(&mut self, announce: bool) {
if self.integrated.is_some() || self.serving() {
let closed = match &self.integrated {
Some(server) => server.call(|g| g.unpublish()).unwrap_or(false),
None => self.unpublish(),
};
if closed {
self.friends_lan_closed();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
"menu.multiplayerOptions.publish.stopped",
&[],
));
}
return;
}
self.friends_lan_closed();
let Some(mut host) = self.lan.take() else {
return;
@@ -516,6 +563,75 @@ impl Game<'_> {
} else {
playerdata::save(guest, &self.world_root);
}
self.save_guest_records(host, guest);
}
/// The file name of a player's advancements and statistics: an
/// integrated server's owner keeps single player's.
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
if host.owner.as_ref().is_some_and(|o| o.uuid == uuid) {
self.level.player_uuid
} else {
Some(playerdata::uuid_ints(uuid))
}
}
/// `PlayerAdvancements.save` and `ServerStatsCounter.save` of a network player.
pub(super) fn save_guest_records(
&self,
host: &LanHost,
guest: &horizon_client::lan::guest::Guest,
) {
if !guest.placed || self.adv.tree.list.is_empty() {
return;
}
let Some(uuid) = self.records_uuid(host, guest.uuid) else {
return;
};
let path = super::stats::player_json(&self.world_root, "advancements", uuid);
let json = guest
.advancements
.to_json(&self.adv.tree, horizon_world::DATA_VERSION);
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(text) = serde_json::to_string_pretty(&json) {
let _ = std::fs::write(path, text);
}
if let Some(play) = host.play.get(&guest.conn) {
play.stats
.write(&super::stats::player_json(&self.world_root, "stats", uuid));
}
}
/// `PlayerAdvancements.load` and `ServerStatsCounter.parseLocal` of a
/// network player who joined; every statistic goes out on the first
/// request (`PlayerList.placeNewPlayer`'s `markAllDirty`).
fn load_guest_records(&mut self, host: &mut LanHost, conn: u32) {
let Some(uuid) = host
.hub
.guest(conn)
.and_then(|g| self.records_uuid(host, g.uuid))
else {
return;
};
let tree = self.advancement_tree();
let path = super::stats::player_json(&self.world_root, "advancements", uuid);
let saved = std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok());
if let Some(g) = host.hub.guest_mut(conn)
&& let Some(json) = saved
{
let mut progress = horizon_client::advancements::Progress::from_json(&json);
// PlayerAdvancements.applyFrom: advancements this server lacks are dropped.
progress
.criteria
.retain(|id, _| tree.by_id.contains_key(id));
g.advancements = progress;
g.advancements_loaded = true;
}
let mut stats =
super::stats::Stats::read(&super::stats::player_json(&self.world_root, "stats", uuid));
stats.mark_all_dirty();
host.play.entry(conn).or_default().stats = stats;
}
/// The integrated server's owner into the world's player, for `Level::write`.
pub(super) fn store_owner(&mut self) {
@@ -527,6 +643,7 @@ impl Game<'_> {
};
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
playerdata::store(g, &mut self.level);
self.save_guest_records(host, g);
}
}
@@ -539,18 +656,37 @@ impl Game<'_> {
}
}
/// What a command changed while the hub was out (`as_guest`), back on
/// the hub: `defaultgamemode` run by a guest or the integrated server's owner.
pub(super) fn sync_hub_settings(&self, host: &mut LanHost) {
host.hub.config.default_game_mode = self.level.mode();
}
/// New command access and game mode forcing settings of the open world.
pub(super) fn lan_apply_settings(&mut self, guest_commands: bool, force: bool) {
if let Some(server) = &self.integrated {
server.call(move |g| g.lan_apply_settings(guest_commands, force));
return;
}
// An integrated server gives guests commands only where the world
// allows them (IntegratedServer.getCustomPermissionLevel).
let effective = guest_commands && (!self.serving() || self.allow_commands());
if let Some(host) = self.lan.as_mut() {
host.settings.guest_commands = guest_commands;
host.settings.force_game_mode = force;
host.hub.config.guest_commands = guest_commands;
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
host.hub.resend_commands();
}
}
pub(super) fn lan_port(&self) -> Option<u16> {
if let Some(server) = &self.integrated {
return server.published();
}
if self.serving() {
return self.published_port();
}
self.lan.as_ref().map(|h| h.settings.port)
}
@@ -630,6 +766,8 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return;
};
// What the tick sends a guest goes to its connection at the end.
let holding = lan::Holding::start();
let state = self.lan_host_state(&host);
// A dedicated server runs the hub without a host player.
if state.is_some() || host.hub.config.dedicated {
@@ -657,7 +795,19 @@ impl Game<'_> {
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
};
host.hub.tick(&mut ctx)
// A server with several levels runs a pass for each
// (`tick_server`): the network is read once, before the first.
let pass = self.level_pass;
let mut events = if pass.first {
host.hub.poll(&mut ctx)
} else {
Vec::new()
};
events.extend(host.hub.tick_level(&mut ctx));
if pass.last {
host.hub.end_tick();
}
events
};
if let Some(state) = &state
&& host.host_mode != state.game_mode
@@ -673,6 +823,7 @@ impl Game<'_> {
&& let Some(g) = host.hub.guest(conn)
{
// PlayerList.placeNewPlayer, then the broadcast join message.
super::dedicated::player_joined(g.uuid, &g.name);
let [x, y, z] = g.position;
super::dedicated::log(
"INFO",
@@ -697,11 +848,20 @@ impl Game<'_> {
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
self.container_openers_changed(pos, false);
// In the level they were in.
let back = self.dim;
let theirs = super::Dim::from_id(&guest.dimension);
if self.enter_level(Some(&mut host), theirs).is_ok() {
self.container_openers_changed(pos, false);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
}
self.lan_crafting_left(&mut host, &mut guest);
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
if self.options.dedicated.is_some() && guest.placed {
super::dedicated::log(
"INFO",
@@ -726,9 +886,15 @@ impl Game<'_> {
}
self.lan_flush_effects(&mut host);
self.tick_guests(&mut host);
self.tick_server_travel(&mut host);
self.lan_swings(&mut host);
self.lan_update_streaming(&host);
self.lan_update_models(&mut host);
self.lan_update_waypoints(&mut host, state.as_ref());
drop(holding);
for g in &host.hub.guests {
g.sender.flush();
}
self.lan = Some(host);
}
@@ -743,6 +909,7 @@ impl Game<'_> {
let default_mode = host.hub.config.default_game_mode;
let force = host.hub.config.force_game_mode;
let root = self.world_root.clone();
let in_place = host.hub.config.dimension.clone();
let owner = host.owner.as_mut();
let Some(g) = host.hub.guest_mut(conn) else {
return;
@@ -765,7 +932,28 @@ impl Game<'_> {
g.previous_position = spawn;
g.health = 20.0;
g.food = 20;
g.dimension = super::Dim::Overworld.id().to_owned();
}
// A server places each player in their own level
// (`PlayerList.placeNewPlayer`); the in-frame game has one.
let back = self.dim;
let level = super::Dim::from_id(&g.dimension);
let elsewhere = self.options.dedicated.is_some() && level != back;
if !elsewhere {
g.dimension = in_place;
}
self.load_guest_records(host, conn);
let theirs;
let entities = if elsewhere {
if let Err(e) = self.enter_level(Some(host), level) {
log::error!("could not open {}: {e}", level.id());
return;
}
theirs = self.lan_entities(&mut host.hub);
&theirs[..]
} else {
entities
};
let mut ctx = TickCtx {
world: &mut self.world,
host: state,
@@ -779,6 +967,9 @@ impl Game<'_> {
tick_frozen: self.clock.frozen(),
};
host.hub.place_new_player(conn, &mut ctx);
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
}
/// Level events the game queued, for the guests (`process_level_events`).
@@ -833,7 +1024,11 @@ impl Game<'_> {
let mut seen = HashMap::new();
for m in &self.mobs {
let dead = !m.alive();
let old = host.mob_state.get(&m.id).copied();
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&m.id))
.copied();
let at = [m.position.x, m.position.y, m.position.z];
if m.hurt_time > old.map_or(0, |o| o.0) && !dead {
host.hub.entity_event(m.id as i32, 2, at);
@@ -843,7 +1038,7 @@ impl Game<'_> {
}
seen.insert(m.id, (m.hurt_time, dead));
}
host.mob_state = seen;
host.mob_state.insert(self.dim, seen);
}
/// Keep the chunks around every guest loaded.
@@ -852,7 +1047,7 @@ impl Game<'_> {
.hub
.guests
.iter()
.filter(|g| g.placed)
.filter(|g| host.hub.here(g))
// The tracked square's corners and their lit neighbours lie
// within (view distance + 2) * √2 of the player's chunk.
.map(|g| (g.chunk(), (g.view_distance + 2) * 3 / 2 + 1))
+407 -43
View File
@@ -13,6 +13,7 @@ use horizon_client::{
items::ItemStack,
lan::{
guest::SPECTATOR,
hub::TickCtx,
menu::{Rules, Slots},
},
motion::{MotionEvent, MotionWorld, PlayerMotion},
@@ -36,6 +37,11 @@ pub(super) struct GuestPlay {
swing: horizon_client::first_person::SwingState,
/// The block being dug and the face the client aimed at.
digging: Option<([i32; 3], [i32; 3])>,
/// The server tick the digging started (ServerPlayerGameMode.destroyProgressStart).
dig_start: u64,
/// A block the client stopped digging before the server's time was up: it breaks once
/// its progress since `dig_start` is complete (ServerPlayerGameMode.hasDelayedDestroy).
delayed: Option<([i32; 3], u64)>,
last_progress: i32,
/// Hand movements the client announced and the host animated.
swung: bool,
@@ -43,6 +49,13 @@ pub(super) struct GuestPlay {
pub(super) merchant: Option<super::lan_trading::GuestMerchant>,
/// The inventory menu and the crafting table the guest crafts in.
pub(super) crafting: super::lan_crafting::GuestCrafting,
/// What the guest's body was given for its effects and enchantments.
potions: super::potions::BodyPotions,
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
pub(super) stats: super::stats::Stats,
last_inventory: u64,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
}
struct ItemRules<'a>(&'a horizon_client::items::Items);
@@ -92,7 +105,11 @@ impl Game<'_> {
let mut play = host.play.remove(&conn).unwrap_or_default();
let g = &mut host.hub.guests[i];
// The guest's body.
let mut motion = g.motion.take()?;
let Some(mut motion) = g.motion.take() else {
host.play.insert(conn, play);
return None;
};
self.potions.swap_player(&mut g.effects, &mut play.potions);
motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]);
motion.ground.on_ground = g.on_ground;
motion.yaw = g.yaw;
@@ -125,13 +142,20 @@ impl Game<'_> {
total: g.xp_total,
},
);
let sneak = self.keys.contains(&KeyCode::ShiftLeft);
// The host's held keys stay theirs: the guest holds only sneak, and a
// screen the guest opens (`keys.clear()`) leaves the host walking.
let host_keys = std::mem::take(&mut self.keys);
if g.sneaking {
self.keys.insert(KeyCode::ShiftLeft);
} else {
self.keys.remove(&KeyCode::ShiftLeft);
}
// The window's screens are the host's: a sign, book, command block or
// mount menu the guest's action opens is not drawn over the host's
// world (#687). The guest's client opens its own.
let screen = std::mem::replace(&mut self.screen, Screen::Playing);
let host_sign = self.sign_edit.take();
let host_book = self.book.take();
let host_command_block = self.command_block_screen.take();
let host_mount_menu = std::mem::take(&mut self.mount_menu);
// The host's crafting grid stays theirs; a crafting table the guest
// opens shows as the 3x3 size.
let craft_size = std::mem::replace(&mut self.craft_size, 2);
@@ -153,6 +177,15 @@ impl Game<'_> {
let host_ender = self.ender_open;
self.close_ender_chest();
std::mem::swap(&mut self.ender_items, &mut g.ender_items);
// The guest's advancements, recipe book and statistics.
self.advancement_tree();
std::mem::swap(&mut self.adv.progress, &mut g.advancements);
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.stats, &mut play.stats);
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
self.lan_acting = Some(conn);
self.lan_acting_name = Some(g.name.clone());
self.lan_acting_uuid = Some(g.uuid);
@@ -181,13 +214,23 @@ impl Game<'_> {
play.swing = std::mem::replace(&mut self.swing, swing);
self.container = container;
self.screen = screen;
if sneak {
self.keys.insert(KeyCode::ShiftLeft);
} else {
self.keys.remove(&KeyCode::ShiftLeft);
}
self.sign_edit = host_sign;
self.book = host_book;
self.command_block_screen = host_command_block;
self.mount_menu = host_mount_menu;
self.keys = host_keys;
let g = &mut host.hub.guests[i];
std::mem::swap(&mut self.ender_items, &mut g.ender_items);
std::mem::swap(&mut self.adv.progress, &mut g.advancements);
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.stats, &mut play.stats);
self.adv.loaded = adv_loaded;
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
let stat_events = std::mem::take(&mut play.stats.events);
let name = g.name.clone();
self.potions.swap_player(&mut g.effects, &mut play.potions);
if let Some(key) = host_ender {
self.world
.block_entities
@@ -231,6 +274,7 @@ impl Game<'_> {
host.hub.set_game_mode(conn, new_mode);
}
host.play.insert(conn, play);
self.stat_criteria_changed(&name, stat_events);
if let Some(pos) = opened
&& !self.lan_open_container(host, conn, pos)
&& !host.hub.guests[i].spectator()
@@ -247,6 +291,32 @@ impl Game<'_> {
Some(result)
}
/// `ServerLevel.players()` in turn: the host's own player, then each
/// guest in the level in place with their state swapped in (`as_guest`).
/// What reaches the players around a point (a beacon's powers, a splash
/// potion, a warden's darkness, an elder guardian's curse) lands on each
/// of them, not only on the host. A server has no player of its own.
/// Inside a guest's action (the hub taken) only that player is in place.
pub(super) fn for_each_player(&mut self, mut f: impl FnMut(&mut Self)) {
if self.player.is_some() {
f(self);
}
let Some(mut host) = self.lan.take() else {
return;
};
let conns: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| g.conn)
.collect();
for conn in conns {
self.as_guest(&mut host, conn, &mut f);
}
self.lan = Some(host);
}
/// The game mode fields without `set_mode`'s side effects on the host.
fn set_mode_raw(&mut self, id: i32) {
use horizon_client::command::args::GameMode;
@@ -274,6 +344,14 @@ impl Game<'_> {
"container.enderchest",
Slots::Ender,
)
} else if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) {
// AbstractFurnaceBlock.openContainer: FurnaceMenu, BlastFurnaceMenu, SmokerMenu.
let (menu, title) = match f.kind.recipe_type() {
"minecraft:blasting" => ("minecraft:blast_furnace", "container.blast_furnace"),
"minecraft:smoking" => ("minecraft:smoker", "container.smoker"),
_ => ("minecraft:furnace", "container.furnace"),
};
(menu, title, Slots::Block(pos))
} else if let Some((other, right)) = self.chest_partner(pos) {
// ChestBlock.combine: the right half's slots come first.
let halves = if right {
@@ -350,35 +428,41 @@ impl Game<'_> {
let face = direction(i32::from(a.direction));
let play = host.play.entry(conn).or_default();
play.digging = Some((a.pos, face));
play.dig_start = host.hub.ticks;
play.last_progress = -1;
// The pause between two blocks is the client's (MultiPlayerGameMode's
// destroyDelay); the server digs from the START it receives.
play.destroy_delay = 0;
}
1 => {}
2 | 3 => {
3 => {
host.hub.ack(conn, a.sequence);
self.guest_stop_digging(host, conn, a.pos);
}
2 => {
host.hub.ack(conn, a.sequence);
let play = host.play.entry(conn).or_default();
play.digging = None;
play.mining = None;
if a.action == 2 {
let state = self.world.block(a.pos[0], a.pos[1], a.pos[2]).0 as i32;
host.hub.send_to(
conn,
&C::BlockUpdate(play::BlockUpdate { pos: a.pos, state }),
);
host.hub.broadcast_near(
[
f64::from(a.pos[0]),
f64::from(a.pos[1]),
f64::from(a.pos[2]),
],
64.0,
&C::BlockDestruction(play::BlockDestruction {
id: host.hub.guests[gi].entity_id,
pos: a.pos,
progress: 255,
}),
Some(conn),
);
}
let state = self.world.block(a.pos[0], a.pos[1], a.pos[2]).0 as i32;
host.hub.send_to(
conn,
&C::BlockUpdate(play::BlockUpdate { pos: a.pos, state }),
);
host.hub.broadcast_near(
[
f64::from(a.pos[0]),
f64::from(a.pos[1]),
f64::from(a.pos[2]),
],
64.0,
&C::BlockDestruction(play::BlockDestruction {
id: host.hub.guests[gi].entity_id,
pos: a.pos,
progress: 255,
}),
Some(conn),
);
}
4 | 5 => {
if !spectator {
@@ -395,10 +479,15 @@ impl Game<'_> {
}
});
}
7 => {
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
7 if !spectator => {
let g = &mut host.hub.guests[gi];
let slot = g.selected;
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
if let Some(play) = host.play.get_mut(&conn) {
play.using = None;
}
}
_ => {}
},
@@ -474,6 +563,7 @@ impl Game<'_> {
S::ContainerClick(p) if self.lan_merchant_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_inventory_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_crafting_click(host, conn, &p) => {}
S::ContainerClick(p) if self.lan_furnace_click(host, conn, &p) => {}
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
let g = &mut host.hub.guests[gi];
@@ -507,6 +597,70 @@ impl Game<'_> {
}
}
S::ChatCommand(c) => self.guest_command(host, conn, &c.command),
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
self.ui.difficulty = if self.level.hardcore {
3
} else {
p.difficulty as u8
};
self.sync_rules();
if self.ui.difficulty == 0 {
self.mobs.retain(|m| !m.hostile());
}
host.hub
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
difficulty: i32::from(self.ui.difficulty),
locked: self.level.difficulty_locked,
}));
}
}
S::LockDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
self.level.difficulty_locked = p.locked;
host.hub
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
difficulty: i32::from(self.ui.difficulty),
locked: self.level.difficulty_locked,
}));
}
S::ClientCommand(p) if p.action == 2 && host.hub.guests[gi].permission >= 2 => {
host.hub.send_to(
conn,
&C::GameRuleValues(play::GameRuleValues {
entries: horizon_client::gamerules::RULES
.iter()
.map(|r| play::GameRuleEntry {
name: format!("minecraft:{}", r.name),
value: self.ui.rules.get(r.name).unwrap_or(r.default).to_string(),
})
.collect(),
}),
);
}
S::SetGameRule(p) if host.hub.guests[gi].permission >= 2 => {
for entry in p.entries {
if self.ui.rules.set_string(&entry.name, &entry.value) {
let message = horizon_client::text::Text::tr(
"commands.gamerule.set",
&[&entry.name, &entry.value],
);
let content =
horizon_client::lan::data::component_nbt(&message.component());
for g in &host.hub.guests {
if g.permission >= 2 {
g.send(&C::SystemChat(play::SystemChat {
content: content.clone(),
overlay: false,
}));
}
}
if self.options.dedicated.is_none() && self.allow_commands() {
self.system_message(message);
}
}
}
self.sync_rules();
}
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
if let Some(mode) = usize::try_from(p.mode)
.ok()
@@ -544,7 +698,17 @@ impl Game<'_> {
{
self.guest_suggestions(host, conn, &payload);
}
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
// ServerStatsCounter.sendStats.
S::ClientCommand(c) if c.action == 1 => {
let stats = host
.play
.get_mut(&conn)
.map(|p| p.stats.take_dirty())
.unwrap_or_default();
host.hub.send_stats(conn, &stats);
}
S::PickItemFromBlock(p) => {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let item = self.res.facts.get(state).item.clone();
@@ -669,6 +833,32 @@ impl Game<'_> {
);
return;
}
// An integrated server runs every player's commands at their own
// permission level (`CommandSourceStack.permissions`).
if host.hub.config.integrated {
let Some(level) = host.hub.guest(conn).map(|g| g.permission) else {
return;
};
let command = command.trim_start_matches('/').to_owned();
let lines = self.as_guest(host, conn, |g| {
let console = std::mem::replace(&mut g.ui.registries.permission, level);
g.chat_capture = Some(Vec::new());
g.run_command(&command);
g.ui.registries.permission = console;
g.chat_capture.take().unwrap_or_default()
});
self.sync_hub_settings(host);
for line in lines.unwrap_or_default() {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
return;
}
// A LAN world follows its Allow Commands; a dedicated server leaves
// it to the ops list (the guest's permission level).
let allowed = self.options.dedicated.is_some() || self.allow_commands();
@@ -683,9 +873,14 @@ impl Game<'_> {
return;
}
let command = command.trim_start_matches('/').to_owned();
// The guest's own level (Commands.performCommand with the player's source): an
// operator's on a dedicated server, never the host's.
let permission = host.hub.guest(conn).map_or(0, |g| g.permission);
let lines = self.as_guest(host, conn, |g| {
g.chat_capture = Some(Vec::new());
let level = std::mem::replace(&mut g.ui.registries.permission, permission);
g.run_command(&command);
g.ui.registries.permission = level;
g.chat_capture
.take()
.unwrap_or_default()
@@ -693,6 +888,7 @@ impl Game<'_> {
.map(|t| horizon_client::lan::data::component_nbt(&t.component()))
.collect::<Vec<_>>()
});
self.sync_hub_settings(host);
for content in lines.unwrap_or_default() {
host.hub.send_to(
conn,
@@ -752,6 +948,18 @@ impl Game<'_> {
if !host.hub.guests[i].dead {
return;
}
// PlayerList.respawn: a server's player comes back in the Overworld,
// where the world spawn is (respawn anchors and beds aside).
let back = self.dim;
let away = self.options.dedicated.is_some()
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
if away {
self.server_trips.retain(|t| !t.is(conn));
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
log::error!("could not open the Overworld: {e}");
return;
}
}
let g = &mut host.hub.guests[i];
g.dead = false;
g.health = 20.0;
@@ -759,24 +967,55 @@ impl Game<'_> {
g.saturation = 5.0;
g.sent_health = None;
g.motion = None;
host.hub.respawn(i, spawn);
let mut xp = Experience {
level: g.xp_level,
progress: g.xp_progress,
total: g.xp_total,
};
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
// A new body: nothing given to the old one carries over.
if let Some(play) = host.play.get_mut(&conn) {
play.potions = Default::default();
}
let ctx = TickCtx {
world: &mut self.world,
host: None,
entities: &[],
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate: if self.ui.rules.bool("advance_time") {
self.ui.clock.rate
} else {
0.0
},
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
};
host.hub.respawn(i, spawn, &ctx);
if away && let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
}
/// Per tick, for every guest: dig, use items, pick up drops, die.
pub(super) fn tick_guests(&mut self, host: &mut LanHost) {
self.lan_merchant_tick(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| g.placed && g.loaded)
.filter(|g| host.hub.here(g) && g.loaded)
.map(|g| g.conn)
.collect();
for conn in conns {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
continue;
};
if host.hub.guests[gi].dead {
if host.hub.guests[gi].dead || self.on_server_trip(conn) {
continue;
}
let play = host.play.entry(conn).or_default();
@@ -806,20 +1045,49 @@ impl Game<'_> {
}
});
}
self.guest_delayed_destroy(host, conn);
self.guest_server_tick(host, conn);
// Player.tick's statistics and ServerPlayer.tick's triggers, then
// PlayerAdvancements.flushDirty.
let sleeping = host.hub.guests[gi].sleeping;
self.as_guest(host, conn, |g| {
g.count_tick_stats(sleeping);
g.tick_advancements();
});
host.hub.flush_progress(conn);
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
g.last_death = Some(g.position.map(|v| v.floor() as i32));
let (id, at) = (g.entity_id, g.position);
let reward = Experience {
level: g.xp_level,
progress: g.xp_progress,
total: g.xp_total,
}
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let message = literal(&format!("{} died", g.name));
host.hub.send_to(
conn,
&C::PlayerCombatKill(play::PlayerCombatKill {
id: host.hub.guests[gi].entity_id,
message,
}),
&C::PlayerCombatKill(play::PlayerCombatKill { id, message }),
);
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
self.spawn_orbs(at, reward);
}
}
}
/// `PlayerList.broadcastSystemMessage` of the advancements guests made
/// (their own actions, commands and ticks).
fn announce_guest_advancements(&mut self, host: &mut LanHost) {
for text in std::mem::take(&mut self.adv.announced) {
host.hub
.system_message(horizon_client::lan::data::component_nbt(&text.component()));
if self.options.dedicated.is_none() {
self.system_message(text);
}
}
}
@@ -846,12 +1114,17 @@ impl Game<'_> {
g.moves_from_ground
};
if g.spectator() {
// Spectators' effects still run (LivingEntity.baseTick).
if !g.effects.list.is_empty() {
self.as_guest(host, conn, |game| game.tick_mob_effects());
}
return;
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sounds = self.as_guest(host, conn, |game| {
let (rules, health) = (game.world.health_rules, &game.motion.health);
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let Some(player) = game.player.as_mut() else {
return Vec::new();
};
@@ -866,6 +1139,7 @@ impl Game<'_> {
w.player.health.advance_clock();
let _ = w.fluid_base_tick();
w.player.ground.on_ground = from_ground;
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
for (moved, on_ground) in moves {
let movement = Vec3::new(moved[0], moved[1], moved[2]);
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
@@ -889,9 +1163,21 @@ impl Game<'_> {
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
let _ = w.player.peaceful_regeneration(rules);
let _ = w.player.food_tick(health, rules);
std::mem::take(&mut w.player.events)
let mut events = std::mem::take(&mut w.player.events);
// ServerGamePacketListenerImpl.handleMovePlayer:
// Player.checkMovementStatistics for each move.
for d in travelled {
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
}
// LivingEntity.baseTick ends with tickEffects.
game.tick_mob_effects();
let Some(player) = game.player.as_mut() else {
return Vec::new();
};
let _ = player.peaceful_regeneration(rules);
let _ = player.food_tick(health, rules);
events.append(&mut player.events);
events
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
@@ -919,6 +1205,81 @@ impl Game<'_> {
})
}
/// ServerPlayerGameMode.handleBlockBreakAction, STOP_DESTROY_BLOCK: the client finished
/// digging `pos` (and predicted the break). The server breaks it if its own count of the
/// digging since START is at least 70 % done; otherwise it keeps digging alone and breaks
/// it when done (`guest_delayed_destroy`), so the client's broken block is never left
/// standing on the server.
fn guest_stop_digging(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
let ticks = host.hub.ticks;
let play = host.play.entry(conn).or_default();
let dug = play.digging.take().is_some_and(|(p, _)| p == pos);
play.mining = None;
let start = play.dig_start;
let state = self.world.block(pos[0], pos[1], pos[2]);
if !dug || self.res.facts.is_air(state) {
return;
}
let progress = self
.as_guest(host, conn, |g| g.destroy_progress(pos))
.map_or(0.0, |p| p * (ticks - start + 1) as f32);
if progress >= 0.7 {
self.guest_destroy(host, conn, pos);
} else {
let play = host.play.entry(conn).or_default();
if play.delayed.is_none() {
play.delayed = Some((pos, start));
}
}
}
/// ServerPlayerGameMode.tick's delayed destroy: the block breaks once its progress since
/// the digging started is complete, unless something else removed it first.
fn guest_delayed_destroy(&mut self, host: &mut LanHost, conn: u32) {
let Some((pos, start)) = host.play.get(&conn).and_then(|p| p.delayed) else {
return;
};
let ticks = host.hub.ticks;
let state = self.world.block(pos[0], pos[1], pos[2]);
let progress = if self.res.facts.is_air(state) {
None
} else {
self.as_guest(host, conn, |g| g.destroy_progress(pos))
.map(|p| p * (ticks - start + 1) as f32)
};
match progress {
Some(p) if p < 1.0 => {}
done => {
if let Some(play) = host.play.get_mut(&conn) {
play.delayed = None;
}
if done.is_some() {
self.guest_destroy(host, conn, pos);
}
}
}
}
/// ServerPlayerGameMode.destroyBlock for a guest, its crack cleared for the others
/// (`destroyBlockProgress(id, pos, -1)`).
fn guest_destroy(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
self.as_guest(host, conn, |g| {
let _ = g.break_block(pos);
});
if let Some(g) = host.hub.guest(conn) {
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&C::BlockDestruction(play::BlockDestruction {
id: g.entity_id,
pos,
progress: 255,
}),
Some(conn),
);
}
}
/// The crack animation other players see.
fn guest_dig_feedback(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -1039,6 +1400,9 @@ impl Game<'_> {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
if !host.hub.here(g) {
continue;
}
let (id, at) = (g.entity_id, g.position);
let play = host.play.get_mut(&conn).expect("play");
let swinging = play.swing.swinging() && play.swing.ticks == 0;
+153 -1
View File
@@ -6,8 +6,9 @@
//! closes (`AbstractContainerMenu.clearContainer`).
use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
lan::guest::Guest,
lan::{guest::Guest, menu::Slots},
menu::{Env, Input, Kind, Menu},
};
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -20,6 +21,9 @@ pub(super) struct GuestCrafting {
inventory: Option<Box<Menu>>,
/// The open crafting table: its window, state id and menu.
table: Option<(i32, i32, Box<Menu>)>,
/// The open furnace's window and menu (`AbstractFurnaceMenu`); its slots live in
/// the furnace block entity and the hub's container menu keeps the client in sync.
furnace: Option<(i32, Box<Menu>)>,
}
/// ServerboundContainerClickPacket's input.
@@ -153,6 +157,154 @@ impl Game<'_> {
true
}
/// A click on the guest's open furnace, blast furnace or smoker: the host's
/// `AbstractFurnaceMenu` (fuel slot, take-only result, quick moves by recipe and
/// fuel) over the block entity's slots. False for other windows.
pub(super) fn lan_furnace_click(
&mut self,
host: &mut LanHost,
conn: u32,
p: &play::ContainerClick,
) -> bool {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return false;
};
let Some(pos) = host.hub.guests[gi]
.menu
.as_ref()
.filter(|m| p.container != 0 && m.window == p.container)
.and_then(|m| match m.slots {
Slots::Block(pos) => Some(pos),
_ => None,
})
else {
return false;
};
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get(&pos) else {
return false;
};
let recipe = match f.kind.recipe_type() {
"minecraft:blasting" => "blasting",
"minecraft:smoking" => "smoking",
_ => "smelting",
};
let items = f.items.clone();
if host.hub.guests[gi].spectator() {
return true;
}
let mut menu = host
.play
.entry(conn)
.or_default()
.crafting
.furnace
.take()
.filter(|(window, _)| *window == p.container)
.map_or_else(|| Box::new(Menu::new(Kind::Furnace(recipe))), |(_, m)| m);
let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
for (k, s) in items.iter().enumerate() {
menu.set_slot(env, k, s.clone());
}
let before: Vec<_> = (0..menu.slot_count())
.map(|s| menu.slot(s).cloned())
.collect();
let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input));
(0..menu.slot_count())
.filter(|&s| menu.slot(s) != before[s].as_ref())
.collect::<Vec<_>>()
});
if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) {
for k in 0..3 {
f.items[k] = menu.slot(k).cloned();
}
// AbstractFurnaceBlockEntity.setItem: only a new input restarts cooking.
f.input_clicked(&self.res.recipes, items[0].clone());
}
host.play.entry(conn).or_default().crafting.furnace = Some((p.container, menu));
host.hub.inventory_clicked(conn, p, &self.world, &changed);
true
}
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
/// book places a known recipe in their inventory grid or crafting table
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
/// are missing.
pub(super) fn lan_place_recipe(
&mut self,
host: &mut LanHost,
conn: u32,
p: &horizon_protocol::packets::recipes::PlaceRecipe,
) {
use horizon_client::menu::place::PostPlace;
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let g = &host.hub.guests[gi];
if g.spectator() {
return;
}
let Some(id) = self
.res
.stations
.recipe_index
.recipe_of_display(p.recipe)
.map(str::to_owned)
else {
return;
};
// An unknown or impossible recipe is refused outright.
if !g.recipe_book.knows(&id)
|| self
.res
.recipes
.placement(&id)
.is_none_or(|placement| placement.impossible())
{
return;
}
let creative = g.creative();
let placed = if p.container == 0 {
let crafting = host.play.entry(conn).or_default();
let mut menu = crafting
.crafting
.inventory
.take()
.unwrap_or_else(|| Box::new(Menu::new(Kind::Inventory)));
let grid = host.hub.guests[gi].craft_grid.clone();
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
for (k, s) in grid.into_iter().enumerate() {
menu.set_slot(env, 1 + k, s);
}
menu.place_recipe(env, &id, p.use_max, creative, None)
});
let g = &mut host.hub.guests[gi];
for k in 0..4 {
g.craft_grid[k] = menu.slot(1 + k).cloned();
}
g.craft_result = menu.slot(0).cloned();
host.play.entry(conn).or_default().crafting.inventory = Some(menu);
host.hub.send_inventory(gi);
placed
} else {
let Some((window, state, mut menu)) = host
.play
.get_mut(&conn)
.and_then(|play| play.crafting.table.take_if(|(w, _, _)| *w == p.container))
else {
return;
};
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
menu.place_recipe(env, &id, p.use_max, creative, None)
});
host.play.entry(conn).or_default().crafting.table = Some((window, state, menu));
self.lan_send_table_content(host, conn);
placed
};
if placed == PostPlace::Ghost {
host.hub.send_ghost_recipe(conn, p.container, &id);
}
}
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32) {
self.lan_crafting_closed(host, conn, None);
+7 -4
View File
@@ -92,7 +92,7 @@ impl Game<'_> {
.hub
.guests
.iter()
.filter(|g| g.placed && g.loaded && !g.dead)
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| {
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
(g.conn, g.uuid, moved)
@@ -138,7 +138,7 @@ impl Game<'_> {
host.hub
.guests
.iter()
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| (Who::Mob(g.entity_id as u32), guest_bounds(g)))
.collect()
}
@@ -209,9 +209,12 @@ pub(super) struct Present {
impl Game<'_> {
/// Every guest in the world (`ServerLevel.getPlayers` past the host).
pub(super) fn lan_present(&self) -> Vec<Present> {
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
let guests = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter().filter(|g| h.hub.here(g)));
guests
.filter(|g| g.placed && g.loaded)
.filter(|g| g.loaded)
.map(|g| {
let [x, y, z] = g.position;
Present {
+10 -3
View File
@@ -19,6 +19,7 @@ pub(super) struct Strike {
scale: f32,
crit: bool,
from: [f64; 3],
sweep: Option<[f64; 3]>,
}
impl Game<'_> {
@@ -31,7 +32,7 @@ impl Game<'_> {
host.hub
.guests
.iter()
.filter(|g| g.placed && g.loaded && !g.dead && !g.spectator())
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.filter_map(|g| Some((g.conn, ray_box(eye, dir, reach, guest_bounds(g))?)))
.min_by(|a, b| a.1.total_cmp(&b.1))
}
@@ -63,7 +64,7 @@ impl Game<'_> {
host.hub
.guests
.iter()
.find(|g| g.entity_id == id && g.conn != conn && g.placed && !g.dead)
.find(|g| g.entity_id == id && g.conn != conn && host.hub.here(g) && !g.dead)
.map(|g| Victim::Guest(g.conn))
}
@@ -73,6 +74,7 @@ impl Game<'_> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
@@ -85,6 +87,7 @@ impl Game<'_> {
scale,
crit,
from,
sweep,
})
}
@@ -105,6 +108,10 @@ impl Game<'_> {
(true, false, true) => "minecraft:entity.player.attack.strong",
(true, false, false) => "minecraft:entity.player.attack.weak",
};
self.audio.play(sound, Some(s.from), 1.0, 1.0);
if let Some(at) = s.sweep.filter(|_| hurt) {
self.sweep_attack(at, Some(s.from));
} else {
self.audio.play(sound, Some(s.from), 1.0, 1.0);
}
}
}
+300 -55
View File
@@ -4,9 +4,11 @@
//! difficulty, LAN on/off with its port, command access for guests and
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
//! Restrictions opens RestrictionsScreen.
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
use super::{
Game, Screen,
game_rules_screen::{Exit, RulesScreen},
integrated::ServerOptions,
lan::LanSettings,
};
use horizon_client::{
@@ -15,9 +17,12 @@ use horizon_client::{
text::{Text, named_color},
ui::{self, EditBox},
};
use horizon_protocol::packets::play::{self, Serverbound as S};
use winit::{event::KeyEvent, keyboard::PhysicalKey};
pub(super) struct WorldOptions {
remote: bool,
gamemaster: bool,
default_mode: i32,
personal_mode: i32,
cheats: bool,
@@ -50,7 +55,7 @@ enum View {
Restrictions,
GameRules,
}
#[derive(Clone, PartialEq)]
#[derive(Clone, Copy, PartialEq)]
struct Snapshot {
default_mode: i32,
personal_mode: i32,
@@ -108,6 +113,36 @@ fn labelled(key: &str, value: Text) -> Text {
}
impl WorldOptions {
/// Vanilla 26.3 WorldOptionsScreen.generalOptions: guests see difficulty,
/// game rules and restrictions; game modes, commands and LAN are host-only.
fn visible_buttons(&self, w: f32) -> Vec<(Button, f32, f32, f32)> {
if !self.remote {
return Self::buttons(w, self.top());
}
let x = Self::left(w);
let top = self.top();
vec![
(Button::Difficulty, x, top + 29.0, 130.0),
(Button::Lock, x + 130.0, top + 29.0, 20.0),
(Button::GameRules, x + 158.0, top + 29.0, 150.0),
(Button::Restrictions, x, top + 53.0, 150.0),
]
}
fn content_height(&self) -> f32 {
if self.remote { 81.0 } else { CONTENT }
}
fn scroll_limit(&self, h: f32) -> f32 {
(self.content_height() - (h - HEADER - FOOTER)).max(0.0)
}
fn server_difficulty(&mut self, difficulty: u8, locked: bool) {
// A server update is the new baseline, not a local edit. In particular,
// the reply to Apply may arrive after the screen has already reopened.
self.difficulty = difficulty;
self.difficulty_locked = locked;
self.locked = locked || self.hardcore;
self.initial.difficulty = self.difficulty;
self.initial.locked = self.locked;
}
fn parsed_port(&self) -> Option<u16> {
if self.port.value.is_empty() {
return Some(self.hint);
@@ -175,16 +210,13 @@ impl WorldOptions {
/// scroll area.
fn hit(&self, (x, y): (f32, f32), (w, h): (f32, f32)) -> Option<Button> {
let inside = y >= HEADER && y < h - FOOTER;
Self::buttons(w, self.top())
self.visible_buttons(w)
.into_iter()
.filter(|_| inside)
.chain(Self::footer(w, h))
.find(|(b, bx, by, bw)| self.active(*b) && ui::hovered((x, y), *bx, *by, *bw, 20.0))
.map(|b| b.0)
}
fn max_scroll(h: f32) -> f32 {
(CONTENT - (h - HEADER - FOOTER)).max(0.0)
}
fn label(&self, b: Button) -> Text {
let mode = |m: i32| {
Text::translate(
@@ -229,6 +261,17 @@ impl WorldOptions {
}
/// WorldOptionsScreen.updateButton and the update*Button methods.
fn active(&self, b: Button) -> bool {
if self.remote {
return match b {
Button::Difficulty | Button::Lock => {
self.gamemaster && !self.hardcore && !self.difficulty_locked
}
Button::GameRules => self.gamemaster,
Button::Restrictions | Button::Cancel => true,
Button::Apply => self.gamemaster && self.changed(),
_ => false,
};
}
match b {
Button::DefaultMode | Button::PersonalMode => self.cheats && !self.hardcore,
// An owner's commands come with Allow Commands.
@@ -404,30 +447,64 @@ impl Restrictions {
}
impl Game<'_> {
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
if let Some(o) = self.world_options.as_mut() {
let lost = o.gamemaster && !permission;
o.gamemaster = permission;
if lost && o.remote {
self.close_world_options();
}
}
}
pub(super) fn remote_world_options_difficulty(&mut self, difficulty: u8, locked: bool) {
if let Some(o) = self.world_options.as_mut() {
o.server_difficulty(difficulty, locked);
}
}
pub(super) fn remote_world_options_rules(&mut self, entries: Vec<play::GameRuleEntry>) {
let Some(o) = self
.world_options
.as_mut()
.filter(|o| o.view == View::GameRules)
else {
return;
};
let mut rules = horizon_client::gamerules::GameRules::default();
for entry in entries {
rules.set_string(&entry.name, &entry.value);
}
o.rules = Some(RulesScreen::new(&rules));
}
pub(super) fn open_world_options(&mut self) {
let hint = horizon_client::lan::available_port();
let lan = self.lan.is_some();
let mut port = EditBox::new(0.0, 0.0, 150.0, 20.0);
port.max_length = 5;
let (guest_commands, force) = self.lan.as_ref().map_or((false, true), |h| {
(h.settings.guest_commands, h.settings.force_game_mode)
});
if let Some(p) = self.lan_port() {
// The world is the server thread's when there is one.
let state = match &self.integrated {
Some(server) => match server.call(|g| g.server_options()) {
Some(state) => state,
None => return,
},
None => self.local_server_options(),
};
if let Some(p) = state.port {
port.set_value(&p.to_string());
}
let mut options = WorldOptions {
default_mode: self.level.mode(),
personal_mode: self.mode_id(),
cheats: self.allow_commands(),
difficulty: self.ui.difficulty,
lan,
remote: self.external_server(),
gamemaster: self.ui.registries.permission >= 2,
default_mode: state.default_mode,
personal_mode: state.personal_mode,
cheats: state.cheats,
difficulty: state.difficulty,
lan: state.port.is_some(),
port,
hint,
guest_commands,
force,
hardcore: self.level.hardcore,
difficulty_locked: self.level.difficulty_locked,
locked: self.level.difficulty_locked || self.level.hardcore,
guest_commands: state.guest_commands,
force: state.force,
hardcore: state.hardcore,
difficulty_locked: state.difficulty_locked,
locked: state.difficulty_locked || state.hardcore,
scroll: 0.0,
view: View::Options,
rules: None,
@@ -458,7 +535,10 @@ impl Game<'_> {
pub(super) fn world_options_escape(&mut self) {
match self.world_options.as_mut() {
Some(o) if o.view == View::GameRules => {
if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
if o.rules.is_none() {
// A server may be slow to answer REQUEST_GAMERULE_VALUES.
o.view = View::Options;
} else if let Some(exit) = o.rules.as_mut().and_then(RulesScreen::escape) {
self.close_game_rules(exit);
}
}
@@ -478,6 +558,20 @@ impl Game<'_> {
let Exit::Apply(changes) = exit else {
return;
};
if self.remote.is_some() {
if self.ui.registries.permission >= 2 && !changes.is_empty() {
self.send(S::SetGameRule(play::SetGameRule {
entries: changes
.into_iter()
.map(|(name, value)| play::GameRuleEntry {
name: format!("minecraft:{name}"),
value: value.to_string(),
})
.collect(),
}));
}
return;
}
if !self.allow_commands() || changes.is_empty() {
return;
}
@@ -503,6 +597,16 @@ impl Game<'_> {
View::GameRules => {
if let Some(rules) = o.rules.as_mut() {
rules.draw(batch, &self.atlas, self.cursor);
} else {
ui::menu_background(
batch,
&self.atlas,
0.0,
0.0,
batch.width,
batch.height,
true,
);
}
}
}
@@ -585,7 +689,7 @@ impl Game<'_> {
fn draw_options_view(&self, o: &mut WorldOptions, batch: &mut HudBatch, cursor: (f32, f32)) {
let atlas = &self.atlas;
let (w, h) = (batch.width, batch.height);
o.scroll = o.scroll.clamp(0.0, WorldOptions::max_scroll(h));
o.scroll = o.scroll.clamp(0.0, o.scroll_limit(h));
// extractMenuBackground: the in-world background, the list
// background under the content, then the separators over it.
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -607,6 +711,9 @@ impl Game<'_> {
("options.worldOptions.general.title", GENERAL_TOP),
("options.worldOptions.multiplayer.title", MULTIPLAYER_TOP),
] {
if o.remote && key == "options.worldOptions.multiplayer.title" {
continue;
}
// A FocusableTextWidget padded by 4, centred over both columns.
let t = Text::translate(key, &[]).underlined().bold();
let tw = atlas.font.width_text(&t);
@@ -614,7 +721,7 @@ impl Game<'_> {
batch.text_styled(atlas, &t, tx, top + y + 4.0, u32::MAX, true);
}
let inside = cursor.1 >= HEADER && cursor.1 < h - FOOTER;
for (b, bx, by, bw) in WorldOptions::buttons(w, top) {
for (b, bx, by, bw) in o.visible_buttons(w) {
let active = o.active(b);
let hover = active && inside && ui::hovered(cursor, bx, by, bw, 20.0);
if b == Button::Lock {
@@ -632,32 +739,34 @@ impl Game<'_> {
}
// The port box beside the LAN button: editable only with LAN on,
// its hint the port to use (setPortError colours the text).
o.port.x = x + 158.0;
o.port.y = top + PORT_TOP;
o.port.focused = o.lan && o.port.focused;
o.port.text_color = if !o.lan {
rgba(0x70, 0x70, 0x70, 255)
} else if o.parsed_port().is_some() {
rgba(0xE0, 0xE0, 0xE0, 255)
} else {
rgba(0xDF, 0x50, 0x50, 255)
};
o.port.draw(batch, atlas, None);
if o.port.value.is_empty() && !o.port.focused {
let hint = if o.lan {
Text::literal(o.hint.to_string())
if !o.remote {
o.port.x = x + 158.0;
o.port.y = top + PORT_TOP;
o.port.focused = o.lan && o.port.focused;
o.port.text_color = if !o.lan {
rgba(0x70, 0x70, 0x70, 255)
} else if o.parsed_port().is_some() {
rgba(0xE0, 0xE0, 0xE0, 255)
} else {
Text::translate("lanServer.port", &[])
rgba(0xDF, 0x50, 0x50, 255)
};
// EditBox.DEFAULT_HINT_STYLE: dark gray.
batch.text_styled(
atlas,
&hint,
o.port.text_x(),
o.port.text_y(),
rgba(0x55, 0x55, 0x55, 255),
true,
);
o.port.draw(batch, atlas, None);
if o.port.value.is_empty() && !o.port.focused {
let hint = if o.lan {
Text::literal(o.hint.to_string())
} else {
Text::translate("lanServer.port", &[])
};
// EditBox.DEFAULT_HINT_STYLE: dark gray.
batch.text_styled(
atlas,
&hint,
o.port.text_x(),
o.port.text_y(),
rgba(0x55, 0x55, 0x55, 255),
true,
);
}
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::scrollbar(
@@ -666,7 +775,7 @@ impl Game<'_> {
(w / 2.0).floor() + 166.0,
HEADER,
h - FOOTER,
CONTENT,
o.content_height(),
o.scroll.trunc(),
);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
@@ -682,7 +791,7 @@ impl Game<'_> {
let h = self.gui_size().1;
match self.world_options.as_mut() {
Some(o) if o.view == View::Options => {
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, WorldOptions::max_scroll(h));
o.scroll = (o.scroll - delta * 10.0).clamp(0.0, o.scroll_limit(h));
}
Some(o) => {
if let Some(rules) = o.rules.as_mut() {
@@ -755,7 +864,8 @@ impl Game<'_> {
}
}
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
o.port.focused = o.lan
o.port.focused = !o.remote
&& o.lan
&& inside
&& ui::hovered(
self.cursor,
@@ -784,7 +894,12 @@ impl Game<'_> {
Some(Button::Lock) => o.locked = !o.locked,
Some(Button::Restrictions) => o.view = View::Restrictions,
Some(Button::GameRules) => {
o.rules = Some(RulesScreen::new(&self.ui.rules));
if self.remote.is_some() {
o.rules = None;
self.send(S::ClientCommand(play::ClientCommand { action: 2 }));
} else {
o.rules = Some(RulesScreen::new(&self.ui.rules));
}
o.view = View::GameRules;
}
Some(Button::Lan) => {
@@ -808,7 +923,7 @@ impl Game<'_> {
fn apply_and_close_world_options(&mut self, o: WorldOptions) {
let wanted = o.snapshot();
let before = o.initial.clone();
let before = o.initial;
self.world_options = Some(o);
self.apply_world_options(&before, &wanted);
self.close_world_options();
@@ -891,7 +1006,60 @@ impl Game<'_> {
}
}
/// The world options of a world run in the frame.
fn local_server_options(&self) -> ServerOptions {
let (guest_commands, force) = self.lan.as_ref().map_or((false, true), |h| {
(h.settings.guest_commands, h.settings.force_game_mode)
});
ServerOptions {
default_mode: self.level.mode(),
personal_mode: self.mode_id(),
cheats: self.allow_commands(),
difficulty: self.ui.difficulty,
hardcore: self.level.hardcore,
difficulty_locked: self.level.difficulty_locked,
port: self.lan_port(),
guest_commands,
force,
}
}
fn apply_world_options(&mut self, before: &Snapshot, wanted: &Snapshot) {
if self.external_server() {
if self.ui.registries.permission >= 2 {
if wanted.difficulty != before.difficulty {
self.send(S::ChangeDifficulty(play::ChangeDifficultyRequest {
difficulty: i32::from(wanted.difficulty),
}));
}
if wanted.locked && !before.locked {
self.send(S::LockDifficulty(play::LockDifficulty { locked: true }));
}
}
return;
}
if let Some(server) = &self.integrated {
// The server applies them; the window tells what came of
// Open to LAN (ShareToLanScreen's messages).
let (b, w) = (*before, *wanted);
server.call(move |g| g.apply_world_options(&b, &w));
if w.lan != b.lan || (w.lan && w.port != b.port) {
match (w.lan, self.lan_port()) {
(true, Some(port)) => self.system_message(Text::translate(
"menu.multiplayerOptions.publish.started.lan",
&[Text::literal(port.to_string())],
)),
(true, None) => {
self.system_message(Text::translate("commands.publish.failed", &[]));
}
(false, _) => self.system_message(Text::translate(
"menu.multiplayerOptions.publish.stopped",
&[],
)),
}
}
return;
}
if wanted.difficulty != before.difficulty {
let name = DIFFICULTIES[usize::from(wanted.difficulty) & 3];
self.as_console(|g| g.run_command(&format!("difficulty {name}")));
@@ -904,10 +1072,15 @@ impl Game<'_> {
self.set_default_game_mode(wanted.default_mode);
}
if wanted.personal_mode != before.personal_mode {
self.set_mode_id(wanted.personal_mode);
if self.serving() {
self.set_owner_mode(wanted.personal_mode);
} else {
self.set_mode_id(wanted.personal_mode);
}
}
if wanted.cheats != before.cheats {
self.set_allow_commands(wanted.cheats);
self.owner_allow_commands(wanted.cheats);
}
let settings_changed =
wanted.guest_commands != before.guest_commands || wanted.force != before.force;
@@ -935,6 +1108,8 @@ mod tests {
fn options(cheats: bool, hardcore: bool) -> WorldOptions {
let mut o = WorldOptions {
remote: false,
gamemaster: cheats,
default_mode: 1,
personal_mode: 1,
cheats,
@@ -988,8 +1163,8 @@ mod tests {
assert_eq!(at(Button::Force), (217.0, 223.0, 150.0));
assert_eq!(at(Button::Apply), (59.0, 213.0, 150.0));
// 218 pixels of content over 174 between the separators.
assert_eq!(WorldOptions::max_scroll(240.0), 44.0);
assert_eq!(WorldOptions::max_scroll(400.0), 0.0);
assert_eq!(options(true, false).scroll_limit(240.0), 44.0);
assert_eq!(options(true, false).scroll_limit(400.0), 0.0);
}
#[test]
@@ -1033,4 +1208,74 @@ mod tests {
let o = options(true, true);
assert!(o.locked && !o.active(Button::Lock));
}
#[test]
fn remote_options_follow_vanilla_guest_layout_and_permissions() {
let mut o = options(false, false);
o.remote = true;
// Vanilla omits host-only rows instead of disabling the whole screen.
let rows = o.visible_buttons(427.0);
assert_eq!(
rows.iter().map(|b| b.0).collect::<Vec<_>>(),
vec![
Button::Difficulty,
Button::Lock,
Button::GameRules,
Button::Restrictions,
]
);
assert_eq!(rows[0], (Button::Difficulty, 59.0, 62.0, 130.0));
assert_eq!(rows[2], (Button::GameRules, 217.0, 62.0, 150.0));
assert_eq!(rows[3], (Button::Restrictions, 59.0, 86.0, 150.0));
assert_eq!(o.scroll_limit(240.0), 0.0);
// A non-operator cannot activate the difficulty or rule controls.
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
assert_eq!(o.hit((240.0, 70.0), (427.0, 240.0)), None);
assert_eq!(
o.hit((100.0, 90.0), (427.0, 240.0)),
Some(Button::Restrictions)
);
o.gamemaster = true;
assert_eq!(
o.hit((100.0, 70.0), (427.0, 240.0)),
Some(Button::Difficulty)
);
assert_eq!(
o.hit((240.0, 70.0), (427.0, 240.0)),
Some(Button::GameRules)
);
o.difficulty_locked = true;
assert_eq!(o.hit((100.0, 70.0), (427.0, 240.0)), None);
// Remote hardcore does not hide an operator's Game Rules screen.
o.hardcore = true;
assert!(o.active(Button::GameRules));
assert!(!o.active(Button::Lock));
for b in [
Button::DefaultMode,
Button::PersonalMode,
Button::Cheats,
Button::Lan,
Button::CommandAccess,
Button::Force,
] {
assert!(!o.active(b));
}
}
#[test]
fn a_delayed_server_reply_does_not_mark_reopened_options_as_edited() {
let mut o = options(false, false);
o.remote = true;
o.gamemaster = true;
// Reopened while the previous Apply's reply was still in flight.
o.server_difficulty(3, false);
assert_eq!(o.difficulty, 3);
assert!(!o.active(Button::Apply));
o.difficulty = 0;
assert!(o.active(Button::Apply));
o.server_difficulty(3, true);
assert!(!o.active(Button::Apply));
assert!(o.locked);
assert!(!o.active(Button::Difficulty));
}
}
@@ -372,7 +372,7 @@ impl Game<'_> {
.hub
.guests
.iter()
.find(|g| g.conn == conn)
.find(|g| g.conn == conn && host.hub.here(g))
.map(|g| uuid_words(g.uuid))
else {
continue;
@@ -0,0 +1,95 @@
//! The locator bar of the players on a served world (lan/waypoints.rs): what
//! each player looks like to the waypoint manager this tick.
use super::{Game, lan::LanHost};
use horizon_client::{
items::ItemStack,
lan::{
hub::HostState,
waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
},
};
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
if crouching || invisible || hiding_head {
0.0
} else {
PLAYER_RANGE
}
}
impl Game<'_> {
/// The player's team and its colour, as the locator bar shows it.
fn waypoint_team(&self, name: &str) -> Option<(String, Option<i32>)> {
let team = self.score.board.players_team(name)?;
let color = team.color.as_deref().and_then(waypoints::team_rgb);
Some((team.name.clone(), color))
}
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost, state: Option<&HostState>) {
let mut players = Vec::new();
if let Some(h) = state {
let icon = self
.cmds
.waypoint_icons
.get(&super::commands::Target::Player)
.map(|i| Icon {
style: i.style.clone(),
color: i.color,
})
.unwrap_or_default();
players.push(waypoints::Player {
uuid: h.uuid,
conn: None,
position: h.position,
spectator: self.spectator(),
transmit_range: transmit_range(
h.sneaking,
self.potions.effects.get("invisibility").is_some(),
h.equipment[5].as_ref(),
),
receive_range: PLAYER_RANGE,
first_tick: false,
view_distance: host.hub.config.view_distance,
team: self.waypoint_team(&h.name),
icon,
});
}
let mut respawned = Vec::new();
for g in host.hub.guests.iter().filter(|g| g.placed) {
// PlayerList.respawn makes a new ServerPlayer.
if g.dead {
host.waypoint_dead.insert(g.uuid);
} else if host.waypoint_dead.remove(&g.uuid) {
respawned.push(g.uuid);
}
// Player.updatePlayerPose: the shift key crouches unless flying.
let crouching = g.sneaking && !g.flying && !g.fall_flying && !g.sleeping;
players.push(waypoints::Player {
uuid: g.uuid,
conn: Some(g.conn),
position: g.position,
spectator: g.spectator(),
transmit_range: transmit_range(
crouching,
g.effects.get("invisibility").is_some(),
g.armor[0].as_ref(),
),
receive_range: PLAYER_RANGE,
first_tick: g.ticks == 0,
view_distance: g.view_distance,
team: self.waypoint_team(&g.name),
icon: Icon::default(),
});
}
host.waypoint_dead
.retain(|uuid| players.iter().any(|p| p.uuid == *uuid));
let locator_bar = self.ui.rules.bool("locator_bar");
for (conn, packet) in host.waypoints.tick(&players, &respawned, locator_bar) {
if let Some(g) = host.hub.guest(conn) {
g.send(&packet);
}
}
}
}
+350
View File
@@ -0,0 +1,350 @@
//! A server's levels, one per dimension (`MinecraftServer.levels`).
//!
//! The game's tick code reads one level: the one in place, held in `Game`'s
//! own fields (the world, its streaming and save, block ticks, entities).
//! A server (the dedicated server and single player's server thread) keeps
//! the other dimensions' levels here and ticks each one that has players by
//! swapping it into place (`enter_level`), the way `as_guest` swaps a player
//! in, so the tick code runs unchanged, one level at a time. The in-frame
//! game holds one level, which follows its player (`enter_dimension`).
//!
//! Shared by every level, as vanilla's derived levels share the
//! Overworld's level data: the game time, the day time and the weather, the
//! game rules, the scoreboard, the maps and the entities waiting to arrive
//! through a portal.
use super::{
Dim, ExperienceOrb, FallingBlock, Game, ItemEntity, Mob, boats, commands,
creaking::CreakingState, dragon, fireworks, lan::LanHost, lightning, machines, minecarts,
ominous_item_spawner, persist, raids::Raiding, sculk::SculkState, tnt, trident,
};
use horizon_client::{
Result, block_entity_anim::BlockEntityAnims, random_tick_sections::RandomTickSections,
save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership, world::World,
};
use horizon_world::level::{LevelEvent, Ticks};
use std::{collections::HashMap, sync::Arc};
/// Server ticks a level other than the Overworld stays loaded with no player
/// in it, before it is saved and closed (vanilla unloads a level's chunks
/// once no ticket holds them; the Overworld stays loaded).
const IDLE_TICKS: u32 = 300;
/// A level that is not in place: every per-level field of `Game`.
pub(super) struct Parked {
world: World,
streaming: Streaming,
save: Arc<SaveDir>,
block_ticks: Ticks,
tick_chunks: TickChunkMembership,
random_tick_sections: RandomTickSections,
level_events: Vec<LevelEvent>,
falling: Vec<FallingBlock>,
paintings: Vec<horizon_client::entities::Painting>,
item_frames: Vec<horizon_client::entities::ItemFrame>,
armor_stands: Vec<horizon_client::entities::ArmorStand>,
others: Vec<commands::OtherEntity>,
boats: Vec<boats::Boat>,
carts: Vec<minecarts::Minecart>,
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
crystals: Vec<dragon::Crystal>,
dragon: Option<dragon::Dragon>,
dragon_fireballs: Vec<dragon::DragonFireball>,
breath_clouds: Vec<dragon::Breath>,
tridents: Vec<trident::ThrownTrident>,
rockets: Vec<fireworks::Rocket>,
ominous_spawners: Vec<ominous_item_spawner::OminousItemSpawner>,
tnts: Vec<tnt::PrimedTnt>,
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
mobs: Vec<Mob>,
projectiles: Vec<horizon_client::projectiles::Projectile>,
dropped: Vec<ItemEntity>,
orbs: Vec<ExperienceOrb>,
persist: persist::EntityMemory,
lectern_pulses: Vec<((i32, i32, i32), i32)>,
lid_openers: HashMap<(i32, i32, i32), i32>,
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_block_events: Vec<([i32; 3], i32, i32, u16)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
sculk: SculkState,
creaking: CreakingState,
/// Server ticks since a player was last in the level.
idle: u32,
}
/// Which passes of the server tick the level in place runs: the first
/// reads the network (`Hub::poll`), the last ends the tick for every player
/// (`Hub::end_tick`). A tick with one level is both.
#[derive(Clone, Copy)]
pub(super) struct Pass {
pub first: bool,
pub last: bool,
}
impl Default for Pass {
fn default() -> Self {
Self {
first: true,
last: true,
}
}
}
/// The levels not in place, by dimension.
#[derive(Default)]
pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
}
impl Game<'_> {
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
fn open_level(&self, dim: Dim) -> Result<Parked> {
let (streaming, save) =
self.make_streaming(&self.world_root.clone(), dim, self.level.seed)?;
let mut world = World::new(self.res.facts.air, self.level.seed);
world.fast_lava = dim == Dim::Nether;
Ok(Parked {
world,
streaming,
save,
block_ticks: Ticks::new(self.level.seed as i32),
tick_chunks: Default::default(),
random_tick_sections: Default::default(),
level_events: Vec::new(),
falling: Vec::new(),
paintings: Vec::new(),
item_frames: Vec::new(),
armor_stands: Vec::new(),
others: Vec::new(),
boats: Vec::new(),
carts: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
crystals: Vec::new(),
dragon: None,
dragon_fireballs: Vec::new(),
breath_clouds: Vec::new(),
tridents: Vec::new(),
rockets: Vec::new(),
ominous_spawners: Vec::new(),
tnts: Vec::new(),
hoppers: HashMap::new(),
mobs: Vec::new(),
projectiles: Vec::new(),
dropped: Vec::new(),
orbs: Vec::new(),
persist: Default::default(),
lectern_pulses: Vec::new(),
lid_openers: HashMap::new(),
lan_events: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
anims: Default::default(),
raiding: Default::default(),
sculk: Default::default(),
creaking: Default::default(),
idle: 0,
})
}
/// Exchange the level in place with `p`.
fn swap_level(&mut self, p: &mut Parked) {
use std::mem::swap;
swap(&mut self.world, &mut p.world);
swap(&mut self.streaming, &mut p.streaming);
swap(&mut self.save, &mut p.save);
swap(&mut self.block_ticks, &mut p.block_ticks);
swap(&mut self.tick_chunks, &mut p.tick_chunks);
swap(&mut self.random_tick_sections, &mut p.random_tick_sections);
swap(&mut self.level_events, &mut p.level_events);
swap(&mut self.falling, &mut p.falling);
swap(&mut self.paintings, &mut p.paintings);
swap(&mut self.item_frames, &mut p.item_frames);
swap(&mut self.armor_stands, &mut p.armor_stands);
swap(&mut self.others, &mut p.others);
swap(&mut self.boats, &mut p.boats);
swap(&mut self.carts, &mut p.carts);
swap(&mut self.jukebox_sounds, &mut p.jukebox_sounds);
swap(&mut self.bolts, &mut p.bolts);
swap(&mut self.crystals, &mut p.crystals);
swap(&mut self.dragon, &mut p.dragon);
swap(&mut self.dragon_fireballs, &mut p.dragon_fireballs);
swap(&mut self.breath_clouds, &mut p.breath_clouds);
swap(&mut self.tridents, &mut p.tridents);
swap(&mut self.rockets, &mut p.rockets);
swap(&mut self.ominous_spawners, &mut p.ominous_spawners);
swap(&mut self.tnts, &mut p.tnts);
swap(&mut self.hoppers, &mut p.hoppers);
swap(&mut self.mobs, &mut p.mobs);
swap(&mut self.creatures.projectiles, &mut p.projectiles);
swap(&mut self.dropped, &mut p.dropped);
swap(&mut self.orbs, &mut p.orbs);
swap(&mut self.persist, &mut p.persist);
swap(&mut self.lectern_pulses, &mut p.lectern_pulses);
swap(&mut self.lid_openers, &mut p.lid_openers);
swap(&mut self.lan_events, &mut p.lan_events);
swap(&mut self.lan_block_events, &mut p.lan_block_events);
swap(&mut self.lan_entity_events, &mut p.lan_entity_events);
let (a, b) = (&mut self.be_anims, &mut p.anims);
swap(&mut a.chests, &mut b.chests);
swap(&mut a.books, &mut b.books);
swap(&mut a.shulkers, &mut b.shulkers);
swap(&mut a.bells, &mut b.bells);
swap(&mut a.conduits, &mut b.conduits);
swap(&mut a.skulls, &mut b.skulls);
swap(&mut a.pots, &mut b.pots);
swap(&mut a.gateways, &mut b.gateways);
swap(&mut a.gateway_servers, &mut b.gateway_servers);
self.raiding.swap_level(&mut p.raiding);
self.sculk.swap_level(&mut p.sculk);
self.creaking.swap_level(&mut p.creaking);
// What only the client draws starts over.
self.particles.clear();
self.pickups.clear();
}
/// Put `target`'s level in place, opening it first if it is not loaded.
/// The level that was in place is kept with the others; the shared
/// clock and weather go with the swap. `host` is the hub when the caller
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.dedicated.is_none() {
return Ok(());
}
if target != self.dim {
let (mut p, opened) = match self.levels.parked.remove(&target) {
Some(p) => (p, false),
None => (self.open_level(target)?, true),
};
p.world.game_time = self.world.game_time;
p.world.day_time = self.world.day_time;
p.world.weather = self.world.weather;
// The sculk tables' watched blocks are built once per game.
p.world.watched = std::mem::take(&mut self.world.watched);
self.swap_level(&mut p);
p.idle = 0;
let left = std::mem::replace(&mut self.dim, target);
self.levels.parked.insert(left, p);
if opened {
// The new streaming's radius and meshing options.
self.apply_settings();
}
}
let host = match host {
Some(h) => Some(h),
None => self.lan.as_mut(),
};
if let Some(h) = host {
h.hub.set_dimension(target.id());
}
Ok(())
}
/// One server tick (`MinecraftServer.tickServer`): each level with
/// players in turn (`tickChildren`), its chunks streamed and loaded and
/// its world ticked. The first pass reads the network and runs the
/// tick's shared parts (the clock, the weather, functions, the autosave);
/// the next ones only their level. While `paused`, only the connections
/// run.
pub(super) fn tick_server(&mut self, paused: bool) -> Result<()> {
let dims = self.levels_to_tick();
let n = dims.len();
let mut result = Ok(());
for (k, dim) in dims.into_iter().enumerate() {
if let Err(e) = self.enter_level(None, dim) {
result = Err(e);
break;
}
self.level_pass = Pass {
first: k == 0,
last: k + 1 == n,
};
self.update_dedicated_streaming();
self.update_world();
result = if paused {
self.tick_lan();
Ok(())
} else if k == 0 {
self.tick()
} else {
let running = self.clock.world_runs();
if running {
self.tick_weather_effects();
}
self.tick_world(running)
};
if result.is_err() {
break;
}
}
self.level_pass = Pass::default();
result?;
self.close_idle_levels()
}
/// The levels this server tick runs, in `MinecraftServer.levels` order
/// (the Overworld, the Nether, the End): every level with a player in
/// it, or the Overworld while nobody is online.
pub(super) fn levels_to_tick(&self) -> Vec<Dim> {
let guests = self
.lan
.as_ref()
.map_or(&[][..], |h| h.hub.guests.as_slice());
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
.into_iter()
.filter(|d| guests.iter().any(|g| g.dimension == d.id()))
.collect();
if dims.is_empty() {
dims.push(Dim::Overworld);
}
dims
}
/// Count the parked levels' empty ticks, and save and close the ones
/// (other than the Overworld) nobody has been in for a while.
pub(super) fn close_idle_levels(&mut self) -> Result<()> {
let occupied = self.levels_to_tick();
let mut closing = Vec::new();
for (dim, p) in &mut self.levels.parked {
if occupied.contains(dim) {
p.idle = 0;
continue;
}
p.idle += 1;
if *dim != Dim::Overworld && p.idle >= IDLE_TICKS {
closing.push(*dim);
}
}
if closing.is_empty() {
return Ok(());
}
let back = self.dim;
for dim in closing {
self.enter_level(None, dim)?;
self.save_level(true)?;
self.enter_level(None, back)?;
self.levels.parked.remove(&dim);
}
Ok(())
}
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
/// in place is the caller's to save.
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
if self.levels.parked.is_empty() {
return Ok(());
}
let back = self.dim;
let dims: Vec<Dim> = self.levels.parked.keys().copied().collect();
for dim in dims {
self.enter_level(None, dim)?;
self.save_level(wait)?;
}
self.enter_level(None, back)
}
}
+84 -16
View File
@@ -92,13 +92,13 @@ impl Game<'_> {
}
}
/// `ServerLevel.isAllowedToEnterPortal`.
fn may_enter(&self, target: Dim) -> bool {
pub(super) fn may_enter(&self, target: Dim) -> bool {
target != Dim::Nether || self.ui.rules.bool("allow_entering_nether_using_portals")
}
/// The plan of an entity of `width` by `height` at `at` going through the
/// portal at `entry` (`getDimensionTransitionFromExit` and the scaled,
/// clamped exit of `getPortalDestination`).
fn portal_plan(
pub(super) fn portal_plan(
&self,
entry: BlockPos,
at: Vec3,
@@ -195,7 +195,7 @@ impl Game<'_> {
}
/// The exit portal of `plan` in the loaded dimension: the closest one
/// within reach, else one built by `PortalForcer.createPortal`.
fn nether_exit_portal(&mut self, plan: &Plan) -> Option<FoundRectangle> {
pub(super) fn nether_exit_portal(&mut self, plan: &Plan) -> Option<FoundRectangle> {
let to_nether = plan.dim == Dim::Nether;
let radius = if to_nether { 16 } else { 128 };
let border = self.border_bounds();
@@ -231,7 +231,7 @@ impl Game<'_> {
}
/// `NetherPortalBlock.createDimensionTransition`: the arrival position
/// and the yaw turned by.
fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) {
let b = &self.res.behaviours;
let corner = exit.min_corner;
let exit_x_axis = b
@@ -312,8 +312,16 @@ impl Game<'_> {
self.portals.travel = Some(plan);
self.end_portal_travel = false;
self.enter_dimension(plan.dim)?;
// The destination is all on disk now: load around the portal its
// points of interest lead to, or the approximate exit.
let centre = self.arrival_centre(&plan);
if let Some((_, target)) = self.travelling.as_mut() {
*target = centre;
}
Ok(())
}
/// Where to wait for the destination of `plan` to load, once its level
/// is open: the portal its points of interest lead to, or the
/// approximate exit.
pub(super) fn arrival_centre(&self, plan: &Plan) -> Vec3 {
let to_nether = plan.dim == Dim::Nether;
let portals = self.portal_blocks(plan.approximate, if to_nether { 16 } else { 128 });
let (min_section, max_section) = if to_nether { (0, 15) } else { (-4, 19) };
@@ -327,14 +335,11 @@ impl Game<'_> {
max_section,
);
let centre = found.unwrap_or(plan.approximate);
if let Some((_, target)) = self.travelling.as_mut() {
*target = Vec3::new(
f64::from(centre.x) + 0.5,
f64::from(centre.y),
f64::from(centre.z) + 0.5,
);
}
Ok(())
Vec3::new(
f64::from(centre.x) + 0.5,
f64::from(centre.y),
f64::from(centre.z) + 0.5,
)
}
/// The player's arrival once the destination has loaded around the
/// approximate exit: the waiting entities first, then the exit portal
@@ -481,9 +486,10 @@ impl Game<'_> {
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let mut portal_sections = horizon_client::fast_hash::FxHashMap::default();
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height) {
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
}
}
@@ -497,7 +503,14 @@ impl Game<'_> {
}
/// The first nether portal block an entity box swept through
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
fn swept_portal(&self, from: Vec3, to: Vec3, width: f32, height: f32) -> Option<BlockPos> {
fn swept_portal(
&self,
from: Vec3,
to: Vec3,
width: f32,
height: f32,
portal_sections: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
) -> Option<BlockPos> {
let half = f64::from(width) / 2.0;
let bounds = horizon_core::geometry::Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
@@ -508,6 +521,13 @@ impl Game<'_> {
f64::from(1.0e-5_f32),
f64::from(1.0e-5_f32),
));
// Every block the sweep visits lies in the box covering both ends.
let reach = bounds
.union_bounds(bounds.move_by(from.subtract(to)))
.inflate(Vec3::new(1.0, 1.0, 1.0));
if !self.may_hold_portal(reach, portal_sections) {
return None;
}
let mut found = None;
let _ = horizon_core::geometry::sweep_intersected_blocks(
from,
@@ -529,6 +549,54 @@ impl Game<'_> {
);
found
}
/// Whether a section `reach` touches has a nether portal state in its
/// palette: without one, no block there is a portal. Answers are kept
/// per section for the tick; a box over many sections is not checked.
fn may_hold_portal(
&self,
reach: horizon_core::geometry::Aabb,
seen: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
) -> bool {
use horizon_client::chunk::{MIN_Y, SECTIONS};
let (min, max) = (reach.min_position(), reach.max_position());
if !min.is_finite() || !max.is_finite() {
return true;
}
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
let top = SECTIONS as i32 - 1;
let (x0, x1) = (min.x >> 4, max.x >> 4);
let (z0, z1) = (min.z >> 4, max.z >> 4);
let (y0, y1) = (
((min.y - MIN_Y) >> 4).clamp(0, top),
((max.y - MIN_Y) >> 4).clamp(0, top),
);
let count = (i64::from(x1) - i64::from(x0) + 1)
* (i64::from(y1) - i64::from(y0) + 1)
* (i64::from(z1) - i64::from(z0) + 1);
if count > 64 {
return true;
}
let behaviours = &self.res.behaviours;
let chunks = &self.world.chunks;
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
(y0..=y1).any(|sy| {
*seen.entry((cx, sy, cz)).or_insert_with(|| {
chunks
.get(&(cx, cz))
.and_then(|c| c.sections.get(sy as usize))
.is_some_and(|s| {
s.blocks.maybe_has(|v| {
behaviours
.portal_axis(horizon_world::registry::BlockStateId(v))
.is_some()
})
})
})
})
})
})
}
/// `Entity.teleportCrossDimension` away from the loaded dimension: the
/// entity is saved and removed, and waits for its dimension to load.
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
+5 -8
View File
@@ -186,16 +186,11 @@ impl Game<'_> {
}
/// Mouse buttons pressed while a key is being bound.
pub(super) fn options_waiting_for_key(&self) -> bool {
self.ui.options.selected_key.is_some() && self.ui.options.page() == Page::KeyBinds
self.ui.options.selected_key.is_some()
}
pub(super) fn options_bind_mouse(&mut self, button: MouseButton) -> Result<()> {
if let Some(name) = self.ui.options.selected_key.take() {
self.settings
.keys
.set(name, &horizon_client::keybinds::mouse_name(button));
self.handle_option_events(vec![Event::Changed])?;
}
Ok(())
let events = self.ui.options.bind_mouse(&mut self.settings, button);
self.handle_option_events(events)
}
pub(super) fn handle_option_events(&mut self, events: Vec<Event>) -> Result<()> {
for e in events {
@@ -206,6 +201,7 @@ impl Game<'_> {
self.apply_settings();
self.sync_mods();
}
Event::Close if self.ui.options.resource_error.is_some() => {}
Event::Close => {
self.settings.save(&Self::options_path());
self.screen = if std::mem::take(&mut self.options_to_world_options) {
@@ -244,6 +240,7 @@ impl Game<'_> {
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
r.gamma = s.gamma;
r.fullbright = s.extras.fullbright;
r.set_present_mode(s.vsync, s.horizon.present_mode);
r.animated_textures = s.horizon.animated_textures;
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
+37 -6
View File
@@ -35,7 +35,7 @@ pub fn open_folder(dir: &Path) {
}
}
/// The resource root to load from, given the vanilla root and the selection.
pub fn startup_root(vanilla: &Path, selected: &[String]) -> PathBuf {
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
let all = packs::discover(vanilla, &packs_dir());
let enabled = packs::enabled(&all, selected);
packs::resource_root(vanilla, &enabled, &cache_dir())
@@ -104,9 +104,18 @@ impl Game<'_> {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let root = startup_root(&vanilla, &self.settings.resource_packs);
if let Err(e) = self.reload_resources(&root) {
let result = startup_root(&vanilla, &self.settings.resource_packs)
.map_err(Into::into)
.and_then(|root| self.reload_resources(&root));
if let Err(e) = result {
log::warn!("Horizon: resource reload failed: {e}");
if self.screen != super::Screen::Options {
self.open_options_at(
self.title.is_some(),
horizon_client::options_ui::Page::Packs,
);
}
self.ui.options.resource_error = Some(e.to_string());
}
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
@@ -121,12 +130,35 @@ impl Game<'_> {
self.pack.context.biomes.clone(),
self.pack.context.light.clone(),
)?;
// Keep the working GPU resources, but release its exclusive window surface.
if let Some(renderer) = &mut self.renderer {
renderer.release_surface();
}
let renderer = match self
.window
.clone()
.map(|window| self.renderer_for(window, &pack, &atlas))
.transpose()
{
Ok(renderer) => renderer,
Err(error) => {
if let Some(renderer) = &mut self.renderer {
renderer.recreate_surface().map_err(|restore| {
format!(
"{error}; could not restore the previous rendering surface: {restore}"
)
})?;
}
return Err(error);
}
};
// Everything loaded: switch over.
self.random_tick_sections.clear();
self.options.resources = root.to_path_buf();
let context = pack.context.clone();
self.pack = pack;
self.atlas = atlas;
horizon_client::ui_theme::reset_atlas();
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
*self
.res
@@ -142,9 +174,8 @@ impl Game<'_> {
self.pending_uploads.clear();
// New GPU textures: a renderer built from the reloaded resources; the
// sections are uploaded again as they are meshed.
if let Some(window) = self.window.clone() {
self.renderer = None;
self.renderer = Some(self.new_renderer(window)?);
if let Some(renderer) = renderer {
self.renderer = Some(renderer);
self.apply_ui_settings();
}
self.ui.chat.rescale(&self.atlas);
-7
View File
@@ -365,13 +365,6 @@ impl Game<'_> {
self.close_book();
return Ok(());
}
if let Some(remote) = self.remote.as_mut()
&& remote.chat_input.is_some()
{
remote.chat_input = None;
self.capture(true);
return Ok(());
}
self.key_pressed(KeyCode::Escape)
}
+7 -11
View File
@@ -31,7 +31,7 @@ use winit::{
dpi::PhysicalPosition,
event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent},
event_loop::ActiveEventLoop,
keyboard::{KeyCode, NamedKey},
keyboard::KeyCode,
};
/// D-pad auto-repeat: the first step at once, the next after a pause.
@@ -859,7 +859,6 @@ impl Game<'_> {
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
|| self.recipe_search_focused()
|| self.station_typing()
|| self.remote_chat_open()
}
/// Open the on-screen keyboard over the focused field: Horizon's, or
@@ -890,7 +889,7 @@ impl Game<'_> {
&& self.pad.in_use
&& menu.open_polls >= 3
&& self.pad_focus_mode()
&& (self.screen == Screen::Chat || self.sign_edit.is_some() || self.remote_chat_open())
&& (self.screen == Screen::Chat || self.sign_edit.is_some())
{
self.pad.menu.osk_dismissed = true;
self.pad_open_keyboard();
@@ -901,10 +900,10 @@ impl Game<'_> {
/// the field that has focus.
fn pad_osk_output(&mut self, out: osk::Output) {
use osk::Output;
let (key, named, text) = match &out {
Output::Text(t) => (None, None, Some(t.as_str())),
Output::Backspace => (Some(KeyCode::Backspace), Some(NamedKey::Backspace), None),
Output::Enter => (Some(KeyCode::Enter), Some(NamedKey::Enter), None),
let (key, text) = match &out {
Output::Text(t) => (None, Some(t.as_str())),
Output::Backspace => (Some(KeyCode::Backspace), None),
Output::Enter => (Some(KeyCode::Enter), None),
Output::Close => {
self.pad.menu.osk = None;
self.pad.menu.osk_dismissed = true;
@@ -933,8 +932,6 @@ impl Game<'_> {
if key != Some(KeyCode::Enter) {
self.station_text(text, backspace);
}
} else if self.remote_chat_open() {
self.remote_chat_input(named, text);
} else if self.screen == Screen::WorldOptions {
if key != Some(KeyCode::Enter) {
self.world_options_text(text);
@@ -985,8 +982,7 @@ impl Game<'_> {
.menu
.focus
.is_some_and(|f| f.center().1 > batch.height / 2.0)
|| self.screen == Screen::Chat
|| self.remote_chat_open();
|| self.screen == Screen::Chat;
let mut ui = horizon_client::ui_theme::Ui::new(&self.atlas, batch, self.cursor);
keyboard.draw(&mut ui, at_top, self.ui.options.pad_style);
ui.finish();
+73 -8
View File
@@ -201,7 +201,7 @@ impl Game<'_> {
)
};
let p = self.player.as_ref()?;
let sleeping = self.sleeping.map(|(head, _)| {
let sleeping = self.sleeping.map(|head| {
let state = self.world.block(head[0], head[1], head[2]);
let props = self
.pack
@@ -313,6 +313,15 @@ impl Game<'_> {
.map_or(25, |s| Self::charge_ticks(s).max(1)) as i32;
let attack = (self.swing.old_animation, self.swing.animation);
let swinging = self.swing.swinging();
// currentSwing.hand().asArm(mainArm).
let swing_right_arm = swinging.then(|| {
(self.swing.attack_hand() == horizon_client::first_person::Hand::Main)
!= self.settings.main_hand_left
});
// LivingEntity.isVisuallySwimming and Entity.isInWater.
let visually_swimming =
p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying);
let in_water = p.fluids.was_touching_water;
let (yaw, pitch) = (self.yaw, self.pitch);
let main = self.inventory[self.selected]
.as_ref()
@@ -378,6 +387,8 @@ impl Game<'_> {
m.extra.charged = charged;
m.extra.ints[5] = charge_duration;
m.extra.elytra.tick(fall_flying, crouching, velocity);
m.extra.swim.tick(visually_swimming, in_water);
m.extra.swing_right_arm = swing_right_arm;
m.hurt_time = hurt;
}
/// Whether the player's own model shows in the world view.
@@ -427,7 +438,7 @@ impl Game<'_> {
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
/// cursor; drawn as depth-sorted, back-face-culled quads.
/// cursor; drawn with a depth buffer and back-face culling.
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_player_preview(
&self,
@@ -495,9 +506,9 @@ impl Game<'_> {
let quads = player_preview_quads(verts, gui, fire, &atlas.sprites);
let start = batch.vertices.len();
for (_, p, uv, color) in quads {
batch.quad(p, uv, color);
batch.quad_3d(p, uv, color);
}
batch.clip_since(start, x0, y0, x1, y1);
batch.depth_since(start, [x0, y0, x1, y1]);
}
/// The survival inventory's player box (`InventoryScreen`).
pub(super) fn draw_inventory_player(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
@@ -570,7 +581,7 @@ fn player_preview_quads(
gui: glam::Mat4,
fire: Option<(f32, f32)>,
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
) -> Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> {
) -> Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> {
let flame_start = verts.len();
if let Some((width, height)) = fire {
horizon_client::fire_render::flames(
@@ -584,7 +595,7 @@ fn player_preview_quads(
&mut verts,
);
}
let mut quads: Vec<(f32, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
let mut quads: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = Vec::new();
for (index, q) in verts.as_chunks::<4>().0.iter().enumerate() {
let p: [glam::Vec3; 4] =
std::array::from_fn(|i| gui.transform_point3(glam::Vec3::from_array(q[i].pos)));
@@ -606,12 +617,12 @@ fn player_preview_quads(
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
quads.push((
depth,
p.map(|v| [v.x, v.y]),
p.map(|v| v.to_array()),
std::array::from_fn(|i| q[i].uv),
color,
));
}
// Far (low z) first: the viewer is on the +z side.
// Keep far-to-near order for translucent faces; opaque overlap uses depth.
quads.sort_by(|a, b| a.0.total_cmp(&b.0));
quads
}
@@ -619,6 +630,60 @@ fn player_preview_quads(
#[cfg(test)]
mod preview_tests {
use super::*;
#[test]
fn preview_keeps_crossing_faces_depth_and_clips_without_dropping_faces() {
use horizon_client::{
hud::{Blend, HudBatch},
mesher::Vertex,
};
let face = |z: [f32; 4]| {
[[-2.0, -1.0], [-2.0, 1.0], [2.0, 1.0], [2.0, -1.0]]
.into_iter()
.zip(z)
.map(|([x, y], z)| Vertex {
pos: [x, y, z],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0; 4],
})
};
// A shield and arm can intersect: neither average depth orders both ends.
let verts = face([1.0, 1.0, -1.0, -1.0]).chain(face([0.0; 4])).collect();
let quads = player_preview_quads(verts, glam::Mat4::IDENTITY, None, &Default::default());
assert_eq!(quads.len(), 2);
let mut batch = HudBatch::new([320, 240]);
for (_, p, uv, color) in quads {
batch.quad_3d(p, uv, color);
}
// Its centre is outside this scissor; the visible part must still be drawn.
batch.depth_since(0, [1.0, 0.0, 2.0, 1.0]);
assert_eq!(batch.vertices.len(), 12);
let shield = &batch.vertices[..6];
let arm = &batch.vertices[6..];
assert!(shield[0].depth < arm[0].depth);
assert!(shield[2].depth > arm[2].depth);
assert_eq!(batch.runs(), vec![(0..12, Blend::Depth([1, 0, 1, 1]))]);
}
#[test]
#[ignore = "requires prepared model data in HORIZON_TEST_RESOURCES"]
fn player_render_transform_uses_vanilla_avatar_scale() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let mut m = Mob::new(1, player_type(&root).unwrap(), Vec3::ZERO, 0.0);
for (id, expected) in [("minecraft:player", 0.9375), ("minecraft:zombie", 1.0)] {
Arc::make_mut(&mut m.kind).id = id.into();
let transform =
horizon_client::mobs::render::base_transform_at(&m, 1.0, glam::Mat4::IDENTITY);
for axis in [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z] {
assert!((transform.transform_vector3(axis).length() - expected).abs() < 1e-6);
}
}
}
#[test]
fn burning_inventory_preview_keeps_visible_fullbright_flames() {
let rect = |u0| horizon_client::hud::Rect {
+78 -37
View File
@@ -49,6 +49,14 @@ impl Game<'_> {
});
}
}
/// What a player's body was last given for its effects and enchantments:
/// a guest keeps their own while the host's are in `PotionState`.
#[derive(Default)]
pub(super) struct BodyPotions {
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
protection_for: Option<Vec<Option<ItemStack>>>,
enchant_modifiers: Vec<(String, String, f64, String)>,
}
/// Effects, thrown potions and the attribute modifiers last applied.
#[derive(Default)]
pub(super) struct PotionState {
@@ -63,6 +71,14 @@ pub(super) struct PotionState {
}
impl PotionState {
/// Trade the player's own state (effects and what their body was given)
/// with a guest's, around `as_guest`. Potions in flight stay.
pub(super) fn swap_player(&mut self, effects: &mut PlayerEffects, body: &mut BodyPotions) {
std::mem::swap(&mut self.effects, effects);
std::mem::swap(&mut self.applied, &mut body.applied);
std::mem::swap(&mut self.protection_for, &mut body.protection_for);
std::mem::swap(&mut self.enchant_modifiers, &mut body.enchant_modifiers);
}
/// Thrown potions in flight as (entity type, position, motion), for the
/// dispenser parity fixture.
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
@@ -274,8 +290,16 @@ impl Game<'_> {
}
}
}
/// `LivingEntity.tickEffects`, attribute modifiers and the status flags.
/// The local player's effects (`tick_mob_effects`), then the potions
/// in flight, which a server without a local player still has.
pub(super) fn tick_effects(&mut self) {
self.tick_mob_effects();
self.tick_thrown_potions();
}
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
/// player or a guest acting (`as_guest`), then its attribute modifiers
/// and status flags.
pub(super) fn tick_mob_effects(&mut self) {
let Some(player) = self.player.as_ref() else {
return;
};
@@ -294,6 +318,20 @@ impl Game<'_> {
for action in actions {
self.apply_effect_action(action);
}
self.apply_effect_status();
}
/// A client's own effects (`LivingEntity.tickEffects` on the client,
/// `MobEffectInstance.tickClient`): they count down, and the server
/// removes them. Their modifiers and flags move the predicted player.
pub(super) fn tick_client_effects(&mut self) {
for e in &mut self.potions.effects.list {
e.tick_client();
}
self.apply_effect_status();
}
/// The effects' attribute modifiers, armor protection and status flags
/// on `self.player`.
fn apply_effect_status(&mut self) {
let modifiers = self.potions.effects.modifiers();
if modifiers != self.potions.applied
&& let Some(p) = self.player.as_mut()
@@ -346,7 +384,6 @@ impl Game<'_> {
status.protection = std::mem::take(&mut p.status.protection);
p.status = status;
}
self.tick_thrown_potions();
}
fn tick_thrown_potions(&mut self) {
let mut thrown = std::mem::take(&mut self.potions.thrown);
@@ -430,6 +467,41 @@ impl Game<'_> {
self.splash(&stack, at);
}
}
/// A splash potion's effects on the player in place, by their distance.
fn splash_on_player(
&mut self,
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
scale: f64,
) {
if let Some(p) = self.player.as_ref() {
let center = Vec3::new(
p.position.x,
p.position.y + f64::from(p.dimensions.height) / 2.0,
p.position.z,
);
let d2 =
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
let intensity = 1.0 - d2.sqrt() / 4.0;
for (id, duration, amp) in effects {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
self.apply_effect_action(a);
}
} else {
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
self.potions.effects.add(EffectInstance::new(e.id, d, *amp));
}
}
}
}
}
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
if &*stack.item == "minecraft:experience_bottle" {
@@ -490,34 +562,9 @@ impl Game<'_> {
let Some(def) = stack.potion().and_then(brewing::potion) else {
return;
};
// The player.
if let Some(p) = self.player.as_ref() {
let center = Vec3::new(
p.position.x,
p.position.y + f64::from(p.dimensions.height) / 2.0,
p.position.z,
);
let d2 =
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
let intensity = 1.0 - d2.sqrt() / 4.0;
for (id, duration, amp) in def.effects {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
self.apply_effect_action(a);
}
} else {
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
self.potions.effects.add(EffectInstance::new(e.id, d, *amp));
}
}
}
}
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations.enchantments.entity_tags;
let mut hits = Vec::new();
@@ -606,13 +653,7 @@ impl Game<'_> {
/// Effects saved in level.dat (`Player.active_effects`).
pub(super) fn restore_effects(&mut self) {
self.potions = PotionState::default();
for (id, duration, amplifier, ambient, _) in self.level.active_effects.clone() {
if let Some(def) = brewing::effect(&id) {
let mut e = EffectInstance::new(def.id, duration, amplifier);
e.ambient = ambient;
self.potions.effects.add(e);
}
}
self.potions.effects = PlayerEffects::from_saved(&self.level.active_effects);
}
pub(super) fn effects_json(&self) -> serde_json::Value {
serde_json::json!({
+18 -7
View File
@@ -39,6 +39,17 @@ pub(super) struct Raiding {
homes: HashMap<i32, (i64, std::sync::Arc<Vec<[i32; 3]>>)>,
}
impl Raiding {
/// Exchange the level-held parts (`ServerLevel.getRaids`) with another
/// level's (game/levels.rs).
pub(super) fn swap_level(&mut self, other: &mut Self) {
std::mem::swap(&mut self.raids, &mut other.raids);
std::mem::swap(&mut self.loaded, &mut other.loaded);
std::mem::swap(&mut self.bar_hurts, &mut other.bar_hurts);
std::mem::swap(&mut self.homes, &mut other.homes);
}
}
/// `SectionPos` of a block position.
pub(super) fn section(p: [i32; 3]) -> (i32, i32, i32) {
(p[0] >> 4, p[1] >> 4, p[2] >> 4)
@@ -613,17 +624,17 @@ impl Game<'_> {
raider::state_mut(m).ticks_outside_raid = ticks;
}
}
Action::Victory { heroes, amplifier } => {
let me = self.level.player_uuid;
if me.is_some_and(|u| heroes.contains(&u)) && !self.spectator() {
// Raid.tick on a victory: each hero who is still alive.
Action::Victory { heroes, amplifier } => self.for_each_player(|g| {
if heroes.contains(&g.acting_uuid()) && !g.spectator() {
let mut effect =
EffectInstance::new("hero_of_the_village", raid::HERO_DURATION, amplifier);
effect.visible = false;
self.potions.effects.add(effect);
self.stats.custom("raid_win", 1);
self.trigger_player("minecraft:hero_of_the_village");
g.potions.effects.add(effect);
g.stats.custom("raid_win", 1);
g.trigger_player("minecraft:hero_of_the_village");
}
}
}),
}
}
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
+122 -1
View File
@@ -110,7 +110,7 @@ fn sprite(name: &str) -> String {
impl Game<'_> {
/// The book the open screen has, if any.
fn active_book(&self) -> Option<BookType> {
if self.screen != Screen::Inventory || self.remote.is_some() || self.station.is_some() {
if self.screen != Screen::Inventory || self.station.is_some() {
return None;
}
if self.mount_active() {
@@ -262,6 +262,33 @@ impl Game<'_> {
/// The items the book counts (`RecipeBookComponent.updateStackedContents`):
/// the inventory and the menu's crafting slots, as the menu engine counts them.
fn book_available(&mut self, _book: BookType) -> Stacked {
if self.remote.is_some() {
// A server's menu: the slots it last sent, counted the same way.
use horizon_client::menu::{Env, Kind, Menu};
let table = self.craft_size == 3;
let grid: Vec<_> = (0..self.craft_size * self.craft_size)
.map(|k| self.slot_mut(100 + k).clone())
.collect();
let mut menu = Menu::new(if table {
Kind::Crafting
} else {
Kind::Inventory
});
let env = Env {
items: &self.res.items,
recipes: &self.res.recipes,
brewing: &self.res.stations.brewing,
banners: &self.res.stations.banners,
trade: None,
};
for i in 0..36 {
menu.set_player_item(i, self.inventory[i].clone());
}
for (k, stack) in grid.into_iter().enumerate() {
menu.set_slot(&env, 1 + k, stack);
}
return menu.stacked_contents(&env);
}
let mut out = Stacked::default();
let stock = &mut out;
self.menu_session(|menu, env, _| {
@@ -274,6 +301,10 @@ impl Game<'_> {
/// replacing batch (vanilla's server iterates an identity hash set; this
/// one goes in id order), then each unlock and removal.
fn sync_client_book(&mut self) {
// On a server the book is the server's packets' (remote_recipe_book_*).
if self.remote.is_some() {
return;
}
let known = &self.level.recipe_book.recipes;
let index = &self.res.stations.recipe_index;
let ui = &mut self.recipe_ui;
@@ -660,6 +691,10 @@ impl Game<'_> {
}
false
}
/// How many displays the client's book holds.
pub(super) fn recipe_book_known(&self) -> usize {
self.recipe_ui.client.displays().len()
}
/// The recipe book's buttons a controller can focus: the book button,
/// recipes, page arrows, the craftable filter and the tabs (GUI pixels;
/// the search box registers itself as a field).
@@ -800,6 +835,18 @@ impl Game<'_> {
else {
return;
};
if self.remote.is_some() {
// The server places it and answers with the slots, or a ghost.
let container = self.remote_menu_id();
self.send(horizon_protocol::packets::play::Serverbound::PlaceRecipe(
horizon_protocol::packets::recipes::PlaceRecipe {
container,
recipe: entry.display_id,
use_max,
},
));
return;
}
let creative = self.creative;
let mut post = None;
let answer = &mut post;
@@ -828,6 +875,80 @@ impl Game<'_> {
}
}
// ---- a server's recipe book ----
/// ClientboundRecipeBookAddPacket (`ClientRecipeBook.add`): the
/// displays join the book, highlighted when new, and the recipes whose
/// entries ask for it show the toast.
pub(super) fn remote_recipe_book_add(
&mut self,
p: horizon_protocol::packets::recipes::RecipeBookAdd,
) {
let ui = &mut self.recipe_ui;
if p.replace {
ui.client.clear();
}
let mut toast = Vec::new();
for e in &p.entries {
ui.client.add(e.id);
if e.flags & 2 != 0 {
ui.client.highlight.insert(e.id);
}
if e.flags & 1 != 0
&& let Some(id) = self.res.stations.recipe_index.recipe_of_display(e.id)
&& !toast.iter().any(|t| t == id)
{
toast.push(id.to_owned());
}
}
// A local world opened later syncs its book from scratch.
ui.client_known = None;
ui.client_version += 1;
self.recipe_toast(&toast);
}
/// ClientboundRecipeBookRemovePacket.
pub(super) fn remote_recipe_book_remove(
&mut self,
p: horizon_protocol::packets::recipes::RecipeBookRemove,
) {
for id in p.ids {
self.recipe_ui.client.remove(id);
}
self.recipe_ui.client_version += 1;
}
/// ClientboundRecipeBookSettingsPacket: each book's open and filter flags.
pub(super) fn remote_recipe_book_settings(
&mut self,
p: horizon_protocol::packets::recipes::RecipeBookSettings,
) {
for (i, (open, filtering)) in p.books.into_iter().enumerate() {
self.level.recipe_book.open[i] = open;
self.level.recipe_book.filtering[i] = filtering;
}
}
/// ClientboundPlaceGhostRecipePacket: the recipe last placed, faded into
/// the open menu's grid.
pub(super) fn remote_ghost_recipe(
&mut self,
p: horizon_protocol::packets::recipes::PlaceGhostRecipe,
) {
let open = self.remote_menu_id();
let Some(book_type) = self.active_book() else {
return;
};
if p.container != open {
return;
}
let entry = self.recipe_ui.last_placed.as_ref().and_then(|id| {
let book = self.recipe_ui.book.as_ref()?;
book.entry(id).cloned()
});
if let Some(entry) = entry {
self.recipe_ui.ghost.clear();
self.fill_ghost(book_type, &entry);
}
}
// ---- ghost recipe ----
/// `fillGhostRecipe`: the recipe's ingredients faded into the slots.
+493 -126
View File
@@ -68,6 +68,7 @@ struct Lerp {
pub(super) struct Remote {
pub session: Session,
pub brand: String,
builtin: Arc<Builtin>,
lang: Arc<Language>,
registries: Arc<Dynamic>,
@@ -88,6 +89,11 @@ pub(super) struct Remote {
was_sprinting: bool,
carried: Option<usize>,
loaded_sent: bool,
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
/// with the sequence of the last action that changed them and the state the server
/// last said they have. Until the server acknowledges that sequence its block updates
/// only replace the remembered state; then the block takes it.
predictions: HashMap<[i32; 3], (i32, i32)>,
/// Open container: (window id, menu name).
menu: Option<(i32, String)>,
/// The open menu's title as the server named it.
@@ -101,7 +107,10 @@ pub(super) struct Remote {
health: (f32, i32, f32),
game_mode: i32,
pub chat: Vec<(String, Instant)>,
pub chat_input: Option<String>,
/// Lines added since the start, for the control socket's events.
pub chat_received: u64,
/// The server's command tree (ClientboundCommandsPacket).
pub commands: Option<horizon_client::command::Dispatcher>,
action_bar: Option<(String, Instant)>,
players: HashMap<u128, String>,
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
@@ -126,6 +135,8 @@ pub(super) struct Remote {
decode_errors: u32,
clock: Option<i32>,
clock_rate: f32,
/// `ClientChunkCache.Storage`'s view centre (ClientboundSetChunkCacheCenterPacket).
chunk_center: (i32, i32),
}
fn face_direction(face: [i32; 3]) -> u8 {
@@ -242,6 +253,7 @@ impl Game<'_> {
progress: 0.0,
total: 0,
};
self.ui.registries.permission = 0;
self.screen = Screen::Playing;
self.title = None;
self.dim = Dim::Overworld;
@@ -251,6 +263,7 @@ impl Game<'_> {
self.apply_settings();
self.remote = Some(Remote {
session,
brand: "unknown".into(),
builtin: Arc::new(builtin),
lang,
registries: Arc::new(Dynamic::default()),
@@ -271,6 +284,7 @@ impl Game<'_> {
was_sprinting: false,
carried: None,
loaded_sent: false,
predictions: HashMap::new(),
menu: None,
menu_title: None,
state_id: 0,
@@ -281,7 +295,8 @@ impl Game<'_> {
health: (20.0, 20, 5.0),
game_mode: 0,
chat: Vec::new(),
chat_input: None,
chat_received: 0,
commands: None,
action_bar: None,
players: HashMap::new(),
tab_players: HashMap::new(),
@@ -299,7 +314,13 @@ impl Game<'_> {
decode_errors: 0,
clock: None,
clock_rate: 1.0,
chunk_center: (0, 0),
});
// The level's TickRateManager follows the server's ticking packets.
self.clock = horizon_client::clock::FixedStep::client();
// The player's advancements and statistics are the server's.
self.adv = Default::default();
self.stats = Default::default();
Ok(())
}
@@ -322,6 +343,9 @@ impl Game<'_> {
None => false,
};
horizon_client::crash::clear_detail("Server");
self.clock = Default::default();
self.adv = Default::default();
self.stats = Default::default();
self.debug_charts.reset();
self.tab_list.reset();
self.score.board = Default::default();
@@ -380,6 +404,31 @@ impl Game<'_> {
.unwrap_or("minecraft:air"),
)
}
/// A mob effect's network id as the effect (`brewing::EFFECTS` id).
fn remote_effect_id(&self, effect: i32) -> Option<&'static str> {
let r = self.remote.as_ref()?;
let name = r.builtin.get("minecraft:mob_effect")?.name(effect)?;
horizon_client::brewing::effect(name).map(|d| d.id)
}
/// ClientPacketListener.handleUpdateMobEffect for the player itself
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
/// Other entities' effects are not shown.
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
if !self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
return;
}
let Some(id) = self.remote_effect_id(p.effect) else {
return;
};
let mut e = horizon_client::effects::EffectInstance::new(id, p.duration, p.amplifier);
e.ambient = p.flags & 1 != 0;
e.visible = p.flags & 2 != 0;
let list = &mut self.potions.effects.list;
match list.iter_mut().find(|x| x.id == id) {
Some(existing) => *existing = e,
None => list.push(e),
}
}
fn item_id(&self, name: &str) -> i32 {
let r = self.remote.as_ref().expect("remote");
r.builtin
@@ -415,16 +464,53 @@ impl Game<'_> {
}
Some(stack)
}
fn chat_line(&mut self, text: String) {
pub(super) fn chat_line(&mut self, text: String) {
log::info!("[chat] {text}");
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
if r.chat.len() > 100 {
r.chat.remove(0);
}
}
}
/// The minecarts the server sent, for the control socket's `status`
/// (their velocity is not tracked here).
pub(super) fn remote_carts(&self) -> Option<Vec<serde_json::Value>> {
let r = self.remote.as_ref()?;
let mut carts: Vec<&Tracked> = r
.entities
.values()
.filter(|t| t.kind.ends_with("minecart"))
.collect();
carts.sort_by(|a, b| {
a.position
.partial_cmp(&b.position)
.unwrap_or(std::cmp::Ordering::Equal)
});
Some(
carts
.into_iter()
.map(|t| serde_json::json!({"kind": t.kind, "position": t.position, "velocity": [0.0, 0.0, 0.0]}))
.collect(),
)
}
/// The names in the server's player list, sorted.
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
let mut names: Vec<String> = r.players.values().cloned().collect();
names.sort();
Some(names)
}
/// The open menu's window id (0: the inventory).
pub(super) fn remote_menu_id(&self) -> i32 {
self.remote
.as_ref()
.and_then(|r| r.menu.as_ref())
.map_or(0, |m| m.0)
}
/// The server's last `n` chat lines, newest first.
pub(super) fn remote_chat(&self, n: usize) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
@@ -456,6 +542,11 @@ impl Game<'_> {
r.status = format!("Joined as {name}; loading terrain...");
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
@@ -509,12 +600,9 @@ impl Game<'_> {
r.dimension = dimension;
r.game_mode = spawn.game_type;
r.loaded_sent = false;
r.predictions.clear();
self.set_mode_id(spawn.game_type);
self.dim = if spawn.dimension == "minecraft:the_nether" {
Dim::Nether
} else {
Dim::Overworld
};
self.dim = Dim::from_id(&spawn.dimension);
if !same {
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
@@ -541,6 +629,8 @@ impl Game<'_> {
r.entities.clear();
r.lerps.clear();
r.pending_items.clear();
// A new ClientLevel: a new chunk storage, centred on 0, 0.
r.chunk_center = (0, 0);
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections();
@@ -617,6 +707,128 @@ impl Game<'_> {
}
Ok(chunk)
}
/// `ClientChunkCache.Storage.inRange`: within the storage around the
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
self.remote.as_ref().is_none_or(|r| {
let range = r.view_distance.max(2) + 3;
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
})
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let gone: Vec<(i32, i32)> = self
.world
.chunks
.keys()
.copied()
.filter(|&(x, z)| !self.remote_chunk_in_range(x, z))
.collect();
for (x, z) in gone {
self.streaming.forget(&mut self.world, x, z);
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
if let Some(renderer) = self.renderer.as_mut() {
for sy in 0..SECTIONS as i32 {
renderer.remove((x, sy, z));
}
}
}
}
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
/// packet's type, loads the tag (`loadWithComponents`); an empty tag
/// changes nothing.
fn remote_block_entity_data(&mut self, pos: [i32; 3], kind: i32, tag: &Tag) {
let key = (pos[0], pos[1], pos[2]);
let Some(name) = self
.remote
.as_ref()
.and_then(|r| r.builtin.get("minecraft:block_entity_type")?.name(kind))
else {
return;
};
let Some(entity) = self.world.block_entities.get(&key) else {
return;
};
let Tag::Compound(fields) = tag else {
return;
};
if entity.id() != name || fields.is_empty() {
return;
}
let mut full = fields.clone();
full.insert("id".into(), Tag::String(name.into()));
for (k, v) in ["x", "y", "z"].into_iter().zip(pos) {
full.insert(k.into(), Tag::Int(v));
}
if let Some((_, loaded)) = horizon_client::save::decode_block_entity(&Tag::Compound(full)) {
self.world.block_entities.insert(key, loaded);
// The section is drawn again with what the block entity shows.
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
}
}
/// `handleAnimate`: a player wakes up, or an entity gets the critical
/// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter.
fn remote_animate(&mut self, id: i32, action: u8) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if me {
self.player.as_ref().map(|p| {
let d = p.dimensions;
(
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
)
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
(
[m.position.x, m.position.y, m.position.z],
m.kind.width,
m.kind.height,
)
})
};
let Some((feet, width, height)) = body else {
return;
};
let particle = match action {
// Player.stopSleepInBed.
0 => {
if me {
self.sleeping = None;
}
return;
}
1 => ParticleType::Crit,
2 => ParticleType::EnchantedHit,
_ => return,
};
self.particles
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
}
/// `handleServerData`: the server list entry of the server joined keeps
/// the icon the server sends (`ServerList.saveSingleServer`). Single
/// player has no entry.
fn remote_server_data(&mut self, icon: Option<&[u8]>) {
let (Some(r), Some(png)) = (self.remote.as_ref(), icon) else {
return;
};
if self.integrated.is_some() {
return;
}
if let Ok(dir) = std::env::current_dir() {
horizon_client::multiplayer::save_server_icon(
&dir.join("saves"),
&r.session.address,
png,
);
}
}
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
fn sync_block_entity(&mut self, pos: (i32, i32, i32), state: BlockStateId) {
let name = self.res.facts.get(state).name.clone();
@@ -636,6 +848,42 @@ impl Game<'_> {
}
}
}
/// ClientLevel.setServerVerifiedBlockState: a block the client predicted keeps its
/// predicted state until the server acknowledges the action; the server's state is
/// remembered for then.
fn server_block(&mut self, pos: [i32; 3], state: i32) {
if let Some((_, known)) = self
.remote
.as_mut()
.and_then(|r| r.predictions.get_mut(&pos))
{
*known = state;
return;
}
self.set_block(pos, state);
}
/// BlockStatePredictionHandler.endPredictionsUpTo (ClientboundBlockChangedAckPacket): the
/// actions up to `sequence` are settled, so the blocks they predicted take the state the
/// server gave them (a break the server refused comes back).
fn end_predictions(&mut self, sequence: i32) {
let Some(r) = self.remote.as_mut() else {
return;
};
let settled: Vec<_> = r
.predictions
.iter()
.filter(|(_, (s, _))| *s <= sequence)
.map(|(&pos, &(_, state))| (pos, state))
.collect();
for (pos, state) in settled {
if let Some(r) = self.remote.as_mut() {
r.predictions.remove(&pos);
}
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 != state {
self.set_block(pos, state);
}
}
}
fn set_block(&mut self, pos: [i32; 3], state: i32) {
let state = BlockStateId(state.max(0) as u32);
if self.world.loaded(pos[0], pos[2])
@@ -746,6 +994,8 @@ impl Game<'_> {
5..=8 => Some(500 + s - 5),
9..=35 => Some(s),
36..=44 => Some(s - 36),
// InventoryMenu.SHIELD_SLOT: the off hand.
45 => Some(504),
_ => None,
},
"minecraft:crafting" => match s {
@@ -805,6 +1055,7 @@ impl Game<'_> {
self.debug_charts.ping.log(delay);
}
C::Login(p) => {
self.level.hardcore = p.hardcore;
if let Some(r) = self.remote.as_mut() {
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
@@ -820,6 +1071,8 @@ impl Game<'_> {
));
}
C::Respawn(p) => {
// A new player, without effects: the server sends them again.
self.potions.effects.clear();
self.enter_remote_dimension(&p.spawn, true);
self.screen = Screen::Playing;
if let Some(r) = self.remote.as_mut() {
@@ -842,6 +1095,15 @@ impl Game<'_> {
})
.clamp(-90.0, 90.0);
}
// ClientChunkCache.replaceWithPacketData: a column outside the
// storage around the view centre is ignored.
C::LevelChunkWithLight(p) if !self.remote_chunk_in_range(p.x, p.z) => {
log::warn!(
"Ignoring chunk since it's not in the view range: {}, {}",
p.x,
p.z
);
}
C::LevelChunkWithLight(p) => match self.remote_chunk(&p) {
Ok(chunk) => {
let (x, z) = (p.x, p.z);
@@ -857,6 +1119,8 @@ impl Game<'_> {
);
let state = self.world.block(pos.0, pos.1, pos.2);
self.sync_block_entity(pos, state);
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
}
}
Err(e) => log::warn!("Horizon: chunk {} {}: {e}", p.x, p.z),
@@ -879,7 +1143,7 @@ impl Game<'_> {
chunks_per_tick: 64.0,
}));
}
C::BlockUpdate(p) => self.set_block(p.pos, p.state),
C::BlockUpdate(p) => self.server_block(p.pos, p.state),
C::SectionBlocksUpdate(p) => {
let [sx, sy, sz] = p.section;
for change in p.changes {
@@ -889,10 +1153,10 @@ impl Game<'_> {
sy * 16 + (local & 15),
sz * 16 + (local >> 4 & 15),
];
self.set_block(pos, (change >> 12) as i32);
self.server_block(pos, (change >> 12) as i32);
}
}
C::BlockChangedAck(_) => {}
C::BlockChangedAck(p) => self.end_predictions(p.sequence),
// ClientLevel.blockEvent: BlockEntity.triggerEvent (chest lids,
// shulker boxes, bells) when the block is still the one it names.
C::BlockEvent(p) => {
@@ -1048,6 +1312,20 @@ impl Game<'_> {
}
}
}
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
// player's crouch (their model and Entity.isDiscrete's name tag).
if self
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
m.extra.crouching = flags & 2 != 0;
}
}
}
for (index, _, value) in &p.values {
if *index == ITEM_ENTITY_ITEM
&& let Value::Item(stack) = value
@@ -1120,6 +1398,13 @@ impl Game<'_> {
}
}
C::EntityEvent(p) => {
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.ui.registries.permission = (p.event - 24) as u8;
self.remote_world_options_permission(p.event >= 26);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
@@ -1133,7 +1418,21 @@ impl Game<'_> {
m.health = 0.0;
}
}
C::Animate(p) if p.action == 0 => {}
C::Animate(p) => self.remote_animate(p.id, p.action),
C::UpdateMobEffect(p) => self.remote_effect(&p),
C::RemoveMobEffect(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
&& let Some(id) = self.remote_effect_id(p.effect)
{
self.potions.effects.list.retain(|e| e.id != id);
}
}
C::ChangeDifficulty(p) => {
self.ui.difficulty = p.difficulty.clamp(0, 3) as u8;
self.level.difficulty_locked = p.locked;
self.remote_world_options_difficulty(self.ui.difficulty, p.locked);
}
C::GameRuleValues(p) => self.remote_world_options_rules(p.entries),
C::SetHealth(p) => {
if let Some(r) = self.remote.as_mut() {
r.health = (p.health, p.food, p.saturation);
@@ -1151,9 +1450,16 @@ impl Game<'_> {
.map(|r| r.lang.clone())
.unwrap_or_default();
let message = plain(&p.message, &lang);
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
if let Some(r) = self.remote.as_mut() {
r.death_message = Some(message);
}
// ClientPacketListener.handlePlayerCombatKill: the death
// screen, whatever health was last sent.
if me && self.screen != Screen::Dead && self.player.is_some() {
self.screen = Screen::Dead;
self.capture(false);
}
}
C::SetExperience(p) => {
self.experience = Experience {
@@ -1188,6 +1494,21 @@ impl Game<'_> {
}
}
C::GameEvent(p) => match p.event {
// GUARDIAN_ELDER_EFFECT: the apparition, and the curse sound
// unless the elder guardian is silent.
10 => {
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
if p.param == 1.0
&& let Some(at) = self.player.as_ref().map(|m| m.position)
{
self.audio.play(
"minecraft:entity.elder_guardian.curse",
Some([at.x, at.y, at.z]),
1.0,
1.0,
);
}
}
1 => {
self.world.weather.raining = true;
self.world.weather.rain_level = (0.0, 0.0);
@@ -1245,6 +1566,8 @@ impl Game<'_> {
match p.slot {
0..=35 => self.inventory[p.slot as usize] = stack,
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
// Inventory.SLOT_OFFHAND.
40 => self.offhand = stack,
_ => {}
}
self.remote_synced();
@@ -1264,6 +1587,10 @@ impl Game<'_> {
}
}
C::OpenScreen(p) => self.remote_open_screen(p),
C::RecipeBookAdd(p) => self.remote_recipe_book_add(p),
C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p),
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
C::PlaceGhostRecipe(p) => self.remote_ghost_recipe(p),
C::MerchantOffers(p) => self.remote_merchant_offers(p),
C::ContainerClose(_) => {
if self.screen == Screen::Inventory {
@@ -1272,6 +1599,15 @@ impl Game<'_> {
self.capture(true);
}
}
C::Commands(p) => {
let tree = horizon_client::command::network::from_packet(
&p,
horizon_client::command::vanilla::dispatcher(),
);
if let Some(r) = self.remote.as_mut() {
r.commands = Some(tree);
}
}
C::SystemChat(p) => {
let text = plain(
&p.content,
@@ -1452,6 +1788,68 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.view_distance = p.distance;
}
// ClientChunkCache.updateViewRadius: a new storage of that size.
self.drop_remote_chunks_out_of_range();
}
C::SetChunkCacheCenter(p) => {
if let Some(r) = self.remote.as_mut() {
r.chunk_center = (p.x, p.z);
}
self.drop_remote_chunks_out_of_range();
}
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
C::SetDefaultSpawnPosition(p) => {
let [x, y, z] = p.pos.pos;
self.spawn = (p.pos.dimension == "minecraft:overworld")
.then(|| Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5));
}
C::ServerData(p) => self.remote_server_data(p.icon.as_deref()),
// handleTickingState / handleTickingStep: the level's TickRateManager.
C::TickingState(p) => {
self.clock.set_rate(p.rate);
self.clock.set_frozen(p.frozen);
}
C::TickingStep(p) => self.clock.set_frozen_ticks_to_run(p.steps),
C::UpdateAdvancements(p) => {
let icons = p
.added
.iter()
.map(|a| {
a.display
.as_ref()
.map_or_else(String::new, |d| self.item_name(d.icon.item).to_string())
})
.collect();
self.remote_advancements(p, icons);
}
// handleAwardStats: StatsCounter.setValue for each.
C::AwardStats(p) => {
let Some(r) = self.remote.as_ref() else {
return Ok(());
};
let mut known = Vec::new();
for (ty, value, count) in p.stats {
let Some(ty) = r
.builtin
.get("minecraft:stat_type")
.and_then(|t| t.name(ty))
else {
continue;
};
let values = match ty {
"minecraft:mined" => "minecraft:block",
"minecraft:killed" | "minecraft:killed_by" => "minecraft:entity_type",
"minecraft:custom" => "minecraft:custom_stat",
_ => "minecraft:item",
};
if let Some(key) = r.builtin.get(values).and_then(|v| v.name(value)) {
known.push((ty.to_owned(), key.to_owned(), count));
}
}
for (ty, key, count) in known {
self.stats.values.entry(ty).or_default().insert(key, count);
}
}
C::SetActionBarText(p) | C::SetTitleText(p) => {
let lang = self
@@ -1784,10 +2182,15 @@ impl Game<'_> {
/// One client tick while connected (Minecraft.tick with a ClientLevel).
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.ticks += 1;
self.world.game_time += 1;
let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate);
if rate > 0.0 {
self.world.day_time += rate.round() as i64;
// Minecraft.tick: TickRateManager.tick; ClientLevel.tick advances
// the time only while the level runs normally.
let running = self.clock.world_runs();
if running {
self.world.game_time += 1;
let rate = self.remote.as_ref().map_or(1.0, |r| r.clock_rate);
if rate > 0.0 {
self.world.day_time += rate.round() as i64;
}
}
// Weather levels drift toward their targets between server updates.
let w = &mut self.world.weather;
@@ -1798,11 +2201,12 @@ impl Game<'_> {
}
// ClientLevel.tick: the border's size change.
self.border.tick();
self.tick_remote_entities();
self.tick_remote_entities(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells.
self.tick_block_entity_anims();
self.tick_hand();
self.tick_particles();
self.tick_client_effects();
let ghast_collisions = self.happy_ghast_player_collisions();
let Some(player) = self.player.as_mut() else {
return Ok(());
@@ -1820,13 +2224,13 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.loaded_sent = true;
}
self.capture_loaded_world();
}
let stick = self.pad_move();
let Some(player) = self.player.as_mut() else {
return Ok(());
};
let chatting = self.remote.as_ref().is_some_and(|r| r.chat_input.is_some());
let playing = self.screen == Screen::Playing && !chatting;
let playing = self.screen == Screen::Playing;
let held = |k| playing && self.keys.contains(&k);
let keys = Keys {
forward: held(KeyCode::KeyW),
@@ -1907,12 +2311,18 @@ impl Game<'_> {
self.send(S::ClientTickEnd(Empty));
Ok(())
}
fn tick_remote_entities(&mut self) {
/// ClientLevel.tickEntities; a frozen level only ticks the players
/// (`TickRateManager.isEntityFrozen`).
fn tick_remote_entities(&mut self, running: bool) {
let Some(r) = self.remote.as_mut() else {
return;
};
let mut lerps = std::mem::take(&mut r.lerps);
let players = &r.level_players;
for m in &mut self.mobs {
if !running && !players.contains_key(&(m.id as i32)) {
continue;
}
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
@@ -1950,7 +2360,7 @@ impl Game<'_> {
horizon_client::mobs::rabbit::post_tick(m);
}
}
for d in &mut self.dropped {
for d in self.dropped.iter_mut().filter(|_| running) {
d.previous = d.position;
d.age += 1;
if let Some(l) = lerps.get_mut(&(d.id as i32))
@@ -2090,8 +2500,7 @@ impl Game<'_> {
if r.destroy_delay > 0 {
r.destroy_delay -= 1;
}
let chatting = r.chat_input.is_some();
if !self.left || self.screen != Screen::Playing || chatting {
if !self.left || self.screen != Screen::Playing {
self.stop_remote_mining();
self.mining = None;
return;
@@ -2108,11 +2517,12 @@ impl Game<'_> {
if self.remote.as_ref().is_some_and(|r| r.destroy_delay > 0) {
return;
}
self.remote_ensure_carried();
self.swing_arm();
self.send(S::Punch(Empty));
if self.creative {
let seq = self.next_sequence();
self.predict_break(hit.pos, state);
self.predict_break(hit.pos, state, seq);
self.send(S::PlayerAction(play::PlayerAction {
action: 0,
pos: hit.pos,
@@ -2161,7 +2571,7 @@ impl Game<'_> {
sequence: seq,
}));
if progress >= 1.0 {
self.predict_break(hit.pos, state);
self.predict_break(hit.pos, state, seq);
if let Some(r) = self.remote.as_mut() {
r.destroy_delay = 5;
}
@@ -2198,7 +2608,7 @@ impl Game<'_> {
self.crack_particle(pos, hit.face, state);
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state);
self.predict_break(pos, state, seq);
self.send(S::PlayerAction(play::PlayerAction {
action: 3,
pos,
@@ -2230,7 +2640,14 @@ impl Game<'_> {
}
}
/// MultiPlayerGameMode.destroyBlock: the predicted break.
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId) {
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
// BlockStatePredictionHandler.retainKnownServerState.
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|p| p.0 = sequence)
.or_insert((sequence, state.0 as i32));
}
let s = self.res.facts.get(state).sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
@@ -2252,7 +2669,22 @@ impl Game<'_> {
self.mining = None;
}
/// Minecraft.startUseItem: use on the targeted block, then the item itself.
/// MultiPlayerGameMode.ensureHasSentCarriedItem: the server hears of a
/// hotbar change before an action that uses the held item.
fn remote_ensure_carried(&mut self) {
let selected = self.selected;
let Some(r) = self.remote.as_mut() else {
return;
};
if r.carried != Some(selected) {
r.carried = Some(selected);
self.send(S::SetCarriedItem(play::SetCarriedItem {
slot: selected as i16,
}));
}
}
fn remote_use(&mut self) {
self.remote_ensure_carried();
let held_block = self
.held()
.and_then(|h| self.res.items.get(&h.item))
@@ -2313,13 +2745,14 @@ impl Game<'_> {
}
/// Left click: attack the targeted entity (MultiPlayerGameMode.attack).
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
if self.remote.as_ref().is_some_and(|r| r.chat_input.is_some()) {
if self.screen == Screen::Chat {
return Ok(());
}
match button {
MouseButton::Left => {
if pressed && let Some((i, _)) = self.mob_hit() {
let id = self.mobs[i].id as i32;
self.remote_ensure_carried();
self.send(S::Attack(play::Attack { id }));
self.send(S::Punch(Empty));
self.swing_arm();
@@ -2372,14 +2805,8 @@ impl Game<'_> {
}
match key {
KeyCode::KeyT | KeyCode::Slash | KeyCode::Enter => {
let text = if key == KeyCode::Slash { "/" } else { "" };
if let Some(r) = self.remote.as_mut() {
r.chat_input = Some(text.to_owned());
}
self.keys.clear();
self.left = false;
self.right = false;
self.capture(false);
// ChatScreen, over the server's command tree.
self.open_chat(if key == KeyCode::Slash { "/" } else { "" });
Ok(true)
}
KeyCode::KeyQ => {
@@ -2416,84 +2843,6 @@ impl Game<'_> {
_ => Ok(false),
}
}
pub(super) fn remote_chat_open(&self) -> bool {
self.remote.as_ref().is_some_and(|r| r.chat_input.is_some())
}
/// ChatScreen: typing, Enter to send, Escape to cancel.
pub(super) fn remote_chat_key(&mut self, event: &winit::event::KeyEvent) {
use winit::keyboard::Key;
if event.state != winit::event::ElementState::Pressed {
return;
}
let shortcut = self.edit_shortcut(event);
let Some(r) = self.remote.as_mut() else {
return;
};
let Some(input) = r.chat_input.as_mut() else {
return;
};
// ChatScreen's input: an EditBox of 256.
const INPUT: horizon_client::clipboard::FieldId = ("remote.chat", 0);
if let Some(shortcut) = shortcut {
Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
INPUT,
input,
shortcut,
256,
);
return;
}
// Logical keys: the text, not the scancode, decides (layouts, virtual keyboards).
let named = match &event.logical_key {
Key::Named(n) => Some(*n),
_ => None,
};
self.remote_chat_input(named, event.text.as_deref());
}
/// A named key or typed text in the chat input (the keyboard, or a
/// controller's on-screen keyboard).
pub(super) fn remote_chat_input(
&mut self,
named: Option<winit::keyboard::NamedKey>,
text: Option<&str>,
) {
use winit::keyboard::NamedKey;
const INPUT: horizon_client::clipboard::FieldId = ("remote.chat", 0);
let Some(r) = self.remote.as_mut() else {
return;
};
let Some(input) = r.chat_input.as_mut() else {
return;
};
match named {
Some(NamedKey::Escape) => {
r.chat_input = None;
self.capture(true);
}
Some(NamedKey::Backspace) => {
if !self.text_selection.take(INPUT, input) {
input.pop();
}
}
Some(NamedKey::Enter) => {
let message = r.chat_input.take().unwrap_or_default();
self.send_chat(&message);
self.capture(true);
}
_ => {
if let Some(text) = text {
self.text_selection.take(INPUT, input);
for c in text.chars().filter(|c| !c.is_control()) {
if input.chars().count() < 256 {
input.push(c);
}
}
}
}
}
}
/// ClientPacketListener.sendChat / sendCommand (unsigned).
pub(super) fn send_chat(&mut self, message: &str) {
let message = message.trim();
@@ -2521,6 +2870,17 @@ impl Game<'_> {
}));
}
}
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
/// sends both slots back; nothing is predicted.
pub(super) fn remote_swap_hands(&mut self) {
self.send(S::PlayerAction(play::PlayerAction {
action: 7,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
}
/// A container click: the server answers with the authoritative slots.
pub(super) fn remote_click(&mut self, right: bool) {
let Some(size) = self
@@ -2825,11 +3185,11 @@ impl Game<'_> {
let Some(r) = &self.remote else { return };
let atlas = &self.atlas;
let (w, h) = (batch.width, batch.height);
let open = r.chat_input.is_some();
let open = self.screen == Screen::Chat;
let now = Instant::now();
let mut y = h - 48.0;
// Container screens cover the chat (vanilla draws them over the HUD).
let shown = if matches!(self.screen, Screen::Playing | Screen::Dead) {
let shown = if matches!(self.screen, Screen::Playing | Screen::Dead | Screen::Chat) {
if open { 20 } else { 10 }
} else {
0
@@ -2857,16 +3217,6 @@ impl Game<'_> {
batch.text(atlas, text, 4.0, y, rgba(255, 255, 255, a), true);
y -= 9.0;
}
if let Some(input) = &r.chat_input {
batch.fill(atlas, 2.0, h - 14.0, w - 4.0, 12.0, rgba(0, 0, 0, 128));
let blink = (self.started.elapsed().as_millis() / 300).is_multiple_of(2);
let shown = if blink {
format!("{input}_")
} else {
input.clone()
};
batch.text(atlas, &shown, 4.0, h - 12.0, rgba(224, 224, 224, 255), true);
}
if let Some((text, at)) = &r.action_bar
&& now.duration_since(*at) < Duration::from_secs(3)
{
@@ -2941,3 +3291,20 @@ impl Game<'_> {
}))
}
}
#[cfg(test)]
mod tests {
use super::Game;
#[test]
fn every_inventory_menu_slot_is_a_game_slot_and_45_is_the_off_hand() {
// InventoryMenu: result, 2x2 grid, armour, 27 + 9 inventory slots, then the off hand.
assert_eq!(Game::menu_slot("inventory", 45), Some(504));
let mut seen = std::collections::HashSet::new();
for slot in 0..46 {
let internal = Game::menu_slot("inventory", slot);
assert!(internal.is_some_and(|i| seen.insert(i)), "slot {slot}");
}
assert_eq!(Game::menu_slot("inventory", 46), None);
}
}
+23 -14
View File
@@ -303,20 +303,33 @@ impl Game<'_> {
// ---- criteria ----------------------------------------------------------
/// `ServerStatsCounter.setValue`'s scoreboard side: the objectives of
/// each changed statistic's criteria follow it for the player `name`.
pub(super) fn stat_criteria_changed(
&mut self,
name: &str,
events: Vec<(String, String, Option<i32>)>,
) {
let board = &mut self.score.board;
if board.objectives.is_empty() {
return;
}
for (ty, key, amount) in events {
let criteria = sb::stat_criteria(&ty, &key);
if board.has_objectives_for(&criteria) {
board.for_all_objectives(&criteria, name, |v| {
amount.map_or(0, |n| v.wrapping_add(n))
});
}
}
}
/// `ServerPlayer.doTick` score updates and the statistic criteria.
pub(super) fn tick_scoreboard(&mut self) {
self.load_scoreboard();
let events = std::mem::take(&mut self.stats.events);
self.stat_criteria_changed(PLAYER_NAME, events);
let board = &mut self.score.board;
if !board.objectives.is_empty() {
for (ty, key, amount) in events {
let criteria = sb::stat_criteria(&ty, &key);
if board.has_objectives_for(&criteria) {
board.for_all_objectives(&criteria, PLAYER_NAME, |v| {
amount.map_or(0, |n| v.wrapping_add(n))
});
}
}
let display = self.player_display_comp();
if let Some(p) = self.player.as_mut() {
let health_and_absorption = p.health.health() + p.health.absorption();
@@ -587,9 +600,7 @@ impl Game<'_> {
/// objective.
pub(super) fn draw_tab_list(&mut self, batch: &mut HudBatch) {
use horizon_client::tab_list::{self, TabInputs};
let chatting = self.remote.is_some() && self.remote_chat_open();
let key = self.screen == super::Screen::Playing
&& !chatting
&& self.keys.contains(&winit::keyboard::KeyCode::Tab);
let (players, online_mode, upside_down) = match self.remote_tab_players() {
Some(remote) => remote,
@@ -735,12 +746,10 @@ mod tab_list_tests {
conn: uuid as u32,
uuid,
name: name.into(),
properties: Vec::new(),
info: ClientInformation::new(8),
brand: "test".into(),
sender: PlayerSender {
tx,
pending: Arc::default(),
},
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(42)),
address: "127.0.0.1:25565".parse().unwrap(),
},
+59 -15
View File
@@ -8,7 +8,8 @@ use horizon_client::{
};
/// PauseScreen buttons: (x, y, width, label key, action id, active).
fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
let (grid_w, grid_h) = (212.0, 166.0);
let x0 = ((w - grid_w) * 0.5).floor();
@@ -26,9 +27,21 @@ fn pause_buttons(w: f32, h: f32) -> Vec<(f32, f32, f32, &'static str, u8, bool)>
9,
true,
),
(x0 + 4.0, y0 + 146.0, 204.0, "menu.returnToMenu", 2, true),
(
x0 + 4.0,
y0 + 146.0,
204.0,
if remote {
"menu.disconnect"
} else {
"menu.returnToMenu"
},
2,
true,
),
]
}
/// The icon row: report bugs, feedback, friends, player reporting.
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
@@ -52,6 +65,10 @@ fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] {
]
}
impl Game<'_> {
/// Singleplayer also has a connection, backed by the integrated server.
pub(super) fn external_server(&self) -> bool {
self.remote.is_some() && self.integrated.is_none()
}
/// Whether the pause menu is drawn in Horizon's style (over a blurred world).
pub(super) fn horizon_pause(&self) -> bool {
self.screen == Screen::Paused
@@ -60,17 +77,14 @@ impl Game<'_> {
&& horizon_client::ui_theme::horizon()
}
fn pause_labels(&self) -> (String, &'static str) {
let world = if self.remote.is_some() {
"Multiplayer".to_owned()
if let Some(server) = &self.integrated {
return (server.world_name.clone(), "Save and quit");
}
if self.external_server() {
("Multiplayer".to_owned(), "Disconnect")
} else {
self.level.name.clone()
};
let quit = if self.remote.is_some() {
"Disconnect"
} else {
"Save and quit"
};
(world, quit)
(self.level.name.clone(), "Save and quit")
}
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
if horizon_client::ui_theme::horizon() {
@@ -79,7 +93,14 @@ impl Game<'_> {
self.tick_friends();
let friends = self.friends_view();
let mut ui = horizon_client::ui_theme::Ui::new(&self.atlas, batch, self.cursor);
horizon_client::front::pause::draw(&mut ui, &world, quit, opened, &friends);
horizon_client::front::pause::draw(
&mut ui,
&world,
quit,
opened,
&friends,
self.external_server(),
);
ui.finish();
return;
}
@@ -96,7 +117,7 @@ impl Game<'_> {
u32::MAX,
true,
);
for (x, y, bw, key, _, active) in pause_buttons(w, h) {
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(
batch,
@@ -142,9 +163,10 @@ impl Game<'_> {
&world,
quit,
&self.friends_view(),
self.external_server(),
)
} else {
pause_buttons(w, h)
pause_buttons(w, h, self.external_server())
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
@@ -263,6 +285,12 @@ impl Game<'_> {
.into_iter()
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
.map(|d| d.3);
if hit == Some(0) && self.remote.is_none() {
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
self.experience.respawn(keep);
// Autosaves keep a login position; death must resolve the respawn point instead.
self.level.position = None;
}
match hit {
Some(0) if self.remote.is_some() => {
self.ui.death_text = None;
@@ -298,3 +326,19 @@ impl Game<'_> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::pause_buttons;
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
}
}
}
+20
View File
@@ -80,6 +80,15 @@ pub(super) struct SculkState {
ticks: u32,
pub tracker_loaded: bool,
}
impl SculkState {
/// Exchange the level-held parts (its sculk block entities and the
/// chunks scanned for them) with another level's (game/levels.rs).
pub(super) fn swap_level(&mut self, other: &mut Self) {
std::mem::swap(&mut self.index, &mut other.index);
std::mem::swap(&mut self.known, &mut other.known);
}
}
/// An insertion-ordered set of block positions (`Level.blockEntityTickers`
/// keeps the order block entities were added in).
#[derive(Default)]
@@ -373,6 +382,14 @@ impl Game<'_> {
let mut found = Vec::new();
for (s, section) in chunk.sections.iter().enumerate() {
let y0 = MIN_Y + s as i32 * 16;
// Without a sculk state in its palette, no block of the section
// is one: most sections are passed over without reading a block.
if !section
.blocks
.maybe_has(|v| u16::try_from(v).is_ok_and(|v| self.kind_of_state(v) != 0))
{
continue;
}
if section.blocks.uniform().is_some() {
// A uniform section of sculk blocks does not exist.
continue;
@@ -1163,6 +1180,9 @@ impl Game<'_> {
/// `Warden.applyDarknessAround` / `MobEffectUtil.addEffectToPlayersAround`.
pub(super) fn apply_darkness(&mut self, at: [f64; 3], radius: f64) {
self.for_each_player(|g| g.darkness_on_player(at, radius));
}
fn darkness_on_player(&mut self, at: [f64; 3], radius: f64) {
if self.creative || self.spectator() {
return;
}
@@ -0,0 +1,271 @@
//! A server's players between dimensions.
//!
//! A server holds a level per dimension (game/levels.rs). A player who steps
//! into a Nether or End portal moves to the destination's level, which opens
//! if nobody was there: the swap is the in-frame path's portal plans and
//! arrival search, and the player hears it as `ServerPlayer.teleport` into
//! another level: a Respawn packet keeping all data, then a teleport to the
//! exit once the destination has loaded around them.
use super::nether_portal::{PLAYER_DELAY, Plan};
use super::{Dim, Game, lan::LanHost};
use horizon_client::lan::hub::TickCtx;
use horizon_core::{geometry::Vec3, position::BlockPos};
/// A trip under way: the player is in the destination's level, whose
/// chunks are loading around `target`.
pub(super) struct Trip {
conn: u32,
dim: Dim,
target: Vec3,
/// Through an End portal.
end: bool,
/// The Nether portal plan, whose exit is found or built on arrival.
plan: Option<Plan>,
}
/// A player's box: Player's standing dimensions.
const WIDTH: f64 = 0.6;
const HEIGHT: f64 = 1.8;
impl Trip {
pub(super) fn is(&self, conn: u32) -> bool {
self.conn == conn
}
}
impl Game<'_> {
/// Whether `conn` is between two levels: the server leaves its body
/// alone until it arrives.
pub(super) fn on_server_trip(&self, conn: u32) -> bool {
self.server_trips.iter().any(|t| t.conn == conn)
}
/// Per server tick in each level, after the guests' own: the arrivals of
/// the trips into this level, then `Entity.handlePortal` for each player
/// in it (Nether portals count up their transition time, End portals
/// take them at once).
pub(super) fn tick_server_travel(&mut self, host: &mut LanHost) {
if self.options.dedicated.is_none() {
return;
}
self.arrive_server_trips(host);
let conns: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| g.conn)
.collect();
for conn in conns {
if self.on_server_trip(conn) {
continue;
}
// `Entity.portalProcess` and `portalCooldown` are the player's own.
let mut portal = host.play.entry(conn).or_default().portal;
std::mem::swap(&mut self.portals.player, &mut portal);
self.handle_server_portal(host, conn);
std::mem::swap(&mut self.portals.player, &mut portal);
host.play.entry(conn).or_default().portal = portal;
}
}
/// `Entity.handlePortal` for one player, whose portal state is in place.
fn handle_server_portal(&mut self, host: &mut LanHost, conn: u32) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let (at, invulnerable, sleeping) = (
Vec3::new(g.position[0], g.position[1], g.position[2]),
g.creative(),
g.sleeping,
);
// Entity.checkInsideBlocks: the Nether portal blocks the box is in.
if let Some(pos) = self.nether_portal_inside(at) {
self.portals.player.set_inside(pos, PLAYER_DELAY);
}
let (end_portal, _) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
} else {
Dim::End
};
self.start_server_trip(host, conn, target, None);
}
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
self.portals.player.cooldown = 10;
return;
}
let rule = if invulnerable {
"players_nether_portal_creative_delay"
} else {
"players_nether_portal_default_delay"
};
let transition = self.ui.rules.int(rule).max(0);
let Some(entry) = self
.portals
.player
.handle(!sleeping, transition, PLAYER_DELAY)
else {
return;
};
let plan = self.portal_plan(entry, at, WIDTH as f32, HEIGHT as f32, true);
if !self.may_enter(plan.dim) {
return;
}
self.start_server_trip(host, conn, plan.dim, Some(plan));
}
/// A Nether portal block a player's box is in.
fn nether_portal_inside(&self, at: Vec3) -> Option<BlockPos> {
let w = WIDTH / 2.0 - 1.0e-5;
let lo = [at.x - w, at.y + 1.0e-5, at.z - w];
let hi = [at.x + w, at.y + HEIGHT - 1.0e-5, at.z + w];
for x in lo[0].floor() as i32..=hi[0].floor() as i32 {
for y in lo[1].floor() as i32..=hi[1].floor() as i32 {
for z in lo[2].floor() as i32..=hi[2].floor() as i32 {
if &*self.block_name([x, y, z]) == "minecraft:nether_portal" {
return Some(BlockPos::new(x, y, z));
}
}
}
}
None
}
/// The player moves to `target`'s level (opened if nobody was there)
/// and is told it is there, at the approximate exit, while it loads. The
/// level whose tick is running is put back in place afterwards.
fn start_server_trip(
&mut self,
host: &mut LanHost,
conn: u32,
target: Dim,
plan: Option<Plan>,
) {
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let g = &host.hub.guests[i];
let from = Vec3::new(g.position[0], g.position[1], g.position[2]);
let yaw = g.yaw;
let source = self.dim;
let end = plan.is_none();
// Coordinates scale by 8 between the Overworld and the Nether; an
// End portal has fixed destinations.
let scale = match (source, target) {
(Dim::Overworld, Dim::Nether) | (Dim::End, Dim::Nether) => 1.0 / 8.0,
(Dim::Nether, Dim::Overworld) => 8.0,
_ => 1.0,
};
self.end_portal_travel = end;
let scaled = self.end_destination(target).unwrap_or(Vec3::new(
from.x * scale,
from.y,
from.z * scale,
));
self.end_portal_travel = false;
if let Err(e) = self.enter_level(Some(host), target) {
log::error!("could not open {}: {e}", target.id());
return;
}
let destination = match plan {
Some(plan) => self.arrival_centre(&plan),
None => scaled,
};
let at = [destination.x, destination.y, destination.z];
let ctx = self.server_ctx();
host.hub.change_level(i, at, yaw, &ctx);
self.as_guest(host, conn, |g| g.trigger_dimension(source, target));
self.server_trips.push(Trip {
conn,
dim: target,
target: destination,
end,
plan,
});
if let Err(e) = self.enter_level(Some(host), source) {
log::error!("could not reopen {}: {e}", source.id());
}
}
/// The trips into the level in place whose destination has loaded
/// around their target: the exit (the Nether portal found or built, the
/// End platform, the Overworld's spawn column) and the teleport there.
fn arrive_server_trips(&mut self, host: &mut LanHost) {
let mut k = 0;
while k < self.server_trips.len() {
let trip = &self.server_trips[k];
let Some(i) = host.hub.guests.iter().position(|g| g.conn == trip.conn) else {
// They left on the way.
self.server_trips.remove(k);
continue;
};
let (cx, cz) = (
(trip.target.x.floor() as i32) >> 4,
(trip.target.z.floor() as i32) >> 4,
);
if trip.dim != self.dim
|| !(-2..=2).all(|dz| (-2..=2).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
{
k += 1;
continue;
}
let trip = self.server_trips.remove(k);
self.resolve_arrivals();
let yaw = host.hub.guests[i].yaw;
let (position, yaw) = if trip.end {
match self.end_arrival(trip.target) {
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
Err(e) => {
log::error!("End arrival: {e}");
(trip.target, yaw)
}
}
} else {
let exit = trip
.plan
.as_ref()
.and_then(|plan| self.nether_exit_portal(plan));
match (trip.plan, exit) {
(Some(plan), Some(exit)) => {
let (at, rotation) = self.arrival(&plan, exit);
(at, yaw + rotation)
}
_ => (trip.target, yaw),
}
};
// Player.getDimensionChangingDelay.
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
let g = &mut host.hub.guests[i];
g.yaw = yaw;
// A new body at the exit: no fall distance or motion carried over.
g.motion = None;
host.hub
.teleport(i, Some([position.x, position.y, position.z]));
}
}
/// The hub's view of the server for packets sent outside its own tick.
pub(super) fn server_ctx(&mut self) -> TickCtx<'_> {
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
TickCtx {
world: &mut self.world,
host: None,
entities: &[],
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate: if self.ui.rules.bool("advance_time") {
self.ui.clock.rate
} else {
0.0
},
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
}
}
}
+77 -6
View File
@@ -6,6 +6,7 @@
use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shulker_box};
use horizon_client::{
containers::{Banner, BlockEntity},
item_data,
items::ItemStack,
};
use horizon_core::position::BlockPos;
@@ -15,6 +16,7 @@ use horizon_world::{
LevelEvent,
removal::{self, PreRemove},
},
nbt::{JavaString, Tag},
registry::BlockStateId,
};
use std::collections::HashMap;
@@ -80,14 +82,37 @@ impl Pending {
stack.data_mut().pot_decorations = Some(Box::new(sides));
}
if stack.item.ends_with("_banner")
&& let Some(Banner { layers, name }) = self.banner.remove(&pos)
&& let Some(Banner {
layers,
name,
components,
}) = self.banner.remove(&pos)
{
let data = stack.data_mut();
data.banner_patterns = layers;
if name.is_some() {
data.custom_name = name;
let mut patch = match item_data::encode_components(stack) {
Some(Tag::Compound(c)) => c,
_ => Default::default(),
};
// The remaining components in the banner loot table's copy_components.
for key in [
"minecraft:item_name",
"minecraft:rarity",
"minecraft:tooltip_display",
] {
if let Some(value) = components.get(&JavaString::from(key)) {
patch.insert(key.into(), value.clone());
}
}
stack.tidy();
patch.insert(
"minecraft:banner_patterns".into(),
horizon_client::banner::layers_to_nbt(&layers),
);
if let Some(name) = name {
patch.insert(
"minecraft:custom_name".into(),
Tag::String(name.as_str().into()),
);
}
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
}
}
/// Remember a decorated pot's sherds from its saved block entity.
@@ -553,3 +578,49 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::{block_entity_item, item_data, mobs::raider, save};
use horizon_world::nbt::Tag;
#[test]
fn ominous_banner_survives_placement_save_and_drop() {
let original = raider::ominous_banner();
let Tag::Compound(components) = item_data::encode_components(&original).unwrap() else {
panic!("banner components");
};
for block in ["minecraft:white_banner", "minecraft:white_wall_banner"] {
let pos = (1, 64, 2);
let fresh = BlockEntity::for_block(block).unwrap();
let fresh = save::encode_block_entity(pos, &fresh, block);
let placed = block_entity_item::place(&fresh, &components, false);
let (_, placed) = save::decode_block_entity(&placed).unwrap();
let saved = save::encode_block_entity(pos, &placed, block);
let (_, loaded) = save::decode_block_entity(&saved).unwrap();
let BlockEntity::Banner(banner) = &loaded else {
panic!("placed banner");
};
assert_eq!(banner.layers.len(), item_data::OMINOUS_LAYERS.len());
let mut pending = Pending::default();
pending.record([pos.0, pos.1, pos.2], &loaded, block, None);
let mut dropped = ItemStack::new("minecraft:white_banner", 1);
pending.attach([pos.0, pos.1, pos.2], &mut dropped);
assert!(dropped.data().unwrap().ominous_banner);
assert!(horizon_client::banner::tooltip_layers(&dropped).is_empty());
assert_eq!(
horizon_client::item_tooltip::name_line(&dropped, &|_| None),
"Ominous Banner"
);
assert_eq!(
horizon_client::item_tooltip::rarity(&dropped, "common"),
"uncommon"
);
assert_eq!(
item_data::encode_components(&dropped),
Some(Tag::Compound(components.clone()))
);
}
}
}
+33 -13
View File
@@ -1860,18 +1860,10 @@ impl Game<'_> {
pub(super) fn stack_tooltip(&self, stack: &ItemStack) -> Vec<(String, u32)> {
let e: &Enchantments = &self.res.stations.enchantments;
let props = e.props(&stack.item);
let mut rarity = props.map_or("common", |p| p.rarity.as_str());
// The ominous banner's rarity component.
if stack.data().is_some_and(|d| d.ominous_banner) {
rarity = "uncommon";
}
if !stack.enchantments().is_empty() {
rarity = match rarity {
"common" | "uncommon" => "rare",
"rare" => "epic",
r => r,
};
}
let rarity = horizon_client::item_tooltip::rarity(
stack,
props.map_or("common", |p| p.rarity.as_str()),
);
let color = match rarity {
"uncommon" => rgba(255, 255, 85, 255),
"rare" => rgba(85, 255, 255, 255),
@@ -1952,7 +1944,7 @@ impl Game<'_> {
));
}
// BannerPatternLayers.addToTooltip: up to six layer names.
for layer in horizon_client::banner::layers(stack).iter().take(6) {
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
let key = self.res.stations.banners.layer_key(layer);
lines.push((self.translate(&key, &key), rgba(170, 170, 170, 255)));
}
@@ -1977,6 +1969,34 @@ impl Game<'_> {
}
}
}
if let Some(info) = self.res.items.get(&stack.item) {
let base = |name: &str, default| {
self.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key(name)?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.ok()
.map(|value| value.base_value())
})
.unwrap_or(default)
};
let player_base = [
base("minecraft:attack_damage", 1.0),
base(
"minecraft:attack_speed",
horizon_client::attack_strength::BASE_ATTACK_SPEED,
),
];
lines.extend(horizon_client::item_tooltip::attribute_lines(
&info.modifiers,
Some(player_base),
&translate,
));
}
if let Some(info) = self.res.items.get(&stack.item)
&& info.max_damage > 0
&& stack.damage > 0
+98 -46
View File
@@ -3,8 +3,12 @@
//! killed by, saved to `players/stats/<uuid>.json`, plus the phantom
//! spawner that reads the time since rest (`PhantomSpawner`).
use super::{Dim, Game};
use horizon_core::geometry::Vec3;
use horizon_core::random::RandomSource;
use std::collections::BTreeMap;
use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
/// `stats[type][key]`, with `type` and `key` namespaced ids.
#[derive(Default)]
@@ -16,6 +20,8 @@ pub(super) struct Stats {
/// Statistic changes (type, key, amount; `None` = reset) for the
/// scoreboard criteria to follow.
pub(super) events: Vec<(String, String, Option<i32>)>,
/// Changed since the client was last sent them (`StatsCounter`'s dirty set).
pub(super) dirty: BTreeSet<(String, String)>,
}
impl Stats {
pub(super) fn add(&mut self, ty: &str, key: &str, n: i32) {
@@ -28,6 +34,8 @@ impl Stats {
*v = v.saturating_add(n);
self.events
.push((format!("minecraft:{ty}"), key.to_owned(), Some(n)));
self.dirty
.insert((format!("minecraft:{ty}"), key.to_owned()));
}
pub(super) fn get(&self, ty: &str, key: &str) -> i32 {
self.values
@@ -40,52 +48,51 @@ impl Stats {
if let Some(m) = self.values.get_mut(&format!("minecraft:{ty}")) {
m.insert(key.to_owned(), 0);
}
self.dirty
.insert((format!("minecraft:{ty}"), key.to_owned()));
self.events
.push((format!("minecraft:{ty}"), key.to_owned(), None));
}
pub(super) fn custom(&mut self, key: &str, n: i32) {
self.add("custom", &format!("minecraft:{key}"), n);
}
}
impl Game<'_> {
fn stats_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/stats")
.join(format!("{text}.json")),
)
/// `ServerStatsCounter.markAllDirty`: everything goes out on the next request.
pub(super) fn mark_all_dirty(&mut self) {
self.dirty = self
.values
.iter()
.flat_map(|(ty, m)| m.keys().map(|k| (ty.clone(), k.clone())))
.collect();
}
/// `ServerStatsCounter.parseLocal`.
pub(super) fn load_stats(&mut self) {
if self.stats.loaded || self.remote.is_some() {
return;
}
self.stats.loaded = true;
let Some(path) = self.stats_path() else {
return;
/// `ServerStatsCounter.sendStats`' entries: the dirty statistics and
/// their values, the set cleared.
pub(super) fn take_dirty(&mut self) -> Vec<(String, String, i32)> {
std::mem::take(&mut self.dirty)
.into_iter()
.map(|(ty, key)| {
let v = self.values.get(&ty).and_then(|m| m.get(&key)).copied();
(ty, key, v.unwrap_or(0))
})
.collect()
}
/// `ServerStatsCounter.parseLocal`: the counters of a stats file.
pub(super) fn read(path: &Path) -> Self {
let mut stats = Self {
loaded: true,
..Self::default()
};
let Ok(text) = std::fs::read_to_string(path) else {
return;
let Some(json) = std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
else {
return stats;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return;
};
if let Some(stats) = json.get("stats").and_then(|s| s.as_object()) {
for (ty, entries) in stats {
if let Some(all) = json.get("stats").and_then(|s| s.as_object()) {
for (ty, entries) in all {
let Some(entries) = entries.as_object() else {
continue;
};
let map = self.stats.values.entry(ty.clone()).or_default();
let map = stats.values.entry(ty.clone()).or_default();
for (key, v) in entries {
if let Some(n) = v.as_i64() {
map.insert(key.clone(), n as i32);
@@ -93,17 +100,11 @@ impl Game<'_> {
}
}
}
stats
}
/// `ServerStatsCounter.save`.
pub(super) fn save_stats(&self) {
if !self.stats.loaded || self.remote.is_some() {
return;
}
let Some(path) = self.stats_path() else {
return;
};
pub(super) fn write(&self, path: &Path) {
let stats: serde_json::Map<String, serde_json::Value> = self
.stats
.values
.iter()
.filter(|(_, m)| !m.is_empty())
@@ -124,9 +125,55 @@ impl Game<'_> {
let _ = std::fs::write(path, text);
}
}
}
/// `players/<dir>/<uuid>.json` of a world (advancements, stats).
pub(super) fn player_json(world: &Path, dir: &str, uuid: [i32; 4]) -> PathBuf {
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]
);
world.join("players").join(dir).join(format!("{text}.json"))
}
impl Game<'_> {
fn stats_path(&self) -> Option<PathBuf> {
Some(player_json(
&self.world_root,
"stats",
self.level.player_uuid?,
))
}
/// `ServerStatsCounter.parseLocal`.
pub(super) fn load_stats(&mut self) {
if self.stats.loaded || self.remote.is_some() || self.no_player_here() {
return;
}
self.stats.loaded = true;
if let Some(path) = self.stats_path() {
self.stats.values = Stats::read(&path).values;
}
}
/// `ServerStatsCounter.save`.
pub(super) fn save_stats(&self) {
if !self.stats.loaded || self.remote.is_some() || self.no_player_here() {
return;
}
if let Some(path) = self.stats_path() {
self.stats.write(&path);
}
}
/// The per-tick counters (`Player.tick`/`ServerPlayer.tick` awardStat).
pub(super) fn tick_stats(&mut self) {
self.load_stats();
self.count_tick_stats(self.sleeping.is_some());
self.tick_movement_stats();
}
/// `Player.tick`'s counters for the player in `self.player`.
pub(super) fn count_tick_stats(&mut self, sleeping: bool) {
let Some(p) = self.player.as_ref() else {
return;
};
@@ -137,21 +184,26 @@ impl Game<'_> {
s.custom("total_world_time", 1);
if alive {
s.custom("time_since_death", 1);
if self.sleeping.is_none() {
if !sleeping {
s.custom("time_since_rest", 1);
}
}
if crouching {
s.custom("sneak_time", 1);
}
self.tick_movement_stats();
}
/// `Player.checkMovementStatistics` / `checkRidingStatistics`.
fn tick_movement_stats(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let d = p.position.subtract(self.previous_position);
self.movement_stats(d);
}
/// `Player.checkMovementStatistics` / `checkRidingStatistics` for one move.
pub(super) fn movement_stats(&mut self, d: Vec3) {
let Some(p) = self.player.as_ref() else {
return;
};
let horizontal = ((d.x * d.x + d.z * d.z).sqrt() * 100.0).round() as i32;
let total = ((d.x * d.x + d.y * d.y + d.z * d.z).sqrt() * 100.0).round() as i32;
// Teleports are not travel.
@@ -173,6 +173,12 @@ const FOOTER: f32 = 33.0;
impl Game<'_> {
pub(super) fn open_stats(&mut self) {
self.load_stats();
// StatsScreen.init: a server's client asks for its statistics.
if self.remote.is_some() {
self.send(horizon_protocol::packets::play::Serverbound::ClientCommand(
horizon_protocol::packets::play::ClientCommand { action: 1 },
));
}
self.stats_ui = StatsUi::default();
self.screen = Screen::Stats;
}
+7 -14
View File
@@ -6,6 +6,7 @@ use horizon_client::{
hud::HudBatch,
hud_layout,
hud_overlays::HeldName,
item_tooltip,
text::{self, Text},
};
@@ -24,22 +25,14 @@ impl Game<'_> {
fn held_name(&self) -> Option<HeldName> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
let custom = stack.custom_name();
// ItemStack.getRarity: enchanting raises common and uncommon to rare
// and rare to epic.
let mut rarity = self.item_rarity(&stack.item);
if !stack.enchantments().is_empty() {
rarity = match rarity.as_str() {
"common" | "uncommon" => "rare".into(),
"rare" => "epic".into(),
_ => rarity,
};
}
let default_rarity = self.item_rarity(&stack.item);
let rarity = item_tooltip::rarity(stack, &default_rarity);
Some(HeldName {
item: stack.item.to_string(),
hover_name: Text::literal(
custom.map_or_else(|| self.pack.names.item(&stack.item), str::to_owned),
),
rarity_color: match rarity.as_str() {
hover_name: Text::literal(item_tooltip::name_line(stack, &|key| {
self.pack.names.get(key).map(str::to_owned)
})),
rarity_color: match rarity {
"uncommon" => text::YELLOW,
"rare" => text::AQUA,
"epic" => text::LIGHT_PURPLE,
+8 -2
View File
@@ -28,7 +28,7 @@ use horizon_client::{
menu::Input,
stations::hover_name,
};
use winit::keyboard::KeyCode;
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
@@ -377,8 +377,14 @@ impl Game<'_> {
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(super) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || keybinds::key_code(&e.sort_key) != Some(key) || self.recipe_search_focused() {
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
+25
View File
@@ -143,6 +143,15 @@ impl GameRules {
pub fn bool(&self, name: &str) -> bool {
matches!(self.get(name), Some(Value::Bool(true)))
}
/// Parse a network rule value, retaining type and range validation.
pub fn set_string(&mut self, name: &str, text: &str) -> bool {
let value = match self.get(name) {
Some(Value::Bool(_)) => text.parse::<bool>().ok().map(Value::Bool),
Some(Value::Int(_)) => text.parse::<i32>().ok().map(Value::Int),
None => None,
};
value.is_some_and(|v| self.set(name, v))
}
pub fn int(&self, name: &str) -> i32 {
match self.get(name) {
Some(Value::Int(v)) => v,
@@ -250,6 +259,22 @@ impl GameRules {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn network_values_validate_rule_names_types_and_bounds() {
let mut rules = GameRules::default();
assert!(rules.set_string("minecraft:keep_inventory", "true"));
assert!(rules.bool("keep_inventory"));
assert!(!rules.set_string("minecraft:keep_inventory", "1"));
assert!(rules.bool("keep_inventory"));
assert!(rules.set_string("minecraft:random_tick_speed", "7"));
assert_eq!(rules.int("random_tick_speed"), 7);
assert!(!rules.set_string("minecraft:random_tick_speed", "-1"));
assert!(!rules.set_string("minecraft:random_tick_speed", "999999999999"));
assert!(!rules.set_string("minecraft:unknown", "true"));
assert!(!rules.set_string("other:keep_inventory", "false"));
assert_eq!(rules.int("random_tick_speed"), 7);
}
#[test]
fn defaults_bounds_and_round_trip() {
let mut rules = GameRules::default();
+401 -156
View File
@@ -64,6 +64,7 @@ mod pack_stages;
pub mod panorama;
mod resident;
mod timing;
pub mod translucency;
pub(crate) mod upload;
mod world_post;
@@ -98,6 +99,8 @@ struct SectionGpu {
translucent: u32,
/// The solid quads by facing: back-facing groups are skipped.
faces: crate::mesher::FaceGroups,
sort: translucency::SortState,
sorted_indices: Option<(u32, u64)>,
}
/// Line segments the hit outline can hold.
const MAX_OUTLINE_EDGES: u64 = 256;
@@ -133,6 +136,8 @@ pub struct Frame<'a> {
pub dark_disc: bool,
/// The hit block outline as line segments (from, to) in world space.
pub outline: Vec<[f64; 6]>,
/// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain.
pub outline_after_terrain: bool,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -222,6 +227,7 @@ struct BorderUniform {
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`).
surface: Option<wgpu::Surface<'static>>,
/// The offscreen colour target, sized like `config`.
@@ -290,7 +296,13 @@ pub struct Renderer {
group: wgpu::BindGroup,
globals: wgpu::Buffer,
lightmap: wgpu::Texture,
sprite_texture: wgpu::Texture,
/// The block sprites, [`SPRITE_SPLIT`] layers per array (the first is
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
sprite_textures: Vec<wgpu::Texture>,
/// A 1-layer array bound where a pack needs fewer arrays.
sprite_unused: wgpu::TextureView,
/// Layers per sprite array ([`SPRITE_SPLIT`] but in tests).
sprite_split: u32,
mip_levels: u32,
/// The mipmap levels sampled (the Mipmap Levels option), at most `mip_levels - 1`.
mipmaps: u32,
@@ -311,6 +323,9 @@ pub struct Renderer {
depth: wgpu::TextureView,
sections: resident::Resident<SectionGpu>,
arena: arena::Arena,
sorted_indices: arena::Arena,
translucent_sorter: translucency::Sorter,
translucency_cursor: usize,
/// `DrawIndexedIndirectArgs` for this frame's solid then translucent draws.
indirect: wgpu::Buffer,
indirect_capacity: u64,
@@ -322,8 +337,8 @@ pub struct Renderer {
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
/// index, base vertex, first instance.
draws: Vec<[u32; 5]>,
/// The arena page of each of `draws`.
draw_pages: Vec<u32>,
/// Vertex arena page and optional sorted index arena page of each draw.
draw_pages: Vec<(u32, Option<u32>)>,
/// Block-entity blocks per meshed section (world position, state id).
block_entities: HashMap<SectionKey, Vec<([i32; 3], u32)>>,
/// VisGraph face connectivity per meshed section (cave culling).
@@ -348,12 +363,87 @@ pub struct Renderer {
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Brightness option (lightmap gamma), 0..1.
pub gamma: f32,
/// Optional full-light lightmap (Horizon Extras).
pub fullbright: bool,
/// Advance animated block textures (Horizon setting; vanilla always does).
pub animated_textures: bool,
present_modes: Vec<wgpu::PresentMode>,
}
/// Every layer's mip levels, layer major (`first` is the first layer's
/// index into `settings`), made by `MipmapGenerator`.
/// Block sprite layers per texture array: the smallest
/// `max_texture_array_layers` of the GPUs Horizon runs on. Mods add sprites
/// past it into more arrays (terrain.wgsl's `sprite`).
pub(crate) const SPRITE_SPLIT: u32 = 2048;
/// [`SPRITE_SPLIT`], or a smaller split from `HORIZON_SPRITE_SPLIT` (dev
/// only: vanilla's sprites drawn from several arrays must look the same).
fn sprite_split() -> u32 {
std::env::var("HORIZON_SPRITE_SPLIT")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
.unwrap_or(SPRITE_SPLIT)
}
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
const SPRITE_ARRAYS: u32 = 4;
/// The views of terrain.wgsl's sprite arrays, the unused ones a 1-layer
/// stand-in.
fn sprite_views(
textures: &[wgpu::Texture],
unused: &wgpu::TextureView,
mips: Option<u32>,
) -> Vec<wgpu::TextureView> {
(0..SPRITE_ARRAYS as usize)
.map(|i| match textures.get(i) {
Some(t) => t.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
mip_level_count: mips,
..Default::default()
}),
None => unused.clone(),
})
.collect()
}
/// terrain.wgsl's group 0.
#[allow(clippy::too_many_arguments)]
fn terrain_group(
device: &wgpu::Device,
label: &str,
layout: &wgpu::BindGroupLayout,
globals: &wgpu::Buffer,
sprites: &[wgpu::TextureView],
sprite_sampler: &wgpu::Sampler,
lightmap: &wgpu::TextureView,
lightmap_sampler: &wgpu::Sampler,
) -> wgpu::BindGroup {
let view = |binding, view| wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(view),
};
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(label),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
},
view(1, &sprites[0]),
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sprite_sampler),
},
view(3, lightmap),
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(lightmap_sampler),
},
view(5, &sprites[1]),
view(6, &sprites[2]),
view(7, &sprites[3]),
],
})
}
fn mip_chain(
pixels: &[u8],
size: u32,
@@ -454,8 +544,14 @@ impl Renderer {
hud_atlas: (&[u8], u32, u32),
forcefield: (&[u8], u32),
panorama_faces: &panorama::Faces,
previous: Option<&Self>,
) -> Result<Self> {
let instance = wgpu::Instance::new(instance_descriptor());
// Reload resources on the same device; replacing live Vulkan devices
// can invalidate the driver's swapchain state on the same window.
let instance = previous.map_or_else(
|| wgpu::Instance::new(instance_descriptor()),
|renderer| renderer.instance.clone(),
);
// Offscreen rendering needs no surface, so it can use a GPU the
// window's display cannot present from (a headless compositor).
let surface = if offscreen {
@@ -463,15 +559,18 @@ impl Renderer {
} else {
Some(instance.create_surface(window.clone())?)
};
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?;
let adapter = match previous {
Some(renderer) => renderer.adapter.clone(),
None => instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
.map_err(|e| format!("{NO_GPU_DRIVER} ({e})"))?,
};
let info = adapter.get_info();
log::info!(
"Horizon renderer: {} ({:?}, {:?})",
@@ -494,22 +593,40 @@ impl Renderer {
),
);
let limits = adapter.limits();
if limits.max_texture_array_layers < layers {
let split = sprite_split();
if layers > split * SPRITE_ARRAYS {
return Err(format!(
"GPU supports {} texture layers; {layers} required",
limits.max_texture_array_layers
"{layers} block textures; Horizon draws at most {}",
split * SPRITE_ARRAYS
)
.into());
}
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: timing::GpuTimer::features(&adapter)
| (adapter.features() & wgpu::Features::INDIRECT_FIRST_INSTANCE),
required_limits: limits.clone(),
..Default::default()
})
.await?;
if limits.max_texture_array_layers < layers.min(split) {
return Err(format!(
"GPU supports {} texture layers; {} required",
limits.max_texture_array_layers,
layers.min(split)
)
.into());
}
let (device, queue) = match previous {
Some(renderer) => (renderer.device.clone(), renderer.queue.clone()),
None => {
adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("Horizon device"),
// Mappable primary buffers: section pages that no
// longer fit in video memory go to system memory.
required_features: timing::GpuTimer::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)),
required_limits: limits.clone(),
..Default::default()
})
.await?
}
};
let size = window.inner_size();
let (config, present_modes) = if let Some(surface) = &surface {
let mut config = surface
@@ -528,11 +645,16 @@ impl Renderer {
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
config.usage |= wgpu::TextureUsages::COPY_SRC;
}
config.present_mode = if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
wgpu::PresentMode::Mailbox
} else {
wgpu::PresentMode::Fifo
};
config.present_mode = previous
.map(|renderer| renderer.config.present_mode)
.filter(|mode| caps.present_modes.contains(mode))
.unwrap_or_else(|| {
if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
wgpu::PresentMode::Mailbox
} else {
wgpu::PresentMode::Fifo
}
});
surface.configure(&device, &config);
(config, caps.present_modes.clone())
} else {
@@ -565,6 +687,19 @@ impl Renderer {
count: None,
};
let vf = wgpu::ShaderStages::VERTEX_FRAGMENT;
let sprite_array = wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
};
let sprite_arrays = layers.div_ceil(split).max(1);
if sprite_arrays > 1 {
log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays");
}
let sprite_constants = [
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
("SPRITE_SPLIT", f64::from(split)),
];
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon terrain bindings"),
entries: &[
@@ -605,6 +740,9 @@ impl Renderer {
vf,
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
),
entry(5, vf, sprite_array),
entry(6, vf, sprite_array),
entry(7, vf, sprite_array),
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -677,7 +815,10 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
blend,
@@ -707,7 +848,7 @@ impl Renderer {
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
false,
true,
true,
Some(alpha_blend),
);
@@ -740,7 +881,7 @@ impl Renderer {
&line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
true,
true,
Some(alpha_blend),
);
@@ -784,30 +925,62 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let (mips, mip_levels) = mip_chain(sprite_pixels, sprite_size, sprite_mips, 0, layers);
let sprite_texture = device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Block sprites"),
let layer_bytes = (sprite_size * sprite_size * 4) as usize;
let mut mip_levels = 1;
let sprite_textures: Vec<wgpu::Texture> = (0..sprite_arrays)
.map(|array| {
let first = array * split;
let count = layers.saturating_sub(first).clamp(1, split);
let start = (first as usize * layer_bytes).min(sprite_pixels.len());
let (mips, levels) = mip_chain(
&sprite_pixels[start..],
sprite_size,
sprite_mips,
first,
count,
);
mip_levels = levels;
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Block sprites"),
size: wgpu::Extent3d {
width: sprite_size,
height: sprite_size,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&mips,
)
})
.collect();
let sprite_unused = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Block sprites (unused)"),
size: wgpu::Extent3d {
width: sprite_size,
height: sprite_size,
depth_or_array_layers: layers,
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: mip_levels,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&mips,
);
let sprite_view = sprite_texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
})
.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
let sprite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Block sprites"),
address_mode_u: wgpu::AddressMode::Repeat,
@@ -838,58 +1011,26 @@ impl Renderer {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon terrain resources"),
layout: &bindings,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&sprite_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sprite_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&lightmap_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
},
],
});
let crosshair_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Debug crosshair resources"),
layout: &bindings,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: crosshair_globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&sprite_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sprite_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&lightmap_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&lightmap_sampler),
},
],
});
let group = terrain_group(
&device,
"Horizon terrain resources",
&bindings,
&globals,
&sprite_views,
&sprite_sampler,
&lightmap_view,
&lightmap_sampler,
);
let crosshair_group = terrain_group(
&device,
"Debug crosshair resources",
&bindings,
&crosshair_globals,
&sprite_views,
&sprite_sampler,
&lightmap_view,
&lightmap_sampler,
);
let index_quads = 1 << 16;
let indices = Self::quad_indices(&device, index_quads);
let instance_capacity = 4096;
@@ -1504,6 +1645,7 @@ impl Renderer {
std::mem::size_of::<TerrainVertex>() as u64,
1 << 20,
);
let sorted_indices = arena::Arena::indices(&device, 1 << 16);
let indirect = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section draws"),
size: 1024 * 20,
@@ -1523,6 +1665,7 @@ impl Renderer {
);
Ok(Self {
instance,
adapter,
surface,
offscreen,
in_flight: std::collections::VecDeque::new(),
@@ -1575,7 +1718,9 @@ impl Renderer {
group,
globals,
lightmap,
sprite_texture,
sprite_textures,
sprite_unused,
sprite_split: split,
mip_levels,
mipmaps: mip_levels - 1,
terrain_layout: bindings,
@@ -1594,6 +1739,9 @@ impl Renderer {
depth,
sections: resident::Resident::default(),
arena,
sorted_indices,
translucent_sorter: translucency::Sorter::default(),
translucency_cursor: 0,
indirect,
indirect_capacity: 1024,
multi_draw,
@@ -1616,6 +1764,7 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
gamma: 0.5,
fullbright: false,
animated_textures: true,
present_modes,
})
@@ -1656,6 +1805,22 @@ impl Renderer {
pub fn is_offscreen(&self) -> bool {
self.surface.is_none()
}
/// Release the window surface before another renderer uses the same window.
pub fn release_surface(&mut self) {
self.surface = None;
}
/// Reattach a window after surface loss or a failed renderer replacement.
pub fn recreate_surface(&mut self) -> Result<()> {
if self.offscreen.is_some() {
return Ok(());
}
// Wayland allows only one tearing-control object per window surface.
self.release_surface();
let surface = self.instance.create_surface(self.window.clone())?;
surface.configure(&self.device, &self.config);
self.surface = Some(surface);
Ok(())
}
fn depth(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
@@ -1760,7 +1925,22 @@ impl Renderer {
self.indices = Self::quad_indices(&self.device, self.index_quads);
}
let vertices = (mesh.solid.len() + mesh.translucent.len()) as u64;
let (page, start) = self.arena.alloc(&self.device, &self.queue, vertices);
// With video and system memory both full, the section is not drawn.
let Some((page, start)) = self.arena.alloc(&self.device, &self.queue, vertices) else {
return;
};
let sorted_indices = if translucent > 0 {
let indices =
self.sorted_indices
.alloc(&self.device, &self.queue, u64::from(translucent) * 6);
if indices.is_none() {
self.arena.release(page, start, vertices);
return;
}
indices
} else {
None
};
let stride = std::mem::size_of::<TerrainVertex>() as u64;
for (offset, part) in [(0, &mesh.solid), (mesh.solid.len(), &mesh.translucent)] {
if !part.is_empty() {
@@ -1781,6 +1961,15 @@ impl Renderer {
solid,
translucent,
faces: mesh.faces,
sort: translucency::SortState::new(
mesh.translucent
.as_chunks::<4>()
.0
.iter()
.map(|q| translucency::quad_center(q[0].pos, q[2].pos))
.collect(),
),
sorted_indices,
},
);
}
@@ -1791,17 +1980,19 @@ impl Renderer {
fn draw_sections(&self, pass: &mut wgpu::RenderPass<'_>, first: u32, count: u32) {
let (mut i, end) = (first as usize, (first + count) as usize);
while i < end {
let page = self.draw_pages[i];
let (page, index_page) = self.draw_pages[i];
let run = self.draw_pages[i..end]
.iter()
.take_while(|&&p| p == page)
.take_while(|&&p| p == (page, index_page))
.count();
pass.set_vertex_buffer(0, self.arena.buffer(page).slice(..));
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
if self.multi_draw {
pass.multi_draw_indexed_indirect(&self.indirect, i as u64 * 20, run as u32);
} else {
for d in &self.draws[i..i + run] {
pass.draw_indexed(0..d[0], d[3] as i32, d[4]..d[4] + 1);
pass.draw_indexed(d[2]..d[2] + d[0], d[3] as i32, d[4]..d[4] + 1);
}
}
i += run;
@@ -1809,6 +2000,10 @@ impl Renderer {
}
fn release_section(&mut self, key: SectionKey) {
if let Some(s) = self.sections.remove(key) {
if let Some((page, start)) = s.sorted_indices {
self.sorted_indices
.release(page, start, u64::from(s.translucent) * 6);
}
self.arena
.release(s.page, s.start, u64::from(s.solid + s.translucent) * 4);
}
@@ -1821,6 +2016,8 @@ impl Renderer {
self.block_entities.clear();
self.sections.clear();
self.arena.clear();
self.sorted_indices.clear();
self.translucency_cursor = 0;
self.occlusion.clear();
}
pub fn remove(&mut self, key: SectionKey) {
@@ -1864,7 +2061,7 @@ impl Renderer {
pub fn set_mipmap_levels(&mut self, levels: u32) {
let levels = sprite_mipmap_limit(
levels,
self.sprite_texture.width(),
self.sprite_textures[0].width(),
self.sprite_minimum_size,
self.mip_levels,
);
@@ -1872,39 +2069,17 @@ impl Renderer {
return;
}
self.mipmaps = levels;
let sprite_view = self
.sprite_texture
.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
mip_level_count: Some(levels + 1),
..Default::default()
});
self.group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon terrain resources"),
layout: &self.terrain_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&sprite_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sprite_sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.lightmap_view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.lightmap_sampler),
},
],
});
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, Some(levels + 1));
self.group = terrain_group(
&self.device,
"Horizon terrain resources",
&self.terrain_layout,
&self.globals,
&views,
&self.sprite_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
);
}
pub fn view_projection(&self, frame: &Frame) -> Mat4 {
self.projection(
@@ -1972,6 +2147,9 @@ impl Renderer {
pixels[i + 3] = 255;
}
}
if self.fullbright {
pixels.fill(255);
}
// The lightmap changes with the time of day, flicker and effects,
// not every frame: upload only what differs from the texture.
if pixels == self.lightmap_pixels && self.lightmap_uploaded {
@@ -2001,12 +2179,12 @@ impl Renderer {
self.uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
texture: &self.sprite_texture,
texture: &self.sprite_textures[(layer / self.sprite_split) as usize],
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer,
z: layer % self.sprite_split,
},
aspect: wgpu::TextureAspect::All,
},
@@ -2018,6 +2196,8 @@ impl Renderer {
);
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
self.arena.trim();
self.sorted_indices.trim();
if self.suspended {
return Ok(false);
}
@@ -2039,9 +2219,7 @@ impl Renderer {
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Lost) => {
let surface = self.instance.create_surface(self.window.clone())?;
surface.configure(&self.device, &self.config);
self.surface = Some(surface);
self.recreate_surface()?;
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Validation) => {
@@ -2217,7 +2395,7 @@ impl Renderer {
visible.clear();
self.sections.visible(
camera,
frame.distance,
(frame.distance / 16.0) as i32,
&view,
|k| !culling || reachable.contains(k),
&mut visible,
@@ -2263,19 +2441,51 @@ impl Renderer {
s.faces.ranges(eye - Vec3::from(v.origin), |first, quads| {
let first = s.start + u64::from(first) * 4;
self.draws.push([quads * 6, 1, 0, first as u32, i as u32]);
self.draw_pages.push(s.page);
self.draw_pages.push((s.page, None));
});
}
let solid_draws = self.draws.len() as u32;
// LevelRenderer: nearby sections each frame, plus a rotating eighth
// (at least 15) of the visible list. Cached views avoid needless sorts.
let count = visible.len();
let budget = (count / 8).max(15).min(count);
self.translucency_cursor %= count.max(1);
for (i, v) in visible.iter().enumerate().rev() {
let s = self.sections.get(v.slot);
if s.translucent > 0 {
let s = self.sections.get_mut(v.slot);
if let Some((index_page, index_start)) = s.sorted_indices {
let nearby = (v.key.0 - camera_section.0).abs() <= 2
&& (v.key.1 - camera_section.1).abs() <= 2
&& (v.key.2 - camera_section.2).abs() <= 2;
let scheduled = (i + count - self.translucency_cursor) % count < budget;
let origin = [
v.key.0 * 16,
crate::chunk::MIN_Y + v.key.1 * 16,
v.key.2 * 16,
];
if (!s.sort.is_sorted() || nearby || scheduled)
&& let Some(indices) =
s.sort
.update(camera.to_array(), origin, &mut self.translucent_sorter)
{
self.uploads.buffer(
&mut encoder,
self.sorted_indices.buffer(index_page),
index_start * 4,
bytemuck::cast_slice(indices),
);
}
let first = s.start + u64::from(s.solid) * 4;
self.draws
.push([s.translucent * 6, 1, 0, first as u32, i as u32]);
self.draw_pages.push(s.page);
self.draws.push([
s.translucent * 6,
1,
index_start as u32,
first as u32,
i as u32,
]);
self.draw_pages.push((s.page, Some(index_page)));
}
}
self.translucency_cursor += budget;
let translucent_draws = self.draws.len() as u32 - solid_draws;
if self.multi_draw && !self.draws.is_empty() {
if self.draws.len() as u64 > self.indirect_capacity {
@@ -2537,7 +2747,7 @@ impl Renderer {
pass.draw_indexed(0..additive_quads * 6, (world_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 {
if outline_edges > 0 && !frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
@@ -2556,6 +2766,14 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(&mut pass, solid_draws, translucent_draws);
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
pass.set_pipeline(&self.border_pipeline);
@@ -2776,6 +2994,7 @@ impl Renderer {
&mut self.uploads,
&mut encoder,
&view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
);
@@ -3288,13 +3507,39 @@ fn mix_frames(current: &mut [u8], next: &[u8], millis: u32) {
let t = millis as f32 / 1000.0;
for (c, n) in current.iter_mut().zip(next) {
let (a, b) = (f32::from(*c) / 255.0, f32::from(*n) / 255.0);
*c = ((a * (1.0 - t) + b * t) * 255.0).round() as u8;
*c = round_unorm8((a * (1.0 - t) + b * t) * 255.0);
}
}
/// `x.round() as u8` for `x >= 0`, without the `roundf` library call that
/// x86-64 makes for every `f32::round` (it has no rounding instruction before
/// SSE4.1): the fraction `x - trunc(x)` is exact below 2^23, so rounding half
/// away from zero is adding one when it is at least a half.
#[inline]
fn round_unorm8(x: f32) -> u8 {
let whole = x as u8;
whole.saturating_add(u8::from(x - f32::from(whole) >= 0.5))
}
#[cfg(test)]
mod sprite_upload_tests {
use super::*;
#[test]
fn blended_frames_round_like_f32_round() {
for millis in (0..1000).step_by(7).chain([999]) {
let t = millis as f32 / 1000.0;
for a in 0..=255u8 {
for b in 0..=255u8 {
let x = (f32::from(a) / 255.0 * (1.0 - t) + f32::from(b) / 255.0 * t) * 255.0;
assert_eq!(round_unorm8(x), x.round() as u8, "{a} {b} {millis}");
}
}
}
for x in [
0.0, 0.49999997, 0.5, 1.5, 2.5, 254.49998, 254.5, 255.0, 255.00002, 300.0,
] {
assert_eq!(round_unorm8(x), x.round() as u8, "{x}");
}
}
#[test]
fn real_rail_mips_do_not_change_with_larger_array_layers() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
+290 -39
View File
@@ -49,35 +49,79 @@ struct Page {
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
pub struct Arena {
pages: Vec<Page>,
pages: Vec<Option<Page>>,
stride: u64,
page_limit: u64,
initial_capacity: u64,
usage: wgpu::BufferUsages,
/// A page had to go to system memory (logged once).
spilled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
#[cfg(test)]
video_memory_full: bool,
}
impl Arena {
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
}
pub fn indices(device: &wgpu::Device, capacity: u64) -> Self {
Self::with_usage(device, 4, capacity, wgpu::BufferUsages::INDEX)
}
fn with_usage(
device: &wgpu::Device,
stride: u64,
capacity: u64,
usage: wgpu::BufferUsages,
) -> Self {
// 256 MiB pages, or less where the device's buffers are smaller.
let bytes = device.limits().max_buffer_size.min(256 << 20);
let page_limit = (bytes / stride) / GRAIN * GRAIN;
let capacity = capacity.next_multiple_of(GRAIN).min(page_limit);
Self {
pages: vec![Page {
buffer: Self::create(device, stride, capacity),
capacity,
free: FreeList::new(capacity),
}],
pages: Vec::new(),
stride,
page_limit,
initial_capacity: capacity,
usage,
spilled: false,
#[cfg(test)]
video_memory_full: false,
}
}
fn create(device: &wgpu::Device, stride: u64, capacity: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section vertex arena"),
size: capacity * stride,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
/// A buffer of `capacity` vertices in video memory or, when that is full
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
.then(|| try_create(device, capacity * self.stride, usage))
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
if !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
{
return None;
}
let buffer = try_create(
device,
capacity * self.stride,
usage | wgpu::BufferUsages::MAP_WRITE,
)?;
if !std::mem::replace(&mut self.spilled, true) {
log::warn!(
"Horizon: video memory is full; more terrain is kept in system memory \
(lower the render distance if frames slow down)"
);
}
Some(buffer)
})
}
/// The rounded length a request of `vertices` occupies.
@@ -86,55 +130,97 @@ impl Arena {
}
/// Bytes of every page's buffer.
pub fn bytes(&self) -> u64 {
self.pages.iter().map(|p| p.capacity).sum::<u64>() * self.stride
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
}
pub fn buffer(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize].buffer
&self.pages[page as usize]
.as_ref()
.expect("live arena page")
.buffer
}
/// A range of `vertices`: (page, first vertex). Grows the newest page
/// (copying its contents on the GPU) or starts a new one when nothing
/// free is large enough.
/// free is large enough; `None` when no memory is left for it.
pub fn alloc(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
vertices: u64,
) -> (u32, u64) {
) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(start) = page.free.take(len) {
return (i as u32, start);
if let Some(page) = page
&& let Some(start) = page.free.take(len)
{
return Some((i as u32, start));
}
}
let last = self.pages.len() - 1;
let current = self.pages[last].capacity;
if current < self.page_limit {
let last = self.pages.len().saturating_sub(1);
if let Some(page) = self.pages.last().and_then(Option::as_ref)
&& page.capacity < self.page_limit
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
if grown - current >= len || grown == self.page_limit {
self.grow(device, queue, last, grown);
if let Some(start) = self.pages[last].free.take(len) {
return (last as u32, start);
}
if (grown - current >= len || grown == self.page_limit)
&& self.grow(device, queue, last, grown)
&& let Some(start) = self.pages[last].as_mut().unwrap().free.take(len)
{
return Some((last as u32, start));
}
}
let capacity = (1 << 20u64).max(len).min(self.page_limit);
let capacity = if self.pages.is_empty() {
self.initial_capacity
} else {
1 << 20
}
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let (buffer, capacity) = match self.create(device, capacity) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None => return None,
};
let mut free = FreeList::new(capacity);
let start = free.take(len).expect("a new page fits one section");
self.pages.push(Page {
buffer: Self::create(device, self.stride, capacity),
let page = Page {
buffer,
capacity,
free,
});
((self.pages.len() - 1) as u32, start)
};
let index = self
.pages
.iter()
.position(Option::is_none)
.unwrap_or_else(|| {
self.pages.push(None);
self.pages.len() - 1
});
self.pages[index] = Some(page);
Some((index as u32, start))
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
self.pages[page as usize].free.give(start, len);
self.pages[page as usize]
.as_mut()
.expect("live arena page")
.free
.give(start, len);
}
fn grow(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, page: usize, capacity: u64) {
/// Moves a page into a larger buffer; false (the page unchanged) when
/// there is no memory for it.
fn grow(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
page: usize,
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let buffer = Self::create(device, self.stride, capacity);
let old = &mut self.pages[page];
let Some(buffer) = self.create(device, capacity) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Grow section arena"),
});
@@ -145,18 +231,124 @@ impl Arena {
old.buffer = buffer;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
true
}
pub fn clear(&mut self) {
/// Reclaim after an upload batch, so replacing a section can reuse its range.
pub fn trim(&mut self) {
for page in &mut self.pages {
page.free = FreeList::new(page.capacity);
if let Some(p) = page
&& p.free.0.len() == 1
&& p.free.0.get(&0) == Some(&p.capacity)
{
*page = None;
}
}
// Keep live page indices stable; later allocations reuse interior holes.
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
}
}
pub fn clear(&mut self) {
self.pages.clear();
}
}
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section buffer arena"),
size,
usage,
mapped_at_creation: false,
});
// Native error scopes resolve at once.
pollster::block_on(scope.pop()).is_none().then_some(buffer)
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
device_with(wgpu::Features::empty())
}
/// A device with those of `features` the adapter has.
fn device_with(features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
eprintln!("arena GPU regression: {}", adapter.get_info().name);
let required_features = adapter.features() & features;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features,
..Default::default()
}))
.ok()
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN);
arena.page_limit = 2 * GRAIN;
assert_eq!(arena.bytes(), 0);
let (first, a) = arena.alloc(&device, &queue, GRAIN).unwrap();
let (_, b) = arena.alloc(&device, &queue, GRAIN).unwrap();
let (live, c) = arena.alloc(&device, &queue, 2 * GRAIN).unwrap();
let buffer = arena.buffer(live).clone();
let data = [1u8, 2, 3, 4];
queue.write_buffer(&buffer, c * 4, &data);
assert_eq!(arena.bytes(), 4 * GRAIN * 4);
arena.release(first, a, GRAIN);
arena.trim();
assert_eq!(arena.bytes(), 4 * GRAIN * 4);
arena.release(first, b, GRAIN);
arena.trim();
assert_eq!(arena.bytes(), 2 * GRAIN * 4);
assert_eq!(arena.buffer(live), &buffer);
let (reused, d) = arena.alloc(&device, &queue, GRAIN).unwrap();
assert_eq!(reused, first);
let (tx, rx) = std::sync::mpsc::channel();
wgpu::util::DownloadBuffer::read_buffer(
&device,
&queue,
&arena.buffer(live).slice(c * 4..c * 4 + 4),
move |result| tx.send(result.unwrap().to_vec()).unwrap(),
);
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert_eq!(rx.recv().unwrap(), data);
arena.release(reused, d, GRAIN);
arena.release(live, c, 2 * GRAIN);
arena.trim();
assert_eq!(arena.bytes(), 0);
assert!(arena.pages.is_empty());
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
for mut arena in [
Arena::new(&device, 28, GRAIN),
Arena::indices(&device, GRAIN),
] {
arena.alloc(&device, &queue, GRAIN).unwrap();
assert_eq!(arena.bytes(), arena.stride * GRAIN);
arena.clear();
assert_eq!(arena.bytes(), 0);
assert_eq!(arena.alloc(&device, &queue, GRAIN), Some((0, 0)));
assert_eq!(arena.bytes(), arena.stride * GRAIN);
}
}
#[test]
fn free_ranges_coalesce() {
let mut free = FreeList::new(4 * GRAIN);
@@ -181,4 +373,63 @@ mod tests {
free.give(a, GRAIN);
assert_eq!(free.take(GRAIN), Some(a));
}
fn read(device: &wgpu::Device, queue: &wgpu::Queue, buffer: &wgpu::Buffer, offset: u64) -> u32 {
let (tx, rx) = std::sync::mpsc::channel();
wgpu::util::DownloadBuffer::read_buffer(
device,
queue,
&buffer.slice(offset..offset + 4),
move |result| tx.send(result.unwrap().to_vec()).unwrap(),
);
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
u32::from_le_bytes(rx.recv().unwrap()[..4].try_into().unwrap())
}
#[test]
fn full_video_memory_spills_to_system_memory() {
let Some((device, queue)) = device_with(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS) else {
return;
};
if !device
.features()
.contains(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS)
{
return;
}
let mut arena = Arena::indices(&device, GRAIN);
let (page, start) = arena.alloc(&device, &queue, GRAIN).unwrap();
queue.write_buffer(arena.buffer(page), start * 4, &7u32.to_le_bytes());
arena.video_memory_full = true;
// The full page can't grow in video memory: it moves to system
// memory with its contents, and new ranges come from there.
let (grown, next) = arena.alloc(&device, &queue, GRAIN).unwrap();
assert!(arena.spilled);
assert_eq!(grown, page);
assert!(
arena
.buffer(page)
.usage()
.contains(wgpu::BufferUsages::MAP_WRITE)
);
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
}
#[test]
fn no_memory_left_is_not_an_error() {
let Some((device, queue)) = device() else {
return;
};
let mut arena = Arena::indices(&device, GRAIN);
arena.alloc(&device, &queue, GRAIN).unwrap();
arena.video_memory_full = true;
// Without mappable primary buffers nothing can spill: the request
// fails, and the arena keeps what it has.
assert_eq!(arena.alloc(&device, &queue, GRAIN), None);
assert_eq!(arena.bytes(), GRAIN * 4);
arena.release(0, 0, GRAIN);
assert_eq!(arena.alloc(&device, &queue, GRAIN), Some((0, 0)));
}
}
+33 -28
View File
@@ -3,7 +3,7 @@
//! the render distance rejects all of its sections with one test, and the
//! sections that remain are reached by index rather than by hashing keys.
use super::{Frustum, SectionKey};
use crate::fast_hash::FxHashMap;
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use glam::{DVec3, Vec3};
/// One chunk column's resident sections.
@@ -58,6 +58,9 @@ impl<T> Resident<T> {
pub fn get(&self, slot: u32) -> &T {
&self.items[slot as usize].1
}
pub fn get_mut(&mut self, slot: u32) -> &mut T {
&mut self.items[slot as usize].1
}
pub fn clear(&mut self) {
self.items.clear();
self.slots.clear();
@@ -149,26 +152,28 @@ fn origin(key: SectionKey, camera: DVec3) -> DVec3 {
) - camera
}
/// Whether the section at `key` is drawn: within `distance` blocks of the
/// camera horizontally (plus a section and a half), and partly inside the
/// frustum. `None` when it is not, else its squared centre distance and origin.
fn in_view(
key: SectionKey,
camera: DVec3,
distance: f32,
frustum: &Frustum,
) -> Option<(f64, Vec3)> {
let origin = origin(key, camera);
let center = origin + DVec3::splat(8.0);
let horizontal = (center.x * center.x + center.z * center.z).sqrt();
if horizontal > f64::from(distance) + 24.0 {
/// The camera's chunk column.
fn camera_column(camera: DVec3) -> (i32, i32) {
(
(camera.x.floor() as i32) >> 4,
(camera.z.floor() as i32) >> 4,
)
}
/// Whether the section at `key` is drawn: its column within the render
/// distance of the camera's (`SectionOcclusionGraph.isInViewDistance`), and
/// partly inside the frustum. `None` when it is not, else its squared centre
/// distance and origin.
fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Option<(f64, Vec3)> {
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
return None;
}
let origin = origin(key, camera);
let min = origin.as_vec3();
if !frustum.intersects(min, min + Vec3::splat(16.0)) {
return None;
}
Some((center.length_squared(), min))
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
}
impl<T> Resident<T> {
@@ -178,22 +183,22 @@ impl<T> Resident<T> {
/// Columns are tested first, with the same distance test (it depends
/// only on x and z) and a box spanning their sections: every section box
/// lies inside it and the frustum test rounds monotonically, so a column
/// the frustum rejects holds no section it would accept.
/// the frustum rejects holds no section it would accept. `radius` is the
/// render distance in chunks.
pub fn visible(
&self,
camera: DVec3,
distance: f32,
radius: i32,
frustum: &Frustum,
reachable: impl Fn(SectionKey) -> bool,
out: &mut Vec<Visible>,
) {
let from = camera_column(camera);
for column in &self.columns {
let low = origin((column.x, column.low, column.z), camera);
let center = low + DVec3::splat(8.0);
if (center.x * center.x + center.z * center.z).sqrt() > f64::from(distance) + 24.0 {
if !in_view_distance(from, radius, column.x, column.z) {
continue;
}
let min = low.as_vec3();
let min = origin((column.x, column.low, column.z), camera).as_vec3();
let top = origin((column.x, column.high, column.z), camera).as_vec3();
if !frustum.intersects(min, top + Vec3::splat(16.0)) {
continue;
@@ -203,7 +208,7 @@ impl<T> Resident<T> {
if !reachable(key) {
continue;
}
if let Some((d, o)) = in_view(key, camera, distance, frustum) {
if let Some((d, o)) = in_view(key, camera, radius, frustum) {
out.push(Visible {
distance: d.to_bits(),
key,
@@ -243,20 +248,20 @@ mod tests {
(DVec3::new(8.0, 64.0, 8.0), 45.0, 89.0),
];
for (camera, yaw, pitch) in cameras {
for distance in [32.0, 96.0, 160.0] {
for radius in [2, 6, 10] {
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let view = crate::camera::view_matrix(yaw, pitch);
let frustum = Frustum {
planes: super::super::frustum(projection * view),
};
let mut fast = Vec::new();
r.visible(camera, distance, &frustum, reachable, &mut fast);
r.visible(camera, radius, &frustum, reachable, &mut fast);
let mut slow: Vec<(SectionKey, f64, [f32; 3])> = r
.items
.iter()
.filter(|(k, _)| reachable(*k))
.filter_map(|(k, _)| {
in_view(*k, camera, distance, &frustum).map(|(d, o)| (*k, d, o.to_array()))
in_view(*k, camera, radius, &frustum).map(|(d, o)| (*k, d, o.to_array()))
})
.collect();
slow.sort_unstable_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
@@ -268,7 +273,7 @@ mod tests {
})
.collect();
assert!(!slow.is_empty());
assert_eq!(fast, slow, "{camera} {yaw} {pitch} {distance}");
assert_eq!(fast, slow, "{camera} {yaw} {pitch} {radius}");
}
}
}
@@ -356,7 +361,7 @@ mod tests {
let t = Instant::now();
keys.clear();
keys.extend(map.keys().filter_map(|&k| {
in_view(k, camera, 512.0, &frustum).map(|(d, o)| (k, d, o.to_array()))
in_view(k, camera, 32, &frustum).map(|(d, o)| (k, d, o.to_array()))
}));
keys.sort_unstable_by(|a, b| a.1.total_cmp(&b.1));
for (k, _, _) in &keys {
@@ -365,7 +370,7 @@ mod tests {
old = old.min(t.elapsed());
let t = Instant::now();
list.clear();
r.visible(camera, 512.0, &frustum, |_| true, &mut list);
r.visible(camera, 32, &frustum, |_| true, &mut list);
for v in &list {
sum += i64::from(r.get(v.slot)[1] + r.get(v.slot)[0]);
}
@@ -0,0 +1,119 @@
//! Vanilla 26.3 MeshData.SortState / VertexSorting.byDistance.
//! Keep vertex order intact; only the quad indices change as the camera moves.
/// MeshData.decodeQuadCentroids uses the opposite corners, not all four vertices.
pub fn quad_center(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
std::array::from_fn(|i| (a[i] + b[i]) / 2.0)
}
#[derive(Default)]
pub struct Sorter {
order: Vec<(u32, f32)>,
indices: Vec<u32>,
}
impl Sorter {
pub fn sort(&mut self, centers: &[[f32; 3]], camera: [f32; 3]) -> &[u32] {
self.order.clear();
self.order.extend(centers.iter().enumerate().map(|(i, c)| {
let [x, y, z] = std::array::from_fn(|j| camera[j] - c[j]);
(i as u32, x * x + y * y + z * z)
}));
// VertexSorting uses a stable merge sort, preserving mesh order for ties.
self.order.sort_by(|a, b| b.1.total_cmp(&a.1));
self.indices.clear();
for &(quad, _) in &self.order {
let v = quad * 4;
self.indices.extend([v, v + 1, v + 2, v + 2, v + 3, v]);
}
&self.indices
}
}
pub struct SortState {
centers: Vec<[f32; 3]>,
last: Option<([i32; 3], [i32; 3])>,
}
impl SortState {
pub fn new(centers: Vec<[f32; 3]>) -> Self {
Self {
centers,
last: None,
}
}
pub fn is_sorted(&self) -> bool {
self.last.is_some()
}
/// TranslucencyPointOfView and ResortTransparencyTask: diagonal views keep
/// their ordering until the point of view changes; aligned views also
/// resort on crossing a block boundary. Sub-block movement needs no upload.
pub fn update<'a>(
&mut self,
camera: [f64; 3],
origin: [i32; 3],
sorter: &'a mut Sorter,
) -> Option<&'a [u32]> {
let block = camera.map(|v| v.floor() as i32);
let pov = std::array::from_fn(|i| ((block[i] >> 4) - (origin[i] >> 4)).clamp(-1, 1));
if let Some((last_block, last_pov)) = self.last
&& last_pov == pov
&& (last_block == block || !pov.contains(&0))
{
return None;
}
self.last = Some((block, pov));
Some(sorter.sort(
&self.centers,
std::array::from_fn(|i| (camera[i] - f64::from(origin[i])) as f32),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_distances_keep_mesh_order_and_triangle_winding() {
let mut sorter = Sorter::default();
assert_eq!(
sorter.sort(
&[[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [4.0, 0.0, 0.0]],
[0.0; 3]
),
&[8, 9, 10, 10, 11, 8, 0, 1, 2, 2, 3, 0, 4, 5, 6, 6, 7, 4]
);
assert!(sorter.sort(&[], [0.0; 3]).is_empty());
}
#[test]
fn movement_reverses_faces_without_moving_vertices() {
let centers = [[0.5, 0.5, 0.0], [0.5, 0.5, 1.0]];
let mut sorter = Sorter::default();
assert_eq!(sorter.sort(&centers, [0.5, 0.5, -2.0])[0], 4);
assert_eq!(sorter.sort(&centers, [0.5, 0.5, 3.0])[0], 0);
}
#[test]
fn cached_viewpoint_handles_negative_and_large_coordinates() {
let origin = [-30_000_000, -64, 30_000_000];
let mut state = SortState::new(vec![[0.5; 3]]);
let mut sorter = Sorter::default();
let camera = [-29_999_999.5, -63.5, 30_000_000.5];
assert!(state.update(camera, origin, &mut sorter).is_some());
assert!(state.update(camera, origin, &mut sorter).is_none());
assert!(
state
.update([-29_999_999.1, -63.1, 30_000_000.9], origin, &mut sorter)
.is_none()
);
assert!(
state
.update([-29_999_998.9, -63.1, 30_000_000.9], origin, &mut sorter)
.is_some()
);
}
}
+254 -61
View File
@@ -139,19 +139,74 @@ impl HudAtlas {
(g.rect, g.advance)
}
pub fn build(root: &Path) -> Result<Self> {
Self::pack(
root,
&[
"gui",
"item",
"block",
"font",
"environment",
"misc",
"painting",
"entity",
"particle",
"mob_effect",
"map",
],
true,
)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world icons and the Horizon menu text.
fn slots(images: &mut Vec<PackedImage>) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
}
// The minimap extra's image (game/extras.rs).
images.push((
MINIMAP_SPRITE.into(),
crate::minimap::SIZE as u32,
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
));
// Downloaded skins (game/scoreboard.rs).
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
}
for i in 0..WORLD_ICON_SLOTS {
images.push((
format!("horizon/world-icon{i}"),
64,
64,
vec![0; 64 * 64 * 4],
));
}
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
}
/// Only the font's metrics, for a game that draws nothing (the
/// integrated server): text measures as with the full atlas, without
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, &["font"], false)?;
atlas.pixels = Vec::new();
Ok(atlas)
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, folders: &[&str], full: bool) -> Result<Self> {
let textures = root.join("assets/minecraft/textures");
let mut files = Vec::new();
walk(&textures.join("gui"), &mut files);
walk(&textures.join("item"), &mut files);
walk(&textures.join("block"), &mut files);
walk(&textures.join("font"), &mut files);
walk(&textures.join("environment"), &mut files);
walk(&textures.join("misc"), &mut files);
walk(&textures.join("painting"), &mut files);
walk(&textures.join("entity"), &mut files);
walk(&textures.join("particle"), &mut files);
walk(&textures.join("mob_effect"), &mut files);
walk(&textures.join("map"), &mut files);
for folder in folders {
walk(&textures.join(folder), &mut files);
}
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
@@ -187,7 +242,9 @@ impl HudAtlas {
.flat_map(|h| h.join().unwrap_or_default())
.collect()
});
images.extend(atlas_sprites(root, &images));
if full {
images.extend(atlas_sprites(root, &images));
}
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
if let Some(edition) = images
.iter_mut()
@@ -198,36 +255,9 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
// Map textures (game/maps.rs) are written into these slots at run time.
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
if full {
Self::slots(&mut images);
}
// The minimap extra's image (game/extras.rs).
images.push((
MINIMAP_SPRITE.into(),
crate::minimap::SIZE as u32,
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
));
// Downloaded skins (game/scoreboard.rs) likewise.
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
}
for i in 0..WORLD_ICON_SLOTS {
images.push((
format!("horizon/world-icon{i}"),
64,
64,
vec![0; 64 * 64 * 4],
));
}
// Horizon-style menu text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
2048,
1024,
vec![0; 2048 * 1024 * 4],
));
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let width = 4096u32;
let mut placements = Vec::with_capacity(images.len());
@@ -319,6 +349,7 @@ pub struct HudVertex {
pub pos: [f32; 2],
pub uv: [f32; 2],
pub color: u32,
pub depth: f32,
}
/// The GUI Scale option (0 = auto).
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
@@ -330,10 +361,10 @@ pub struct HudBatch {
pub height: f32,
/// Glyph pixels to upload into the atlas before drawing.
pub patches: Vec<crate::font::Patch>,
/// Vertex ranges drawn with another blend than alpha blending, in order.
/// Vertex ranges with another blend or depth/scissor state, in order.
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
}
/// How a run of HUD vertices blends with what is under it.
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Blend {
/// `BlendFunction.TRANSLUCENT`.
@@ -342,6 +373,8 @@ pub enum Blend {
Invert,
/// `BlendFunction.ADDITIVE` (`RenderPipelines.GUI_TEXT_HIGHLIGHT`).
Additive,
/// Alpha blending with an isolated depth buffer and a pixel scissor.
Depth([u32; 4]),
}
pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
u32::from(a) << 24 | u32::from(b) << 16 | u32::from(g) << 8 | u32::from(r)
@@ -430,9 +463,44 @@ impl HudBatch {
pos: p[i],
uv: uv[i],
color,
depth: 0.0,
});
}
}
pub fn quad_3d(&mut self, p: [[f32; 3]; 4], uv: [[f32; 2]; 4], color: u32) {
for i in [0, 1, 2, 0, 2, 3] {
self.vertices.push(HudVertex {
pos: [p[i][0], p[i][1]],
uv: uv[i],
color,
depth: p[i][2],
});
}
}
/// Keep per-vertex depth for an orthographic GUI model, with +z toward the viewer.
pub fn depth_since(&mut self, start: usize, clip: [f32; 4]) {
let vertices = &mut self.vertices[start..];
if vertices.is_empty() {
return;
}
let min = vertices
.iter()
.map(|v| v.depth)
.fold(f32::INFINITY, f32::min);
let max = vertices
.iter()
.map(|v| v.depth)
.fold(f32::NEG_INFINITY, f32::max);
for v in vertices {
v.depth = (max - v.depth) / (max - min).max(f32::EPSILON);
}
let [x0, y0, x1, y1] = clip.map(|v| (v * self.scale).max(0.0) as u32);
// Separate previews clear depth independently, even if their scissors match.
self.blends.push((
start as u32..self.vertices.len() as u32,
Blend::Depth([x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)]),
));
}
pub fn rect_uv(&mut self, x: f32, y: f32, w: f32, h: f32, r: Rect, color: u32) {
self.quad(
[[x, y], [x + w, y], [x + w, y + h], [x, y + h]],
@@ -569,6 +637,7 @@ impl HudBatch {
pos: p[i],
uv,
color: c[i],
depth: 0.0,
});
}
}
@@ -729,6 +798,7 @@ pub struct HudRenderer {
/// `RenderPipelines.CROSSHAIR`.
invert_pipeline: wgpu::RenderPipeline,
additive_pipeline: wgpu::RenderPipeline,
depth_pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
@@ -808,8 +878,8 @@ impl HudRenderer {
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
let create = |label, blend, fragment_entry| {
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32];
let create = |label, blend, fragment_entry, depth: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
@@ -824,7 +894,13 @@ impl HudRenderer {
})],
},
primitive: Default::default(),
depth_stencil: None,
depth_stencil: depth.then_some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
@@ -840,7 +916,13 @@ impl HudRenderer {
cache: None,
})
};
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main");
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
let depth_pipeline = create(
"HUD model",
wgpu::BlendState::ALPHA_BLENDING,
"fs_main",
true,
);
// BlendFunction.INVERT: ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR,
// ONE, ZERO.
let invert_pipeline = create(
@@ -858,6 +940,7 @@ impl HudRenderer {
},
},
"fs_crosshair",
false,
);
// BlendFunction.ADDITIVE: ONE, ONE.
let add = wgpu::BlendComponent {
@@ -872,6 +955,7 @@ impl HudRenderer {
alpha: add,
},
"fs_main",
false,
);
let capacity = 1 << 16;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -886,6 +970,7 @@ impl HudRenderer {
pipeline,
invert_pipeline,
additive_pipeline,
depth_pipeline,
group,
uniform,
buffer,
@@ -935,6 +1020,7 @@ impl HudRenderer {
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
depth: &wgpu::TextureView,
batch: &HudBatch,
size: [u32; 2],
) {
@@ -965,7 +1051,7 @@ impl HudRenderer {
);
}
}
if batch.vertices.is_empty() {
if batch.vertices.is_empty() || size.contains(&0) {
return;
}
let count = batch.vertices.len() as u64;
@@ -1001,20 +1087,55 @@ impl HudRenderer {
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
color_attachments: &attachments,
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.buffer.slice(..));
for (range, blend) in batch.runs() {
pass.set_pipeline(match blend {
Blend::Alpha => &self.pipeline,
Blend::Invert => &self.invert_pipeline,
Blend::Additive => &self.additive_pipeline,
let runs = batch.runs();
let mut at = 0;
while at < runs.len() {
let clip = match runs[at].1 {
Blend::Depth(clip) => Some(clip),
_ => None,
};
let end = if clip.is_some() {
at + 1
} else {
runs[at..]
.iter()
.position(|(_, b)| matches!(b, Blend::Depth(_)))
.map_or(runs.len(), |n| at + n)
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("HUD"),
color_attachments: &attachments,
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.draw(range, 0..1);
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.buffer.slice(..));
if let Some([x, y, w, h]) = clip {
let (x, y) = (x.min(size[0]), y.min(size[1]));
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
if w == 0 || h == 0 {
at = end;
continue;
}
pass.set_scissor_rect(x, y, w, h);
}
for (range, blend) in &runs[at..end] {
pass.set_pipeline(match blend {
Blend::Alpha => &self.pipeline,
Blend::Invert => &self.invert_pipeline,
Blend::Additive => &self.additive_pipeline,
Blend::Depth(_) => &self.depth_pipeline,
});
pass.draw(range.clone(), 0..1);
}
at = end;
}
}
}
@@ -1023,6 +1144,43 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
let root = std::env::temp_dir().join(format!("horizon-fonts-atlas-{}", std::process::id()));
let font = root.join("assets/minecraft/font");
let textures = root.join("assets/minecraft/textures/font");
fs::create_dir_all(&font).unwrap();
fs::create_dir_all(&textures).unwrap();
fs::write(
font.join("default.json"),
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/test.png","ascent":7,"chars":["ab"]}]}"#,
)
.unwrap();
let mut pixels = vec![0u8; 16 * 8 * 4];
for y in 0..8 {
for x in (0..3).chain(8..13) {
pixels[(y * 16 + x) * 4 + 3] = 255;
}
}
let mut encoder =
png::Encoder::new(fs::File::create(textures.join("test.png")).unwrap(), 16, 8);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
let full = HudAtlas::build(&root).unwrap();
let fonts = HudAtlas::fonts(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert!(fonts.pixels.is_empty());
let width = full.font.width("abba");
assert!(width > 0.0);
assert_eq!(fonts.font.width("abba"), width);
}
#[test]
fn effect_icons_are_painted_from_the_mob_effect_directory() {
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
@@ -1092,6 +1250,41 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
#[test]
fn gui_models_keep_separate_depth_runs_between_overlay_draws() {
let mut batch = HudBatch::new([640, 480]);
batch.scale = 2.0;
let p = [
[0.0, 0.0, 2.0],
[0.0, 1.0, 2.0],
[1.0, 1.0, 2.0],
[1.0, 0.0, 2.0],
];
let uv = [[0.0; 2]; 4];
batch.quad([[0.0; 2]; 4], uv, u32::MAX);
for _ in 0..2 {
let start = batch.vertices.len();
batch.quad_3d(p, uv, u32::MAX);
batch.depth_since(start, [1.0, 2.0, 4.0, 6.0]);
}
batch.quad([[0.0; 2]; 4], uv, u32::MAX);
assert_eq!(
batch.runs(),
vec![
(0..6, Blend::Alpha),
(6..12, Blend::Depth([2, 4, 6, 8])),
(12..18, Blend::Depth([2, 4, 6, 8])),
(18..24, Blend::Alpha),
]
);
assert!(
batch
.vertices
.iter()
.all(|v| v.depth.is_finite() && (0.0..=1.0).contains(&v.depth))
);
}
#[test]
fn hud_shader_validates_both_fragment_entries() {
let module = naga::front::wgsl::parse_str(include_str!("hud.wgsl")).unwrap();
+2 -2
View File
@@ -7,10 +7,10 @@ struct Out {
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
}
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>) -> Out {
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32) -> Out {
let pixels = pos * hud.size.z;
var out: Out;
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, 0.0, 1.0);
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0);
out.uv = uv;
out.color = color;
return out;
+203 -18
View File
@@ -1,8 +1,9 @@
//! The lines of an item's tooltip as `ItemStack.getTooltipLines` writes them
//! without a player and without advanced tooltips, for stacks as recipes
//! produce them. The recipe book's search tree matches them, so recipes of
//! tools and armour answer to "attack speed" or "when on head".
use crate::items::{ItemInfo, ItemStack};
//! Item tooltip formatting: equipment modifiers with a player's base values,
//! and lines without player context for the recipe book's search tree.
use crate::{
hud::rgba,
items::{ItemInfo, ItemStack},
};
/// `EquipmentSlotGroup` in declaration order, the order tooltips list them in.
const SLOT_GROUPS: [&str; 11] = [
@@ -25,6 +26,7 @@ fn modifier_line(
attribute: &str,
amount: f64,
operation: usize,
total: bool,
) -> Option<String> {
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let scale = match operation {
@@ -32,7 +34,9 @@ fn modifier_line(
0 => 1.0,
_ => 100.0,
};
let (kind, shown) = if amount > 0.0 {
let (kind, shown) = if total {
("equals", amount * scale)
} else if amount > 0.0 {
("plus", amount * scale)
} else if amount < 0.0 {
("take", -amount * scale)
@@ -47,22 +51,27 @@ fn modifier_line(
)
}
/// `ItemStack.addAttributeTooltips` for the item's own modifiers: per slot
/// group an empty line, the group's heading and a line per modifier.
fn attribute_lines(info: &ItemInfo, translate: &dyn Fn(&str) -> Option<String>) -> Vec<String> {
/// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player
/// attack damage/speed bases. Recipe search has no player context.
pub fn attribute_lines(
modifiers: &[(String, String, f64, String, String)],
player_base: Option<[f64; 2]>,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
let mut lines = Vec::new();
for group in SLOT_GROUPS {
let mut first = true;
for (attribute, _, amount, operation, slot) in &info.modifiers {
for (attribute, modifier, amount, operation, slot) in modifiers {
if slot != group {
continue;
}
if std::mem::take(&mut first) {
lines.push(String::new());
lines.push(
lines.push((String::new(), u32::MAX));
lines.push((
translate(&format!("item.modifiers.{group}"))
.unwrap_or_else(|| format!("item.modifiers.{group}")),
);
rgba(170, 170, 170, 255),
));
}
let id = match operation.as_str() {
"add_multiplied_base" => 1,
@@ -70,7 +79,27 @@ fn attribute_lines(info: &ItemInfo, translate: &dyn Fn(&str) -> Option<String>)
_ => 0,
};
let path = attribute.strip_prefix("minecraft:").unwrap_or(attribute);
lines.extend(modifier_line(translate, path, *amount, id));
let base = player_base.and_then(|[damage, speed]| match modifier.as_str() {
"minecraft:base_attack_damage" => Some(damage),
"minecraft:base_attack_speed" => Some(speed),
_ => None,
});
if let Some(line) = modifier_line(
translate,
path,
amount + base.unwrap_or(0.0),
id,
base.is_some(),
) {
let (line, color) = if base.is_some() {
(format!(" {line}"), rgba(0, 170, 0, 255))
} else if *amount > 0.0 {
(line, rgba(85, 85, 255, 255))
} else {
(line, rgba(255, 85, 85, 255))
};
lines.push((line, color));
}
}
}
lines
@@ -102,6 +131,10 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
if let Some(name) = stack.custom_name() {
return name.to_owned();
}
if stack.data().is_some_and(|d| d.ominous_banner) {
return translate("block.minecraft.ominous_banner")
.unwrap_or_else(|| "Ominous Banner".to_owned());
}
let key = if is_potion_item(&stack.item) {
Some(crate::brewing::potion_name_key(&stack.item, stack.potion()))
} else if let Some(pattern) = path.strip_suffix("_armor_trim_smithing_template") {
@@ -120,6 +153,24 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
.unwrap_or_else(|| path.replace('_', " "))
}
/// ItemStack.getRarity: the ominous banner override, then the enchantment upgrade.
pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
let rarity = if stack.data().is_some_and(|d| d.ominous_banner) {
"uncommon"
} else {
default
};
if stack.enchantments().is_empty() {
rarity
} else {
match rarity {
"common" | "uncommon" => "rare",
"rare" => "epic",
r => r,
}
}
}
/// The lines of `stack` below its name that its components give, in
/// `ItemStack.addDetailsToTooltip` order.
pub fn detail_lines(
@@ -211,6 +262,7 @@ pub fn detail_lines(
attribute,
amount * (*amplifier + 1) as f64,
operation,
false,
)
})
.collect();
@@ -242,7 +294,11 @@ pub fn detail_lines(
lines.push(text("item.dyed"));
}
if let Some(info) = info {
lines.extend(attribute_lines(info, translate));
lines.extend(
attribute_lines(&info.modifiers, None, translate)
.into_iter()
.map(|(line, _)| line),
);
}
lines
}
@@ -292,6 +348,26 @@ pub fn lines(
mod tests {
use super::*;
#[test]
fn ominous_banner_name_and_rarity_use_the_stack() {
let translate = |key: &str| match key {
"block.minecraft.ominous_banner" => Some("Ominous Banner".to_owned()),
"block.minecraft.white_banner" => Some("White Banner".to_owned()),
_ => None,
};
let mut banner = crate::mobs::raider::ominous_banner();
assert_eq!(name_line(&banner, &translate), "Ominous Banner");
assert_eq!(name_line(&banner, &|_| None), "Ominous Banner");
assert_eq!(rarity(&banner, "common"), "uncommon");
banner.data_mut().custom_name = Some("Captain's Standard".into());
assert_eq!(name_line(&banner, &translate), "Captain's Standard");
banner.enchant("minecraft:unbreaking", 1);
assert_eq!(rarity(&banner, "common"), "rare");
let plain = ItemStack::new("minecraft:white_banner", 1);
assert_eq!(name_line(&plain, &translate), "White Banner");
assert_eq!(rarity(&plain, "common"), "common");
}
#[test]
fn container_lines_list_five_stacks_then_how_many_more() {
let mut chest = ItemStack::new("minecraft:chest", 1);
@@ -335,6 +411,115 @@ mod tests {
assert_eq!(format_amount(0.375), "0.38");
}
#[test]
fn equipment_attributes_use_player_totals_and_slot_group_colors() {
let modifiers = [
("attack_damage", "base_attack_damage", 6.0, "mainhand"),
("attack_speed", "base_attack_speed", -2.4, "mainhand"),
("armor", "armor", 3.0, "head"),
]
.map(|(attribute, id, amount, slot)| {
(
format!("minecraft:{attribute}"),
format!("minecraft:{id}"),
amount,
"add_value".to_owned(),
slot.to_owned(),
)
});
let root = std::env::temp_dir().join(format!(
"horizon-tooltip-language-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let dir = root.join("assets/minecraft/lang");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("en_us.json"),
serde_json::to_vec(&serde_json::json!({
"item.modifiers.mainhand": "When in Main Hand:",
"item.modifiers.head": "When on Head:",
"attribute.modifier.equals.0": "%s %s",
"attribute.modifier.plus.0": "+%s %s",
"attribute.modifier.take.0": "-%s %s",
"attribute.name.attack_damage": "Attack Damage",
"attribute.name.attack_speed": "Attack Speed",
"attribute.name.armor": "Armor",
}))
.unwrap(),
)
.unwrap();
let enchantments = crate::enchant::Enchantments::load(&root);
std::fs::remove_dir_all(&root).unwrap();
let lang = |key: &str| enchantments.translate(key).map(str::to_owned);
let green = rgba(0, 170, 0, 255);
let gray = rgba(170, 170, 170, 255);
let blue = rgba(85, 85, 255, 255);
let red = rgba(255, 85, 85, 255);
assert_eq!(
attribute_lines(&modifiers, Some([1.0, 4.0]), &lang),
[
(String::new(), u32::MAX),
("When in Main Hand:".into(), gray),
(" 7 Attack Damage".into(), green),
(" 1.6 Attack Speed".into(), green),
(String::new(), u32::MAX),
("When on Head:".into(), gray),
("+3 Armor".into(), blue),
]
);
let recipe = attribute_lines(&modifiers, None, &lang);
assert_eq!(recipe[2], ("+6 Attack Damage".into(), blue));
assert_eq!(recipe[3], ("-2.4 Attack Speed".into(), red));
let custom_base = attribute_lines(&modifiers, Some([2.0, 6.0]), &lang);
assert_eq!(custom_base[2].0, " 8 Attack Damage");
assert_eq!(custom_base[3].0, " 3.6 Attack Speed");
assert_eq!(
modifier_line(&lang, "attack_damage", 0.0, 0, true).as_deref(),
Some("0 Attack Damage")
);
}
#[test]
#[ignore = "requires prepared items/language in HORIZON_TEST_RESOURCES"]
fn equipment_attributes_with_prepared_items_and_language() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let items = crate::items::Items::catalog(&root).unwrap();
let enchantments = crate::enchant::Enchantments::load(&root);
let translate = |key: &str| enchantments.translate(key).map(str::to_owned);
for (item, expected) in [
(
"minecraft:diamond_sword",
[
"",
"When in Main Hand:",
" 7 Attack Damage",
" 1.6 Attack Speed",
],
),
(
"minecraft:diamond_helmet",
["", "When on Head:", "+3 Armor", "+2 Armor Toughness"],
),
] {
let info = items.get(item).unwrap();
let lines = attribute_lines(&info.modifiers, Some([1.0, 4.0]), &translate);
assert_eq!(
lines
.iter()
.map(|(text, _)| text.as_str())
.collect::<Vec<_>>(),
expected,
"{item}"
);
}
}
#[test]
fn modifier_lines_follow_the_sign_and_operation() {
let lang = |k: &str| {
@@ -349,13 +534,13 @@ mod tests {
)
};
assert_eq!(
modifier_line(&lang, "movement_speed", 0.2, 2).as_deref(),
modifier_line(&lang, "movement_speed", 0.2, 2, false).as_deref(),
Some("+20% Speed")
);
assert_eq!(
modifier_line(&lang, "movement_speed", -0.6, 2).as_deref(),
modifier_line(&lang, "movement_speed", -0.6, 2, false).as_deref(),
Some("-60% Speed")
);
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2), None);
assert_eq!(modifier_line(&lang, "movement_speed", 0.0, 2, false), None);
}
}
+14 -6
View File
@@ -274,16 +274,20 @@ pub(crate) fn load_tags(root: &Path, kind: &str) -> Result<HashMap<String, HashS
}
/// Every tag's contents in `TagLoader.tryBuildTag` order (a `LinkedHashSet`).
pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result<HashMap<String, Vec<String>>> {
let dir = root.join(format!("data/minecraft/tags/{kind}"));
let mut raw: HashMap<String, Vec<String>> = HashMap::new();
fn walk(dir: &Path, base: &Path, raw: &mut HashMap<String, Vec<String>>) -> Result<()> {
fn walk(
dir: &Path,
base: &Path,
namespace: &str,
raw: &mut HashMap<String, Vec<String>>,
) -> Result<()> {
for entry in fs::read_dir(dir)?.flatten() {
let p = entry.path();
if p.is_dir() {
walk(&p, base, raw)?;
walk(&p, base, namespace, raw)?;
} else if p.extension().is_some_and(|e| e == "json") {
let name = format!(
"minecraft:{}",
"{namespace}:{}",
p.strip_prefix(base)?
.with_extension("")
.to_string_lossy()
@@ -301,8 +305,12 @@ pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result<HashMap<String,
}
Ok(())
}
if dir.exists() {
walk(&dir, &dir, &mut raw)?;
// Vanilla's tags; with content mods also the convention (`c`) tags and the mods' own.
for namespace in crate::mod_content::tag_namespaces(root) {
let dir = root.join(format!("data/{namespace}/tags/{kind}"));
if dir.exists() {
walk(&dir, &dir, &namespace, &mut raw)?;
}
}
fn resolve(name: &str, raw: &HashMap<String, Vec<String>>, out: &mut Vec<String>, depth: u32) {
if depth > 32 {
+49
View File
@@ -373,11 +373,60 @@ impl KeyBinds {
}
Some(Err(button))
}
/// `AbstractContainerScreen.checkHotbarMouseClicked`: off hand, then hotbar.
pub fn container_mouse_key(&self, button: MouseButton, creative: bool) -> Option<KeyCode> {
let name = mouse_name(button);
if matches!(button, MouseButton::Left | MouseButton::Right)
|| (creative && self.get("key.pickItem") == name)
{
return None;
}
std::iter::once("key.swapOffhand")
.chain(
MAPPINGS
.iter()
.map(|m| m.0)
.filter(|n| n.starts_with("key.hotbar.")),
)
.find(|mapping| self.get(mapping) == name)
.and_then(|mapping| key_code(Self::default_of(mapping)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn container_mouse_hotkeys_follow_slot_bindings_and_pick_item_priority() {
for button in [MouseButton::Back, MouseButton::Forward, MouseButton::Middle] {
for (mapping, default, _) in MAPPINGS.iter().filter(|m| m.0.starts_with("key.hotbar."))
{
let mut keys = KeyBinds::default();
keys.set(mapping, &mouse_name(button));
assert_eq!(keys.container_mouse_key(button, false), key_code(default));
assert_eq!(
keys.container_mouse_key(button, true),
if button == MouseButton::Middle {
None
} else {
key_code(default)
}
);
}
}
let mut keys = KeyBinds::default();
keys.set("key.hotbar.1", "key.mouse.4");
keys.set("key.swapOffhand", "key.mouse.4");
assert_eq!(
keys.container_mouse_key(MouseButton::Back, false),
Some(KeyCode::KeyF)
);
keys.set("key.hotbar.2", "key.mouse.left");
assert_eq!(keys.container_mouse_key(MouseButton::Left, false), None);
assert_eq!(keys.container_mouse_key(MouseButton::Right, false), None);
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
}
#[test]
fn rebinding_translates_to_defaults() {
let mut k = KeyBinds::default();
+212 -66
View File
@@ -4,11 +4,14 @@
//! ClientboundRecipeBookAddPacket) and the advancements the player may see go
//! out in ClientboundUpdateAdvancementsPacket (PlayerAdvancements.flushDirty).
use super::data::{SyncData, json_to_nbt};
use crate::{advancements::Tree, recipe_book::RecipeIndex};
use crate::{
advancements::{Progress, Tree},
recipe_book::{RecipeIndex, Settings},
};
use horizon_protocol::buf::Writer;
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
};
@@ -54,8 +57,16 @@ impl ServerAdvancements {
}
}
/// The first tick of a player without advancements.
pub fn first_tick(&self, sync: &SyncData, now_millis: i64) -> FirstTick {
/// The first tick of a player without advancements: the criteria
/// `minecraft:tick` meets go into `progress` and the recipes their
/// advancements reward into `book`.
pub fn first_tick(
&self,
sync: &SyncData,
now_millis: i64,
progress: &mut Progress,
book: &mut Settings,
) -> FirstTick {
// CriteriaTriggers.TICK: the criteria it meets, and what they complete.
let mut obtained: Vec<Vec<&str>> = vec![Vec::new(); self.tree.list.len()];
for (i, a) in self.tree.list.iter().enumerate() {
@@ -77,25 +88,24 @@ impl ServerAdvancements {
.all(|group| group.iter().any(|c| got.contains(&c.as_str())))
})
.collect();
let date = crate::advancements::date_text(now_millis.div_euclid(1000));
for (a, got) in self.tree.list.iter().zip(&obtained) {
for c in got {
progress.grant(&a.id, c, &date);
}
}
// AdvancementRewards.grant: the recipes, in the advancements' order.
let mut entries = Vec::new();
for (i, a) in self.tree.list.iter().enumerate() {
if !done[i] {
continue;
}
book.add_recipes(&self.displays, &a.rewards.recipes);
for id in &a.rewards.recipes {
entries.extend(self.book_entries(id, sync));
entries.extend(self.book_entries(id, sync, NOTIFY_AND_HIGHLIGHT));
}
}
let recipe_book_add = (!entries.is_empty()).then(|| {
let mut w = Writer::new();
w.varint(entries.len() as i32);
for e in entries {
w.data.extend(e);
}
w.bool(false); // not a replacement
w.data
});
let recipe_book_add = (!entries.is_empty()).then(|| book_add(entries, false));
// flushDirty: every advancement the player may see, and its progress.
let visible = self.tree.visible(&self.kids, |i| done[i]);
let shown: Vec<usize> = (0..self.tree.list.len()).filter(|i| visible[*i]).collect();
@@ -103,60 +113,14 @@ impl ServerAdvancements {
w.bool(true); // the first packet resets the client's advancements
w.varint(shown.len() as i32);
for &i in &shown {
let a = &self.tree.list[i];
w.string(&a.id);
w.bool(a.parent.is_some());
if let Some(p) = &a.parent {
w.string(p);
}
w.bool(a.display.is_some());
if let Some(d) = &a.display {
w.nbt(&json_to_nbt(&d.title));
w.nbt(&json_to_nbt(&d.description));
w.varint(sync.id("minecraft:item", &d.icon).unwrap_or(0));
w.varint(1);
w.varint(0);
w.varint(0);
w.varint(match d.frame {
crate::advancements::Frame::Task => 0,
crate::advancements::Frame::Challenge => 1,
crate::advancements::Frame::Goal => 2,
});
w.i32(
i32::from(d.background.is_some())
| i32::from(d.show_toast) << 1
| i32::from(d.hidden) << 2,
);
if let Some(b) = &d.background {
w.string(b);
}
}
w.varint(a.requirements.len() as i32);
for group in &a.requirements {
w.varint(group.len() as i32);
for c in group {
w.string(c);
}
}
w.bool(false); // sends_telemetry_event
let (x, y) = self.layout[i].unwrap_or((0, 0.0));
w.f32(x as f32);
w.f32(y);
self.write_advancement(&mut w, i, sync);
}
w.varint(0); // nothing removed
w.varint(shown.len() as i32);
for &i in &shown {
let a = &self.tree.list[i];
w.string(&a.id);
w.varint(a.criteria.len() as i32);
for c in &a.criteria {
w.string(&c.name);
let got = obtained[i].contains(&c.name.as_str());
w.bool(got);
if got {
w.i64(now_millis);
}
}
self.write_progress(&mut w, i, |c| {
obtained[i].contains(&c).then_some(now_millis)
});
}
w.bool(true); // show the advancements
FirstTick {
@@ -165,9 +129,176 @@ impl ServerAdvancements {
}
}
/// `PlayerAdvancements.flushDirty`: the advancements that became visible
/// or hidden since `visible` (which it updates; empty before the first
/// packet, which resets the client's and carries everything visible) and
/// the progress of the `changed` ones still visible. None when there is
/// nothing to send.
pub fn update(
&self,
sync: &SyncData,
progress: &Progress,
visible: &mut Vec<bool>,
changed: &BTreeSet<usize>,
show: bool,
) -> Option<Vec<u8>> {
let n = self.tree.list.len();
let first = visible.len() != n;
let now = self
.tree
.visible(&self.kids, |i| progress.done(&self.tree.list[i]));
let was = |i: usize| !first && visible[i];
let added: Vec<usize> = (0..n).filter(|&i| now[i] && !was(i)).collect();
let removed: Vec<usize> = (0..n).filter(|&i| !now[i] && was(i)).collect();
let progressed: Vec<usize> = if first {
(0..n).filter(|&i| now[i]).collect()
} else {
changed
.iter()
.copied()
.filter(|&i| i < n && now[i])
.collect()
};
*visible = now;
if !first && added.is_empty() && removed.is_empty() && progressed.is_empty() {
return None;
}
let mut w = Writer::new();
w.bool(first);
w.varint(added.len() as i32);
for &i in &added {
self.write_advancement(&mut w, i, sync);
}
w.varint(removed.len() as i32);
for &i in &removed {
w.string(&self.tree.list[i].id);
}
w.varint(progressed.len() as i32);
for &i in &progressed {
let id = &self.tree.list[i].id;
self.write_progress(&mut w, i, |c| {
progress
.criteria
.get(id)
.and_then(|m| m.get(c))
.map(|date| crate::advancements::date_millis(date).unwrap_or(0))
});
}
w.bool(show);
Some(w.data)
}
/// Which advancements a player with `progress` may see.
pub fn visibility(&self, progress: &Progress) -> Vec<bool> {
self.tree
.visible(&self.kids, |i| progress.done(&self.tree.list[i]))
}
/// `ClientboundUpdateAdvancementsPacket.PositionedAdvancement`.
fn write_advancement(&self, w: &mut Writer, i: usize, sync: &SyncData) {
let a = &self.tree.list[i];
w.string(&a.id);
w.bool(a.parent.is_some());
if let Some(p) = &a.parent {
w.string(p);
}
w.bool(a.display.is_some());
if let Some(d) = &a.display {
w.nbt(&json_to_nbt(&d.title));
w.nbt(&json_to_nbt(&d.description));
w.varint(sync.id("minecraft:item", &d.icon).unwrap_or(0));
w.varint(1);
w.varint(0);
w.varint(0);
w.varint(match d.frame {
crate::advancements::Frame::Task => 0,
crate::advancements::Frame::Challenge => 1,
crate::advancements::Frame::Goal => 2,
});
w.i32(
i32::from(d.background.is_some())
| i32::from(d.show_toast) << 1
| i32::from(d.hidden) << 2,
);
if let Some(b) = &d.background {
w.string(b);
}
}
w.varint(a.requirements.len() as i32);
for group in &a.requirements {
w.varint(group.len() as i32);
for c in group {
w.string(c);
}
}
w.bool(false); // sends_telemetry_event
let (x, y) = self.layout[i].unwrap_or((0, 0.0));
w.f32(x as f32);
w.f32(y);
}
/// An `AdvancementProgress`: each criterion and when it was obtained.
fn write_progress(&self, w: &mut Writer, i: usize, obtained: impl Fn(&str) -> Option<i64>) {
let a = &self.tree.list[i];
w.string(&a.id);
w.varint(a.criteria.len() as i32);
for c in &a.criteria {
w.string(&c.name);
let at = obtained(&c.name);
w.bool(at.is_some());
if let Some(at) = at {
w.i64(at);
}
}
}
/// `ServerRecipeBook.addRecipes`' packet for recipes just unlocked:
/// each display, with its notification and highlighted. None when no
/// recipe has a display.
pub fn recipes_added(&self, ids: &[String], sync: &SyncData) -> Option<Vec<u8>> {
let entries: Vec<Vec<u8>> = ids
.iter()
.flat_map(|id| self.book_entries(id, sync, NOTIFY_AND_HIGHLIGHT))
.collect();
(!entries.is_empty()).then(|| book_add(entries, false))
}
/// `ServerRecipeBook.sendInitialRecipeBook`: every known recipe, without
/// notifications, highlighted while not yet displayed, replacing the
/// client's book.
pub fn initial_recipe_book(&self, book: &Settings, sync: &SyncData) -> Vec<u8> {
let entries: Vec<Vec<u8>> = book
.recipes
.iter()
.flat_map(|id| {
let highlight = if book.to_be_displayed.contains(id) {
2
} else {
0
};
self.book_entries(id, sync, highlight)
})
.collect();
book_add(entries, true)
}
/// The recipe's display for ClientboundPlaceGhostRecipePacket (the
/// recipes the book lists).
pub fn ghost_display(
&self,
id: &str,
sync: &SyncData,
) -> Option<horizon_protocol::packets::recipes::RecipeDisplay> {
let bytes = self.book_entries(id, sync, 0).into_iter().next()?;
let mut r = horizon_protocol::buf::Reader::new(&bytes);
horizon_protocol::packets::recipes::RecipeBookEntry::read(&mut r)
.ok()
.map(|e| e.display)
}
/// ClientboundRecipeBookAddPacket.Entry for each display of a recipe:
/// highlighted, and with a notification unless the recipe turns it off.
fn book_entries(&self, id: &str, sync: &SyncData) -> Vec<Vec<u8>> {
fn book_entries(&self, id: &str, sync: &SyncData, flags: i8) -> Vec<Vec<u8>> {
let Some(r) = self.recipes.get(id) else {
return Vec::new();
};
@@ -245,12 +376,27 @@ impl ServerAdvancements {
for v in placed {
holder_set(&mut w, v, &item);
}
// The notification only where the recipe shows one.
let notify = r["show_notification"].as_bool().unwrap_or(true);
w.i8(i8::from(notify) | 2);
w.i8(if notify { flags } else { flags & !1 });
vec![w.data]
}
}
/// ClientboundRecipeBookAddPacket.Entry flags: notification, highlight.
const NOTIFY_AND_HIGHLIGHT: i8 = 1 | 2;
/// ClientboundRecipeBookAddPacket: the entries, then whether they replace the book.
fn book_add(entries: Vec<Vec<u8>>, replace: bool) -> Vec<u8> {
let mut w = Writer::new();
w.varint(entries.len() as i32);
for e in entries {
w.data.extend(e);
}
w.bool(replace);
w.data
}
/// ShapedRecipePattern.shrink: the pattern without blank border rows and columns.
fn shrink(rows: &[Value]) -> Vec<Vec<char>> {
let rows: Vec<Vec<char>> = rows
+376 -64
View File
@@ -90,7 +90,78 @@ pub struct ChunkEncoder {
pub block_ids: Vec<i32>,
/// The save's tables, for the heightmaps.
pub tables: Option<Arc<crate::save::SaveTables>>,
/// Sections already looked at, by address (see `Encoded`).
cache: std::sync::Mutex<HashMap<usize, Encoded>>,
/// Each column's heightmaps, with the sections they were made from.
heights: std::sync::Mutex<HashMap<(i32, i32), Heights>>,
/// The level's sections among a column's: all of them in the Overworld,
/// y 0 to 255 in the Nether and the End (`DimensionType` min_y and height).
sections: std::ops::Range<usize>,
/// Whether the level has sky light (`DimensionType.hasSkyLight`).
sky: bool,
}
/// A column's client heightmaps, valid while it holds the same sections.
struct Heights {
sections: Vec<std::sync::Weak<Section>>,
maps: Vec<(i32, Vec<i64>)>,
}
/// What a resident section gives the wire, kept while the section is
/// unchanged. Resident sections are immutable behind `Arc` (edits go through
/// `Arc::make_mut`, which moves an edited section to a new address while a
/// `Weak` points at the old one), and the `Weak` keeps the old address from
/// being reused: the same address is the same, unchanged section.
struct Encoded {
section: std::sync::Weak<Section>,
non_air: bool,
/// LevelChunkSection.write, and whether it holds block entity blocks.
wire: Option<(Arc<[u8]>, bool)>,
/// The section's light as the wire's two DataLayers, when not uniform.
light: Option<Arc<Layers>>,
}
/// One section's light as DataLayers (two blocks per byte, the even one in
/// the low nibble), and whether each holds any light.
struct Layers {
sky: Vec<u8>,
block: Vec<u8>,
sky_any: bool,
block_any: bool,
}
impl Layers {
fn of(light: &Light) -> Self {
let (mut sky, mut block) = (vec![0u8; 2048], vec![0u8; 2048]);
let (sky_any, block_any) = match light {
Light::Uniform(v) => {
let (sk, bl) = (v >> 4, v & 15);
sky.fill(sk | sk << 4);
block.fill(bl | bl << 4);
(sk != 0, bl != 0)
}
Light::Full(a) => {
for ((pair, sky), block) in
a.as_chunks::<2>().0.iter().zip(&mut sky).zip(&mut block)
{
let [even, odd] = *pair;
*sky = even >> 4 | (odd & 0xF0);
*block = (even & 15) | odd << 4;
}
let all = a.iter().fold(0u8, |acc, &v| acc | v);
(all & 0xF0 != 0, all & 15 != 0)
}
};
Self {
sky,
block,
sky_any,
block_any,
}
}
}
/// Sections kept before the ones no longer resident are dropped.
const CACHE_SWEEP: usize = 1 << 15;
impl ChunkEncoder {
/// `biome_names`: the internal biome list in order; `biome_registry`:
/// the synchronized biome entries in network order.
@@ -143,8 +214,106 @@ impl ChunkEncoder {
biome_bits: ceil_log2(biome_registry.len()),
block_ids,
tables: None,
cache: Default::default(),
heights: Default::default(),
sections: 0..SECTIONS,
sky: true,
}
}
/// The client heightmaps (Heightmap.prime over the column), from the
/// cache while the column's sections are unchanged.
fn heightmaps(&self, chunk: &Chunk, tables: &crate::save::SaveTables) -> Vec<(i32, Vec<i64>)> {
let same = |h: &Heights| {
h.sections.len() == chunk.sections.len()
&& h.sections
.iter()
.zip(&chunk.sections)
.all(|(w, s)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(s)))
};
let mut heights = self.heights.lock().unwrap();
if let Some(h) = heights.get(&(chunk.x, chunk.z))
&& same(h)
{
return h.maps.clone();
}
if heights.len() >= CACHE_SWEEP / 4 {
heights.retain(|_, h| h.sections.iter().all(|w| w.strong_count() > 0));
if heights.len() >= CACHE_SWEEP / 8 {
heights.clear();
}
}
let min_y = MIN_Y + 16 * self.sections.start as i32;
let height = 16 * self.sections.len() as i32;
let maps = crate::save::client_heightmaps_in(chunk, tables, min_y, height);
heights.insert(
(chunk.x, chunk.z),
Heights {
sections: chunk.sections.iter().map(Arc::downgrade).collect(),
maps: maps.clone(),
},
);
maps
}
/// The cached entry for a section, made with `non_air` when missing.
fn cached<T>(&self, section: &Arc<Section>, read: impl FnOnce(&mut Encoded) -> T) -> T {
let mut cache = self.cache.lock().unwrap();
if cache.len() >= CACHE_SWEEP {
cache.retain(|_, e| e.section.strong_count() > 0);
if cache.len() >= CACHE_SWEEP / 2 {
cache.clear();
}
}
let entry = cache
.entry(Arc::as_ptr(section) as usize)
.or_insert_with(|| Encoded {
section: Arc::downgrade(section),
non_air: self.non_air(section),
wire: None,
light: None,
});
read(entry)
}
/// LevelChunkSection.hasOnlyAir, negated.
fn non_air(&self, section: &Section) -> bool {
let non_air = |v: u32| self.flags.get(v as usize).copied().unwrap_or(0) & NON_AIR != 0;
// The palette first: most sections are all air, or one block.
if !section.blocks.maybe_has(non_air) {
false
} else if let Some(v) = section.blocks.uniform() {
non_air(v)
} else {
// Stops at the first block, usually near the bottom.
(0..BLOCKS).any(|i| non_air(section.blocks.get(i)))
}
}
/// A section on the wire, from the cache or encoded now.
fn section_wire(&self, section: &Arc<Section>) -> (Arc<[u8]>, bool) {
if let Some(wire) = self.cached(section, |e| e.wire.clone()) {
return wire;
}
let (encoded, entities) = self.section(section);
let mut w = horizon_protocol::buf::Writer::new();
encoded.write(&mut w);
let wire: (Arc<[u8]>, bool) = (w.data.into(), entities);
self.cached(section, |e| e.wire = Some(wire.clone()));
wire
}
/// Encode for a level of `min_y` and `height` (multiples of 16 within
/// the column), with or without sky light.
pub fn set_dimension(&mut self, min_y: i32, height: i32, sky: bool) {
let lo = usize::try_from((min_y - MIN_Y) >> 4)
.unwrap_or(0)
.min(SECTIONS);
let n = usize::try_from(height >> 4).unwrap_or(SECTIONS);
self.sections = lo..(lo + n).min(SECTIONS);
self.sky = sky;
// The heightmaps depend on the level's height.
self.heights.get_mut().unwrap().clear();
}
pub fn entity_type(&self, state: u16) -> Option<i32> {
self.entity_types
.get(usize::from(state))
@@ -170,11 +339,17 @@ impl ChunkEncoder {
}
f & ENTITY != 0
};
let mut unpacked = [0u16; BLOCKS];
section.unpack(&mut unpacked);
for &v in &unpacked {
entities |= count(v, 1);
values.push(i32::from(v));
// One block everywhere (all air, mostly): what from_values would give.
let uniform = section.blocks.uniform();
if let Some(v) = uniform {
entities = count(v as u16, BLOCKS as i16);
} else {
let mut unpacked = [0u16; BLOCKS];
section.unpack(&mut unpacked);
for &v in &unpacked {
entities |= count(v, 1);
values.push(i32::from(v));
}
}
let biome_values: Vec<i32> = (0..BIOMES)
.map(|j| {
@@ -189,7 +364,14 @@ impl ChunkEncoder {
horizon_protocol::chunk::Section {
non_empty,
fluids,
states: Paletted::from_values(&values, Strategy::Blocks, 16),
states: match uniform {
Some(v) => Paletted {
bits: 0,
palette: vec![v as i32],
data: Vec::new(),
},
None => Paletted::from_values(&values, Strategy::Blocks, 16),
},
biomes: Paletted::from_values(&biome_values, Strategy::Biomes, self.biome_bits),
},
entities,
@@ -200,13 +382,8 @@ impl ChunkEncoder {
/// The non-air sections of a column as bits (LevelChunkSection.hasOnlyAir).
fn occupied(&self, chunk: &Chunk) -> u64 {
let mut bits = 0u64;
for (i, s) in chunk.sections.iter().enumerate() {
let mut unpacked = [0u16; BLOCKS];
s.unpack(&mut unpacked);
let any = unpacked
.iter()
.any(|&v| self.flags.get(usize::from(v)).copied().unwrap_or(0) & NON_AIR != 0);
if any {
for (i, s) in chunk.sections[self.sections.clone()].iter().enumerate() {
if self.cached(s, |e| e.non_air) {
bits |= 1 << i;
}
}
@@ -224,8 +401,8 @@ impl ChunkEncoder {
}
}
}
let mut packet = self.encode(chunk, &world.block_entities);
packet.light = light_data(chunk, around);
let mut packet = self.encode_blocks(chunk, &world.block_entities);
packet.light = self.light_data(chunk, around);
packet
}
@@ -234,10 +411,27 @@ impl ChunkEncoder {
chunk: &Chunk,
block_entities: &HashMap<(i32, i32, i32), BlockEntity>,
) -> LevelChunkWithLight {
let mut sections = Vec::with_capacity(SECTIONS);
let mut packet = self.encode_blocks(chunk, block_entities);
packet.light = self.light_data(chunk, u64::MAX);
packet
}
/// The packet without its light.
fn encode_blocks(
&self,
chunk: &Chunk,
block_entities: &HashMap<(i32, i32, i32), BlockEntity>,
) -> LevelChunkWithLight {
let mut buffer = Vec::new();
let mut entity_infos = Vec::new();
for (i, s) in chunk.sections.iter().enumerate() {
let (encoded, has_entities) = self.section(s);
for (i, s) in chunk
.sections
.iter()
.enumerate()
.skip(self.sections.start)
.take(self.sections.len())
{
let (encoded, has_entities) = self.section_wire(s);
if has_entities {
for y in 0..16 {
for z in 0..16 {
@@ -261,7 +455,7 @@ impl ChunkEncoder {
}
}
}
sections.push(encoded);
buffer.extend_from_slice(&encoded);
}
// A loaded chunk's block entities enter its maps in the save's order
// (ChunkAccess.pendingBlockEntities, then LevelChunk.blockEntities):
@@ -293,14 +487,46 @@ impl ChunkEncoder {
heightmaps: self
.tables
.as_ref()
.map(|t| crate::save::client_heightmaps(chunk, t))
.map(|t| self.heightmaps(chunk, t))
.unwrap_or_default(),
buffer: ChunkData::from_sections(&sections),
buffer,
block_entities: entity_infos,
},
light: light_data(chunk, u64::MAX),
light: LightData {
sky_mask: Vec::new(),
block_mask: Vec::new(),
empty_sky_mask: Vec::new(),
empty_block_mask: Vec::new(),
sky: Vec::new(),
block: Vec::new(),
},
}
}
/// ClientboundLightUpdatePacketData: the light layers of the sections
/// that exist around the column (`around`: the non-air sections of it
/// and its neighbours, as bits). A resident section's layers come from
/// the cache while it is unchanged (uniform light is quicker to fill than
/// to keep).
fn light_data(&self, chunk: &Chunk, around: u64) -> LightData {
let mut data = light_data_with(&chunk.sections[self.sections.clone()], around, |s| {
if matches!(s.light, Light::Uniform(_)) {
return Arc::new(Layers::of(&s.light));
}
self.cached(s, |e| e.light.clone()).unwrap_or_else(|| {
let layers = Arc::new(Layers::of(&s.light));
self.cached(s, |e| e.light = Some(layers.clone()));
layers
})
});
if !self.sky {
// No sky light storage at all.
data.sky_mask.clear();
data.empty_sky_mask.clear();
data.sky.clear();
}
data
}
}
/// The order LevelChunk.getBlockEntities() iterates in: a fastutil
@@ -373,7 +599,18 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
/// non-air section of this column or a neighbouring one (`around`, bit i for
/// section i; LayerLightSectionStorage.updateSectionStatus). An existing
/// layer is sent, or marked empty when all dark; the others are left out.
fn light_data(chunk: &Chunk, around: u64) -> LightData {
#[cfg(test)]
fn light_data(sections: &[Arc<Section>], around: u64) -> LightData {
light_data_with(sections, around, |s| Arc::new(Layers::of(&s.light)))
}
/// The light layers, `section` giving a resident section's.
fn light_data_with(
sections: &[Arc<Section>],
around: u64,
section: impl Fn(&Arc<Section>) -> Arc<Layers>,
) -> LightData {
let n = sections.len();
let mut data = LightData {
sky_mask: Vec::new(),
block_mask: Vec::new(),
@@ -383,55 +620,27 @@ fn light_data(chunk: &Chunk, around: u64) -> LightData {
block: Vec::new(),
};
// Light section b holds chunk section b - 1.
let exists = |b: usize| {
(b.saturating_sub(2)..=b.min(SECTIONS - 1)).any(|s| s + 2 >= b && around >> s & 1 != 0)
};
let full_sky = Light::Uniform(0xF0);
for b in 0..SECTIONS + 2 {
let exists =
|b: usize| (b.saturating_sub(2)..=b.min(n - 1)).any(|s| s + 2 >= b && around >> s & 1 != 0);
for b in 0..n + 2 {
if !exists(b) {
continue;
}
let light = match b {
let layers = match b {
// Below the world nothing is lit; above it the sky is full.
0 => &Light::Uniform(0),
_ if b == SECTIONS + 1 => &full_sky,
_ => &chunk.sections[b - 1].light,
0 => Arc::new(Layers::of(&Light::Uniform(0))),
_ if b == n + 1 => Arc::new(Layers::of(&Light::Uniform(0xF0))),
_ => section(&sections[b - 1]),
};
let (mut sky, mut block) = (vec![0u8; 2048], vec![0u8; 2048]);
let (mut sky_any, mut block_any) = (false, false);
match light {
Light::Uniform(v) => {
let (sk, bl) = (v >> 4, v & 15);
sky_any = sk != 0;
block_any = bl != 0;
let (sb, bb) = (sk | sk << 4, bl | bl << 4);
sky.fill(sb);
block.fill(bb);
}
Light::Full(a) => {
for (j, &v) in a.iter().enumerate() {
let (sk, bl) = (v >> 4, v & 15);
sky_any |= sk != 0;
block_any |= bl != 0;
if j & 1 == 0 {
sky[j >> 1] |= sk;
block[j >> 1] |= bl;
} else {
sky[j >> 1] |= sk << 4;
block[j >> 1] |= bl << 4;
}
}
}
}
if sky_any {
if layers.sky_any {
set_bit(&mut data.sky_mask, b);
data.sky.push(sky);
data.sky.push(layers.sky.clone());
} else {
set_bit(&mut data.empty_sky_mask, b);
}
if block_any {
if layers.block_any {
set_bit(&mut data.block_mask, b);
data.block.push(block);
data.block.push(layers.block.clone());
} else {
set_bit(&mut data.empty_block_mask, b);
}
@@ -460,16 +669,119 @@ mod tests {
assert_eq!(block_entity_type("minecraft:stone"), None);
}
fn encoder() -> ChunkEncoder {
let states = ["minecraft:air", "minecraft:stone", "minecraft:water"]
.map(|n| (Arc::<str>::from(n), n.ends_with("water")));
ChunkEncoder::new(states, &Builtin::default(), &[], &[])
}
/// The bytes on the wire for a section, encoded afresh.
fn fresh(encoder: &ChunkEncoder, section: &Section) -> Vec<u8> {
let mut w = horizon_protocol::buf::Writer::new();
encoder.section(section).0.write(&mut w);
w.data
}
#[test]
fn cached_sections_follow_edits() {
let encoder = encoder();
let mut chunk = Chunk::empty(0, 0);
chunk.set(1, MIN_Y + 2, 3, 1);
let before = encoder.encode(&chunk, &HashMap::new());
assert_eq!(encoder.occupied(&chunk), 1);
assert_eq!(
&*encoder.section_wire(&chunk.sections[0]).0,
fresh(&encoder, &chunk.sections[0])
);
// An edit moves the section: the cache sees a new one.
chunk.set(4, MIN_Y + 20, 4, 2);
chunk.set(1, MIN_Y + 2, 3, 0);
assert_eq!(encoder.occupied(&chunk), 2);
let after = encoder.encode(&chunk, &HashMap::new());
assert_ne!(before.chunk.buffer, after.chunk.buffer);
let mut expected = Vec::new();
for s in &chunk.sections {
expected.extend(fresh(&encoder, s));
}
assert_eq!(after.chunk.buffer, expected);
}
#[test]
fn light_layers_pack_two_blocks_per_byte() {
let mut chunk = Chunk::empty(0, 0);
let mut light = Box::new([0u8; 4096]);
for (i, v) in light.iter_mut().enumerate() {
*v = (i * 37 % 251) as u8;
}
Arc::make_mut(&mut chunk.sections[3]).light = Light::Full(light.clone());
let data = light_data(&chunk.sections, u64::MAX);
// Layer 4 holds section 3. Below the world (layer 0) there is no sky
// light, so the sky layers listed start at layer 1; section 3 has the
// only block light.
let (sky, block) = (&data.sky[3], &data.block[0]);
for j in 0..4096 {
let (s, b) = if j & 1 == 0 {
(sky[j >> 1] & 15, block[j >> 1] & 15)
} else {
(sky[j >> 1] >> 4, block[j >> 1] >> 4)
};
assert_eq!((s, b), (light[j] >> 4, light[j] & 15), "block {j}");
}
}
#[test]
fn cached_light_follows_edits() {
let encoder = encoder();
let mut chunk = Chunk::empty(0, 0);
let mut light = Box::new([0x5Au8; 4096]);
Arc::make_mut(&mut chunk.sections[2]).light = Light::Full(light.clone());
let first = encoder.encode(&chunk, &HashMap::new()).light;
assert_eq!(first, light_data(&chunk.sections, u64::MAX));
// Relighting moves the section, so the cached layers aren't reused.
light[7] = 0xF3;
Arc::make_mut(&mut chunk.sections[2]).light = Light::Full(light);
let second = encoder.encode(&chunk, &HashMap::new()).light;
assert_ne!(first, second);
assert_eq!(second, light_data(&chunk.sections, u64::MAX));
}
#[test]
fn light_masks_cover_the_world_plus_borders() {
let chunk = Chunk::empty(0, 0);
// A column with nothing around it has no light layers at all.
assert!(light_data(&chunk, 0).sky.is_empty());
let data = light_data(&chunk, u64::MAX);
assert!(light_data(&chunk.sections, 0).sky.is_empty());
let data = light_data(&chunk.sections, u64::MAX);
// Default sections are full sky light and no block light.
assert_eq!(data.sky.len(), SECTIONS + 1);
assert_eq!(data.block.len(), 0);
assert_eq!(data.sky[0][0], 0xFF);
assert!(horizon_protocol::buf::bit(&data.empty_block_mask, 5));
}
#[test]
fn a_nether_column_sends_its_sixteen_sections_without_sky_light() {
let chunk = Chunk::empty(0, 0);
let mut encoder = ChunkEncoder::new(
[("minecraft:air".into(), false)],
&Builtin::default(),
&[],
&[],
);
let overworld = encoder.encode(&chunk, &HashMap::new());
encoder.set_dimension(0, 256, false);
let nether = encoder.encode(&chunk, &HashMap::new());
// An all-air section is the same bytes wherever it is.
let per_section = overworld.chunk.buffer.len() / SECTIONS;
assert_eq!(nether.chunk.buffer.len(), per_section * 16);
assert!(nether.light.sky.is_empty() && nether.light.sky_mask.is_empty());
// Block light layers: one below and one above the sixteen sections.
assert!(horizon_protocol::buf::bit(
&nether.light.empty_block_mask,
17
));
assert!(!horizon_protocol::buf::bit(
&nether.light.empty_block_mask,
18
));
}
}
+279 -19
View File
@@ -1,4 +1,5 @@
//! One accepted connection: handshake, status ping, offline-mode login and the
//! One accepted connection: handshake, status ping, login (offline, or online
//! with encryption and the session service's verdict) and the
//! configuration phase (ServerStatusPacketListenerImpl,
//! ServerLoginPacketListenerImpl, ServerConfigurationPacketListenerImpl). A
//! player that completes configuration continues in the play state: the
@@ -12,7 +13,7 @@ use super::{
use horizon_protocol::{
PROTOCOL_VERSION,
connection::{ConnReader, ConnWriter, Connection},
crypto::offline_uuid,
crypto::{offline_uuid, server_hash, uuid_string},
packets::{
Empty,
configuration::{self as cfg, ClientInformation, KnownPack},
@@ -21,10 +22,11 @@ use horizon_protocol::{
text::translatable,
};
use std::{
cell::Cell,
net::TcpStream,
sync::{
Arc,
atomic::{AtomicI32, AtomicI64, AtomicUsize, Ordering},
Arc, Mutex, PoisonError,
atomic::{AtomicI32, AtomicI64, AtomicU32, AtomicUsize, Ordering},
mpsc::{Receiver, RecvTimeoutError, Sender, channel},
},
time::{Duration, Instant},
@@ -122,16 +124,64 @@ pub fn valid_name(name: &str) -> bool {
(1..=16).contains(&name.len()) && name.chars().all(|c| c as u32 > 32 && (c as u32) < 127)
}
thread_local! {
static HOLDING: Cell<bool> = const { Cell::new(false) };
}
/// While one is alive, the packets this thread sends to each player are held
/// and handed to the player's writer thread together by
/// [`PlayerSender::flush`]: one wake-up per player per tick rather than one
/// per packet. The bytes and their order don't change.
pub struct Holding(bool);
impl Holding {
pub fn start() -> Self {
Self(HOLDING.replace(true))
}
}
impl Drop for Holding {
fn drop(&mut self) {
HOLDING.set(self.0);
}
}
/// The sending half given to the game thread.
#[derive(Clone)]
pub struct PlayerSender {
pub tx: Sender<Out>,
pub pending: Arc<AtomicUsize>,
held: Arc<Mutex<Vec<Vec<u8>>>>,
}
impl PlayerSender {
pub fn new(tx: Sender<Out>, pending: Arc<AtomicUsize>) -> Self {
Self {
tx,
pending,
held: Arc::default(),
}
}
fn held(&self) -> std::sync::MutexGuard<'_, Vec<Vec<u8>>> {
self.held.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn send(&self, body: Vec<u8>) {
self.pending.fetch_add(body.len(), Ordering::Relaxed);
let _ = self.tx.send(Out::Packet(body));
if HOLDING.get() {
self.held().push(body);
} else {
self.flush();
let _ = self.tx.send(Out::Packet(body));
}
}
/// Hand the held packets to the writer thread.
pub fn flush(&self) {
let held = std::mem::take(&mut *self.held());
if !held.is_empty() {
let _ = self.tx.send(Out::Packets(held));
}
}
/// Send a play disconnect with this reason after what was sent before.
pub fn disconnect(&self, reason: horizon_world::nbt::Tag) {
self.flush();
let _ = self.tx.send(Out::Disconnect(reason));
}
}
@@ -305,6 +355,145 @@ fn serve_status(c: &mut Connection, shared: &Shared) -> Result<(), String> {
}
}
/// ServerLoginPacketListenerImpl.getUserName: the name and the address
/// (Connection.getLoggableAddress, `/ip:port` as InetSocketAddress prints it).
fn user_name(name: &str, address: std::net::SocketAddr, log: &super::NetLog) -> String {
if log.log_ips {
format!("{name} (/{address})")
} else {
format!("{name} (<ip address withheld>)")
}
}
/// ServerLoginPacketListenerImpl.disconnect: log the reason's text and send
/// it. Returns the text, for the "lost connection" line that follows.
fn refuse(
c: &mut Connection,
shared: &Shared,
thread: &str,
user: &str,
reason: &horizon_world::nbt::Tag,
) -> String {
let text = shared
.config
.log
.as_ref()
.map(|log| horizon_protocol::text::plain(reason, &log.lang))
.unwrap_or_default();
if let Some(log) = &shared.config.log {
(log.write)(thread, "INFO", &format!("Disconnecting {user}: {text}"));
}
login_disconnect(c, reason);
text
}
/// ServerLoginPacketListenerImpl.onDisconnect, once the server thread sees
/// the connection closed: by a refusal, or by the client ("Disconnected").
fn lost(shared: &Shared, user: &str, text: &str) {
if let Some(log) = &shared.config.log {
(log.write)(
"Server thread",
"INFO",
&format!("{user} lost connection: {text}"),
);
}
}
/// UNIQUE_THREAD_ID: the User Authenticator threads are numbered from 1.
static AUTHENTICATORS: AtomicU32 = AtomicU32::new(0);
/// ServerLoginPacketListenerImpl.handleKey and the User Authenticator
/// thread: check the client's answer to the encryption request, encrypt the
/// connection, and ask the session service whether the player joined with
/// this server's hash. The profile, or the text of the refusal.
fn authenticate(
c: &mut Connection,
shared: &Shared,
online: &super::OnlineMode,
name: &str,
address: std::net::SocketAddr,
user: &str,
) -> Result<Result<(u128, Vec<login::Property>), String>, String> {
let e = |e: horizon_protocol::Error| e.to_string();
let mut challenge = [0u8; 4];
horizon_protocol::crypto::random_bytes(&mut challenge).map_err(e)?;
c.send(
&login::Clientbound::Hello(login::Hello {
server_id: String::new(),
public_key: online.public_der.clone(),
challenge: challenge.to_vec(),
should_authenticate: true,
})
.encode(),
)
.map_err(e)?;
let (id, payload) = c.recv().map_err(e)?;
let key = match login::Serverbound::decode(id, &payload).map_err(e)? {
login::Serverbound::Key(k) => k,
_ => return Err("Unexpected packet during login".into()),
};
// ServerboundKeyPacket.isChallengeValid and getSecretKey.
let protocol_error = || "Protocol error".to_owned();
if online.key.decrypt(&key.challenge).ok().as_deref() != Some(&challenge[..]) {
return Err(protocol_error());
}
let secret: [u8; 16] = online
.key
.decrypt(&key.key)
.ok()
.and_then(|s| s.try_into().ok())
.ok_or_else(protocol_error)?;
let digest = server_hash("", &secret, &online.public_der);
c.enable_encryption(&secret);
let thread = format!(
"User Authenticator #{}",
AUTHENTICATORS.fetch_add(1, Ordering::Relaxed) + 1
);
let log = |level: &str, line: &str| {
if let Some(log) = &shared.config.log {
(log.write)(&thread, level, line);
}
};
let ip = online.prevent_proxy.then(|| address.ip());
match crate::auth::has_joined(name, &digest, ip) {
Ok(Some(profile)) => {
log(
"INFO",
&format!("UUID of player {name} is {}", uuid_string(profile.uuid)),
);
Ok(Ok((profile.uuid, profile.properties)))
}
Ok(None) => {
let text = refuse(
c,
shared,
&thread,
user,
&translatable("multiplayer.disconnect.unverified_username", &[]),
);
log(
"ERROR",
&format!("Username '{name}' tried to join with an invalid session"),
);
Ok(Err(text))
}
Err(_) => {
let text = refuse(
c,
shared,
&thread,
user,
&translatable("multiplayer.disconnect.authservers_down", &[]),
);
log(
"ERROR",
"Couldn't verify username because servers are unavailable",
);
Ok(Err(text))
}
}
}
fn serve_login(
mut c: Connection,
address: std::net::SocketAddr,
@@ -323,14 +512,36 @@ fn serve_login(
);
return Ok(());
}
// Offline mode: the profile is derived from the name (PlayerList.canPlayerLogin
// then rejects duplicates and a full server).
let uuid = offline_uuid(&hello.name);
let user = shared
.config
.log
.as_ref()
.map(|log| user_name(&hello.name, address, log))
.unwrap_or_default();
// handleHello: online mode authenticates first; offline mode derives the
// profile from the name.
let (uuid, properties) = match &shared.config.online {
Some(online) => match authenticate(&mut c, shared, online, &hello.name, address, &user) {
Ok(Ok(profile)) => profile,
Ok(Err(refused)) => {
lost(shared, &user, &refused);
return Ok(());
}
Err(err) => {
lost(shared, &user, "Disconnected");
return Err(err);
}
},
None => (offline_uuid(&hello.name), Vec::new()),
};
// verifyLoginAndFinishConnectionSetup: PlayerList.canPlayerLogin, then
// duplicates and a full server.
if let Some(reason) = shared.try_admit(address, uuid, &hello.name) {
login_disconnect(&mut c, &reason);
let text = refuse(&mut c, shared, "Server thread", &user, &reason);
lost(shared, &user, &text);
return Ok(());
}
let result = login_and_configure(&mut c, shared, uuid, &hello.name);
let result = login_and_configure(&mut c, shared, uuid, &hello.name, properties);
match result {
Ok(Some(parts)) => {
play(c, shared, parts, address);
@@ -342,6 +553,9 @@ fn serve_login(
}
Err(err) => {
shared.release(uuid);
// ServerConfigurationPacketListenerImpl.onDisconnect.
let profile = format!("{} ({})", hello.name, uuid_string(uuid));
lost(shared, &profile, "Disconnected");
Err(err)
}
}
@@ -350,6 +564,7 @@ fn serve_login(
struct Parts {
uuid: u128,
name: String,
properties: Vec<login::Property>,
info: ClientInformation,
brand: String,
}
@@ -359,10 +574,12 @@ fn login_and_configure(
shared: &Arc<Shared>,
uuid: u128,
name: &str,
properties: Vec<login::Property>,
) -> Result<Option<Parts>, String> {
let e = |e: horizon_protocol::Error| e.to_string();
// ServerLoginPacketListenerImpl: memory connections are not compressed.
let threshold = shared.config.compression_threshold;
if threshold >= 0 {
if threshold >= 0 && !c.is_memory() {
c.send(&login::Clientbound::Compression(login::Compression { threshold }).encode())
.map_err(e)?;
c.set_compression(threshold);
@@ -372,7 +589,7 @@ fn login_and_configure(
profile: login::GameProfile {
uuid,
name: name.to_owned(),
properties: Vec::new(),
properties: properties.clone(),
},
session: uuid,
})
@@ -451,6 +668,7 @@ fn login_and_configure(
Ok(Some(Parts {
uuid,
name: name.to_owned(),
properties,
info,
brand,
}))
@@ -465,10 +683,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
let pending = Arc::new(AtomicUsize::new(0));
let latency = Arc::new(AtomicI32::new(0));
let alive = Arc::new(AtomicI64::new(-1));
let sender = PlayerSender {
tx: tx.clone(),
pending: pending.clone(),
};
let sender = PlayerSender::new(tx.clone(), pending.clone());
let writer_thread = {
let (alive, latency, pending) = (alive.clone(), latency.clone(), pending.clone());
std::thread::Builder::new()
@@ -480,6 +695,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
conn: conn_id,
uuid: parts.uuid,
name: parts.name,
properties: parts.properties,
info: parts.info,
brand: parts.brand,
sender,
@@ -543,13 +759,18 @@ fn write_loop(
let mut sent_at: Option<(i64, Instant)> = None;
let mut next = Instant::now() + KEEP_ALIVE_EVERY;
loop {
let mut write = |body: Vec<u8>| {
pending.fetch_sub(body.len(), Ordering::Relaxed);
writer.send(&body).is_ok() && pending.load(Ordering::Relaxed) <= MAX_PENDING
};
match rx.recv_timeout(Duration::from_millis(200)) {
Ok(Out::Packet(body)) => {
pending.fetch_sub(body.len(), Ordering::Relaxed);
if writer.send(&body).is_err() {
if !write(body) {
break;
}
if pending.load(Ordering::Relaxed) > MAX_PENDING {
}
Ok(Out::Packets(bodies)) => {
if !bodies.into_iter().all(&mut write) {
break;
}
}
@@ -593,3 +814,42 @@ fn write_loop(
}
writer.shutdown();
}
#[cfg(test)]
mod tests {
use super::*;
fn bodies(rx: &Receiver<Out>) -> Vec<Vec<Vec<u8>>> {
rx.try_iter()
.map(|out| match out {
Out::Packet(body) => vec![body],
Out::Packets(bodies) => bodies,
Out::Disconnect(_) => vec![b"disconnect".to_vec()],
Out::Close => vec![b"close".to_vec()],
})
.collect()
}
#[test]
fn held_packets_go_together_and_in_order() {
let (tx, rx) = channel();
let sender = PlayerSender::new(tx, Arc::default());
sender.send(vec![1]);
assert_eq!(bodies(&rx), [vec![vec![1]]]);
let holding = Holding::start();
sender.send(vec![2]);
sender.send(vec![3]);
assert!(bodies(&rx).is_empty());
drop(holding);
// A packet sent after the tick still follows the held ones.
sender.send(vec![4]);
assert_eq!(bodies(&rx), [vec![vec![2], vec![3]], vec![vec![4]]]);
let holding = Holding::start();
sender.send(vec![5]);
sender.disconnect(horizon_world::nbt::Tag::Byte(0));
drop(holding);
sender.flush();
assert_eq!(bodies(&rx), [vec![vec![5]], vec![b"disconnect".to_vec()]]);
assert_eq!(sender.pending.load(Ordering::Relaxed), 5);
}
}
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff. Show more